一、问题背景:Context用着用着就变味了
项目是一个React 18.2 + TypeScript 5.0的中后台系统,大约120个页面组件。最初为了“少引依赖”,状态管理用的是最朴素的方式:就近用Props,跨层级用Context。刚开始很舒服,但随着业务膨胀,问题集中爆发:
- Provider地狱。AuthContext、ThemeContext、PermissionContext、GlobalConfigContext、NotificationContext五层嵌套包在App外层,每加一个全局状态就要动一次根组件,新同事看一眼main.tsx就头大。
- 重渲染失控。Context的value一旦变化,所有消费该Context的组件都会重渲染,哪怕它只关心value里的某一个字段。我们有个GlobalConfigContext存了主题、语言、侧边栏折叠、表格密度等7个字段,改一下侧边栏折叠,整个布局树全部重渲染。用React DevTools Profiler测了一次,单次切换触发了140+次组件render。
- 逻辑分散。每个Context都要手写Provider、useXxx hook、类型定义,异步请求逻辑散落在各个组件里,重复代码多。
于是决定迁移到专门的状态管理库。技术栈是React,所以对比范围锁定在React生态。
二、环境与版本
- React 18.2.0 / ReactDOM 18.2.0
- TypeScript 5.0.4
- Vite 4.4.9
- 迁移前:React Context(原生)
- 候选库:Zustand 4.4.7、Redux Toolkit 1.9.5、Jotai 2.4.2
- 最终选择:Zustand 4.4.7
方案对比我们拉了个表,重点看三件事:样板代码量、重渲染控制能力、学习成本。
| 维度 | Context | Redux Toolkit | Jotai | Zustand |
|---|---|---|---|---|
| 样板代码 | 中(每个Context一套) | 高(slice/store/provider) | 低 | 低 |
| 重渲染控制 | 差(value变全量重渲) | 好(useSelector) | 好(原子级) | 好(selector+浅比较) |
| 异步支持 | 手写 | createAsyncThunk | 手写 | action内直接async |
| 包体积(gzip) | 0 | ~13KB | ~4KB | ~1.2KB |
| 学习成本 | 低 | 中高 | 中 | 低 |
Redux Toolkit能力强但对这个项目偏重;Jotai的原子模型很优雅,但团队对“原子拆分粒度”没经验,容易拆过头;Zustand的API最接近我们原来写Context hook的习惯,迁移成本最低,且不需要Provider包裹(这点对减少嵌套很关键)。最终选Zustand。
三、方案设计:Store怎么切
迁移不是把Context一比一翻译成store,那样只是换了个壳。我们按“更新频率 + 消费范围”重新划分:
- userStore:登录用户信息、token、权限列表。低频更新,全局消费。
- appStore:主题、语言、侧边栏折叠、表格密度。中频更新,布局层消费。
- notificationStore:全局消息、未读数。高频更新,但只有顶栏铃铛消费。
原则是:一个store内字段的更新频率尽量接近,避免高频字段拖累低频字段的订阅者。Context时代我们把这些全塞一个GlobalConfigContext里,正是重渲染的根源。
目录结构:
src/store/
index.ts
userStore.ts
appStore.ts
notificationStore.ts
四、核心实现
4.1 定义store(含selector与浅比较)
以appStore为例。关键是导出细粒度selector,组件只订阅自己用到的字段:
// src/store/appStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
export type ThemeMode = 'light' | 'dark';
export type TableDensity = 'default' | 'middle' | 'small';
interface AppState {
theme: ThemeMode;
locale: 'zh-CN' | 'en-US';
sidebarCollapsed: boolean;
tableDensity: TableDensity;
setTheme: (t: ThemeMode) => void;
toggleSidebar: () => void;
setTableDensity: (d: TableDensity) => void;
}
export const useAppStore = create()(
devtools(
persist(
(set) => ({
theme: 'light',
locale: 'zh-CN',
sidebarCollapsed: false,
tableDensity: 'default',
setTheme: (theme) => set({ theme }),
toggleSidebar: () =>
set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setTableDensity: (tableDensity) => set({ tableDensity }),
}),
{
name: 'app-store',
// 只持久化配置项,不持久化方法
partialize: (s) => ({
theme: s.theme,
locale: s.locale,
sidebarCollapsed: s.sidebarCollapsed,
tableDensity: s.tableDensity,
}),
version: 1,
}
),
{ name: 'AppStore', enabled: import.meta.env.DEV }
)
);
// 细粒度selector,组件按需订阅
export const useTheme = () => useAppStore((s) => s.theme);
export const useSidebarCollapsed = () => useAppStore((s) => s.sidebarCollapsed);
// 需要一次取多个字段时,务必配合shallow,否则每次返回新对象会无限重渲
export const useLayoutConfig = () =>
useAppStore(
(s) => ({ theme: s.theme, sidebarCollapsed: s.sidebarCollapsed }),
shallow
);
这里有两个细节值得强调:partialize把方法和不需要持久化的字段排除,避免localStorage里存函数;shallow是防止“选择器返回新对象导致每次都判定为变化”的关键,后面踩坑部分会细说。
4.2 异步action与组件接入
userStore里放登录和拉取用户信息的异步逻辑,Zustand允许直接在action里写async:
// src/store/userStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { fetchProfile, login as loginApi } from '@/api/auth';
interface UserInfo {
id: string;
name: string;
avatar: string;
}
interface UserState {
token: string | null;
profile: UserInfo | null;
permissions: string[];
loading: boolean;
login: (username: string, password: string) => Promise;
logout: () => void;
}
export const useUserStore = create()(
devtools(
(set, get) => ({
token: null,
profile: null,
permissions: [],
loading: false,
login: async (username, password) => {
set({ loading: true });
try {
const { token } = await loginApi({ username, password });
// 先落token,再拿profile,避免并发请求时token还没写进去
set({ token });
const { profile, permissions } = await fetchProfile(token);
set({ profile, permissions, loading: false });
} catch (e) {
set({ loading: false });
throw e;
}
},
logout: () => set({ token: null, profile: null, permissions: [] }),
}),
{ name: 'UserStore', enabled: import.meta.env.DEV }
)
);
export const useUserProfile = () => useUserStore((s) => s.profile);
export const usePermissions = () => useUserStore((s) => s.permissions);
组件侧从原来的useContext(GlobalConfigContext).sidebarCollapsed改成useSidebarCollapsed(),改动很小:
// 迁移前
// const { sidebarCollapsed, toggleSidebar } = useContext(GlobalConfigContext);
// 迁移后
import { useSidebarCollapsed } from '@/store/appStore';
import { useAppStore } from '@/store/appStore';
function Sider() {
const collapsed = useSidebarCollapsed();
// 方法引用稳定,单独取不会引发额外订阅
const toggle = useAppStore((s) => s.toggleSidebar);
return ;
}
注意toggleSidebar这种函数在store创建时引用就固定了,单独select它不会因其他字段变化而重渲染,这是Context做不到的(Context里整个value对象一变,拿方法的组件也跟着重渲)。
五、踩坑与优化
坑1:选择器返回对象没加shallow,导致无限重渲染。 一开始写useAppStore(s => ({a: s.a, b: s.b})),每次render返回新对象,Zustand默认用Object.is比较,判定为变化,于是重渲染,再返回新对象……直接Maximum update depth exceeded。解决:要么拆成多个单字段selector,要么加shallow。我们统一规范是“多字段必加shallow”。
坑2:DevTools导致action参数序列化卡顿。 开了redux-devtools扩展后,某个批量更新action携带了上千条记录,序列化明显卡顿。用enabled: import.meta.env.DEV只在开发环境开,生产关闭,问题消失。
坑3:persist水合时机。 首屏从localStorage恢复状态前,组件已经用默认值渲染了一帧,出现主题闪烁。用persist的onRehydrateStorage配合一个hasHydrated标记,在根组件等水合完成再渲染,闪烁消除。
坑4:迁移顺序。 我们按“从叶子到根”迁:先迁notificationStore(只有顶栏用),再迁appStore(布局层),最后迁userStore(涉及路由守卫)。每迁一个跑一遍回归。切忌一次性全换,出问题难定位。
优化: 对高频更新的notificationStore,未读数用单独的selector,并把铃铛组件用React.memo包一层;列表数据不进store,留在组件本地state,只把“跨页面共享”的部分放进store,避免store变成第二个全局变量桶。
六、效果数据
用React DevTools Profiler和Lighthouse各测了3次取平均(Chrome 118,本地dev build关掉,production build):
| 指标 | 迁移前(Context) | 迁移后(Zustand) | 变化 |
|---|---|---|---|
| 首屏渲染耗时 | 420ms | 280ms | -33% |
| 切换侧边栏触发render次数 | 142 | 45 | -68% |
| Provider嵌套层数 | 5 | 1(仅主题/国际化Provider) | -80% |
| 每次全局状态更新的commit耗时 | 38ms | 12ms | -68% |
| 状态相关样板代码行数 | ~620 | ~310 | -50% |
重渲染次数下降主要来自两点:一是细粒度selector让无关组件不再被牵连;二是去掉了Provider嵌套,减少了Context value变更的传播链路。首屏提升一部分来自包体积(Context方案里手写的Provider逻辑被精简)和渲染次数减少。
七、总结
Context不是不能用,它适合“低频、小范围、字段少”的共享状态。一旦出现“多字段混装 + 高频更新 + 全局消费”,就该考虑专用库了。Zustand对我们的价值不在于“新潮”,而在于selector带来的精确订阅和去掉Provider后的结构简化,迁移成本也低——大部分组件只改一行hook调用。
给准备迁移的同学几条建议:按更新频率拆store;多字段selector一定加shallow;异步action里注意set的时序;persist记得处理水合闪烁;迁移从叶子组件开始,逐个验证。别指望一次迁移解决所有性能问题,store里塞太多东西,照样会变成新的“全局变量地狱”。