**

一、问题背景: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,改造成本高。

三、方案设计:分三步走

我没有一次性全量替换,而是按「读多写少 → 读少写多 → 跨模块」的顺序分批迁移:

  1. 第一批:User、Theme、Permission 这类低频变更的全局状态 → Zustand
  2. 第二批:表单草稿、列表筛选条件这类高频局部状态 → 保留在组件内或用 Zustand slice
  3. 第三批:跨模块通信(如通知系统)→ 独立 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 该放哪」。状态管理这件事,工具选对了,代码自然就干净了。