**
一、问题背景:Context 不是状态管理库
先说结论:Context 是依赖注入工具,不是状态管理方案。这个坑我踩了两年。
项目是一个中后台管理系统,React 18.2 + TypeScript 5.0 + Vite 4.3。早期页面少,用 Props 传数据完全够用。后来模块膨胀到 12 个业务域、300+ 组件,我们开始用 Context 抽全局状态:
// 迁移前的典型写法
const UserContext = createContext(null!);
const ThemeContext = createContext(null!);
const PermissionContext = createContext(null!);
// ... 一共 7 个 Context 嵌套
问题出现在用户信息更新时。我们的 UserProvider value 是一个对象字面量:
每次 UserProvider 自身 re-render,value 引用就变,所有消费该 Context 的组件无论是否用到 user 都会重渲染。我们用 React DevTools Profiler 测了一次「切换用户头像」操作:
- 提交阶段耗时:620ms
- 参与渲染组件数:217 个
- 其中真正依赖 user 数据的:23 个
也就是说,89% 的渲染是浪费的。更麻烦的是 Context 嵌套地狱,调试时根本不知道某个 state 从哪来。
二、环境与版本
| 依赖 | 版本 |
|---|---|
| React | 18.2.0 |
| TypeScript | 5.0.4 |
| Vite | 4.3.9 |
| 原状态方案 | Context + useReducer |
| 候选库 | Zustand 4.4.1 / Redux Toolkit 1.9.5 / Jotai 2.4.2 |
选型时我拉了三个指标:包体积(gzip)、样板代码量、selector 粒度。
| 方案 | 体积 | 样板 | selector 优化 |
|---|---|---|---|
| Redux Toolkit | 13.2kb | 高(slice + store + hook) | 需 reselect |
| Zustand | 1.2kb | 低 | 内置 shallow |
| Jotai | 3.8kb | 中 | 原子级天然细 |
最后选 Zustand,理由很直接:迁移成本最低,体积最小,不需要 Provider 包裹。Jotai 的原子模型很好,但我们已有大量对象型 state,改造成本高。
三、方案设计:分三步走
我没有一次性全量替换,而是按「读多写少 → 读少写多 → 跨模块」的顺序分批迁移:
- 第一批:User、Theme、Permission 这类低频变更的全局状态 → Zustand
- 第二批:表单草稿、列表筛选条件这类高频局部状态 → 保留在组件内或用 Zustand slice
- 第三批:跨模块通信(如通知系统)→ 独立 store
核心原则:store 按业务域拆分,不搞单一 store。
src/stores/
├── userStore.ts
├── themeStore.ts
├── permissionStore.ts
└── notificationStore.ts
四、核心实现
先装依赖:
npm i zustand@4.4.1
userStore.ts —— 用 persist 中间件做 token 持久化,subscribeWithSelector 便于外部订阅:
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
interface UserState {
user: User | null;
token: string | null;
setUser: (u: User) => void;
logout: () => void;
}
export const useUserStore = create()(
subscribeWithSelector(
persist(
(set) => ({
user: null,
token: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null, token: null }),
}),
{
name: 'app-user',
partialize: (s) => ({ token: s.token }), // 只持久化 token
}
)
)
);
组件消费 —— 关键是按字段 selector 订阅,不要整个 store 拿:
// ❌ 错误:整个 store 订阅,任何字段变化都重渲染
const { user, token } = useUserStore();
// ✅ 正确:只订阅需要的字段,Zustand 用 Object.is 比较
const user = useUserStore((s) => s.user);
const logout = useUserStore((s) => s.logout);
// ✅ 多字段用 shallow
import { shallow } from 'zustand/shallow';
const { user, token } = useUserStore(
(s) => ({ user: s.user, token: s.token }),
shallow
);
迁移 Context 的过渡写法 —— 为了不改动 200+ 组件的 import,我保留了一个兼容层,两周内逐步替换:
// hooks/useUser.ts —— 兼容旧 API
import { useUserStore } from '@/stores/userStore';
export function useUser() {
return useUserStore((s) => s.user);
}
// 旧代码 `const { user } = useUserContext()`
// 改成 `const user = useUser()` 即可,无需改组件结构
五、踩坑与优化
坑 1:selector 返回新对象导致无限渲染。
// ❌ 每次返回新数组,引用不等,触发 re-render
const ids = useUserStore((s) => s.list.map((i) => i.id));
解决:用 shallow 比较,或在 store 里缓存计算结果。
坑 2:persist 把整个 user 对象写进 localStorage,包含 base64 头像,单条 400kb。
解决:partialize 只存 token,user 每次启动重新拉取。
坑 3:Zustand 的 set 是浅合并,嵌套对象更新要手动展开。
setUser: (patch) =>
set((s) => ({ user: { ...s.user!, ...patch } })),
优化:用 subscribeWithSelector 做跨 store 副作用,替代 useEffect 监听:
useUserStore.subscribe(
(s) => s.token,
(token) => {
if (!token) useNotificationStore.getState().clear();
}
);
六、效果数据
迁移前后同一台测试机(MacBook Pro M1,Chrome 118),跑 5 次取中位数:
| 指标 | 迁移前(Context) | 迁移后(Zustand) | 变化 |
|---|---|---|---|
| 切换用户头像提交耗时 | 620ms | 168ms | ↓ 73% |
| 参与渲染组件数 | 217 | 31 | ↓ 86% |
| 首屏可交互时间(TTI) | 2.4s | 1.9s | ↓ 21% |
| 主 bundle gzip | 312kb | 298kb | ↓ 14kb |
| Context Provider 嵌套层数 | 7 | 0 | — |
最直观的收益是调试体验:Zustand DevTools 能直接看到每次 action 前后的 diff,而 Context 在 Profiler 里只显示「Context.Provider 更新」,完全不知道谁改的。
七、总结
如果你的项目 Context 数量 > 3,或者出现「改一个 state 全页面重渲染」,就该考虑迁移了。我的建议:
- 别用 Context 管全局状态,它适合主题、i18n 这类几乎不变的注入
- Zustand 迁移成本最低,一个文件一个 store,没有 Provider 嵌套
- selector 粒度决定性能,从第一天就按字段订阅,别偷懒整体取
- 渐进式迁移,用兼容 Hook 包一层,两周内慢慢换,别一次性重构
这次迁移总共花了 3 天(含回归测试),换来的是后续 10 个模块开发时不再纠结「这个 state 该放哪」。状态管理这件事,工具选对了,代码自然就干净了。