一、问题背景:Context 不是不能用,是先被我们用坏了

项目是一个运营中后台,React 18.2 + TypeScript 5.0 + Vite 4.3,大概 180 个页面组件,全局状态包括:用户信息、权限树、主题配置、字典缓存、全局弹窗、通知列表、租户配置等。

最初的设计很"标准":每个领域一个 Context,App 根部用嵌套 Provider 包起来。

// 迁移前的 App.tsx(简化)

问题是在迭代半年后集中爆发的:

  1. Provider Hell:嵌套 7 层,新人看 App.tsx 要滚两屏。
  2. 重渲染失控:Context value 只要变,所有 useContext 的组件全部重渲染。我们的 DictContext 存了 300+ 条字典,任意一次字典更新都会让整个表格页闪一下。
  3. Props 透传:有些深层组件拿不到 Context(比如 Portal 渲染的 Modal 在 Provider 树外),只能一层层传 props,一个组件接了 12 个 props。
  4. 调试困难:React DevTools 里看不到 Context 的变更来源,只能靠 console.log 打点。

我们用 React Profiler 测了一次首页:commit 阶段 1.2s,其中 DictProvider 触发的重渲染占了 640ms,涉及 217 个组件。这就是迁移的导火索。

二、环境与版本

依赖 版本
React 18.2.0
TypeScript 5.0.4
Vite 4.3.9
Zustand 4.4.1
Immer 10.0.2
React-Redux 8.1.2(对比用)
Jotai 2.4.2(对比用)

选型时我们对比了四个方案:

方案 包体积(gzip) 学习成本 重渲染控制 适合场景
Context + useMemo 0 低频变更的配置
Redux Toolkit 13.4kb 中(需 useSelector) 大型团队、强约束
Zustand 3.1kb 好(selector 精确订阅) 中后台、快速迭代
Jotai 4.7kb 好(原子级) 细粒度派生状态多

最终选 Zustand,理由很实际:团队 6 个人,没人愿意写 slice/reducer/action 三件套;Zustand 的 API 几乎是"把 useState 提到组件外",迁移成本最低,而且 selector 天然解决重渲染问题。

三、方案设计:Store 怎么切

我们没有把所有 Context 无脑合成一个大 store,而是按"变更频率"和"依赖关系"切:

  • useUserStore:用户信息、token、登录态(低频,登录后基本不变)
  • usePermissionStore:权限码集合、菜单树(低频)
  • useDictStore:字典缓存(中频,按需加载)
  • useUIStore:主题、侧边栏折叠、全局 loading(高频,但字段少)
  • useModalStore:全局弹窗栈(高频)
  • useNotificationStore:通知列表(高频,轮询 30s 一次)

关键原则:高频变更的字段单独成 store,避免和低频字段混在一起导致 selector 失效。比如把 themeuserInfo 放一起,改主题就会让所有订阅 userInfo 的组件重渲染。

四、核心实现

4.1 Store 定义(含 Immer 与 persist)

// stores/dictStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { persist, createJSONStorage } from 'zustand/middleware';

interface DictItem {
  label: string;
  value: string | number;
  color?: string;
}

interface DictState {
  dicts: Record;
  loading: Record;
  fetchDict: (code: string) => Promise;
  getDict: (code: string) => DictItem[];
}

// 缓存请求,避免并发重复拉取
const pending = new Map>();

export const useDictStore = create()(
  persist(
    immer((set, get) => ({
      dicts: {},
      loading: {},
      fetchDict: async (code) => {
        if (get().dicts[code] || pending.has(code)) {
          return pending.get(code);
        }
        const task = (async () => {
          set((s) => {
            s.loading[code] = true;
          });
          try {
            const res = await fetch(`/api/dict/${code}`);
            const data = (await res.json()) as DictItem[];
            set((s) => {
              s.dicts[code] = data;
              s.loading[code] = false;
            });
          } catch (e) {
            set((s) => {
              s.loading[code] = false;
            });
            throw e;
          } finally {
            pending.delete(code);
          }
        })();
        pending.set(code, task);
        return task;
      },
      getDict: (code) => get().dicts[code] ?? [],
    })),
    {
      name: 'dict-storage',
      storage: createJSONStorage(() => sessionStorage),
      // 只持久化数据,不持久化 loading
      partialize: (s) => ({ dicts: s.dicts }),
      version: 1,
    }
  )
);

注意 partialize:早期没写,结果 loading 被持久化,刷新后某些字典永远显示 loading。这个坑后面细说。

4.2 组件中使用(selector 是关键)

// 迁移前:整个 Context 订阅,任何字段变化都重渲染
const { dicts, loading } = useContext(DictContext);

// 迁移后:只订阅需要的字段
import { useShallow } from 'zustand/react/shallow';

// 场景 1:只需要某个字典的数据
function StatusTag({ code }: { code: string }) {
  const items = useDictStore((s) => s.dicts[code]);
  return {items?.map((i) => {i.label})};
}

// 场景 2:需要多个字段,用 useShallow 避免对象引用变化
function DictSelect({ code }: { code: string }) {
  const { items, loading } = useDictStore(
    useShallow((s) => ({
      items: s.dicts[code] ?? [],
      loading: s.loading[code] ?? false,
    }))
  );
  const fetchDict = useDictStore((s) => s.fetchDict);

  useEffect(() => {
    fetchDict(code);
  }, [code, fetchDict]);

  return ;
}

这里有两个必须说的点:

第一,不要返回新对象。 useDictStore((s) => ({ a: s.a, b: s.b })) 每次都会返回新引用,导致无限重渲染。Zustand 4 提供了 useShallow,或者老老实实写两个 selector。

第二,action 单独取。 fetchDict 是稳定引用,单独 useDictStore((s) => s.fetchDict) 拿,不要和 state 混在一个 selector 里。

4.3 非 React 环境访问(解决 Portal 问题)

之前 Modal 用 Context 拿不到,现在直接:

// 任意地方,包括 axios 拦截器、路由守卫
import { useUserStore } from '@/stores/userStore';

axios.interceptors.request.use((config) => {
  const token = useUserStore.getState().token;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

getState()setState() 是 Zustand 相比 Context 最大的优势之一,彻底解决了"组件树外访问状态"的问题。

五、踩坑与优化

坑 1:persist 把 loading 也存了。 上面提过,用 partialize 白名单解决。

坑 2:selector 返回数组导致重渲染。 s.dicts[code] 如果 code 不存在返回 undefined,每次 ?? [] 都产生新数组。解决方式是 store 里保证 dicts[code] 初始化为 [],或者用常量 EMPTY_ARRAY

坑 3:Immer 和 Map/Set。 我们的权限树用了 Set,Immer 默认不识别,需要开 enableMapSet()

import { enableMapSet } from 'immer';
enableMapSet(); // 必须在 create 之前调用

坑 4:批量更新。 迁移初期有个页面在 useEffect 里连续调了 5 次 setState,虽然 Zustand 内部有批处理,但跨微任务还是会触发多次渲染。我们用 unstable_batchedUpdates 或者干脆合并成一次 set

优化:selector 粒度。 表格页有 20 列,每列都要字典。最初写成 const dicts = useDictStore((s) => s.dicts),整个字典对象一变全表重渲染。改成每列 useDictStore((s) => s.dicts[code]) 后,只有对应字典变化才重渲染。

优化:订阅外部 store 用 useSyncExternalStore Zustand 4 内部已经用了,但如果自己写跨组件订阅要注意 React 18 的并发特性。

六、效果数据

用 React Profiler + 自建埋点(记录 commit 次数和耗时)对比,测试环境:Chrome 118,MacBook Pro M1,禁用缓存,跑 10 次取中位数。

指标 Context 方案 Zustand 方案 变化
首页首屏 commit 耗时 1200ms 460ms -61.7%
首页 commit 组件数 217 89 -59.0%
切换主题(改一个值)重渲染组件数 203 6 -97.0%
字典加载后重渲染组件数 187 24 -87.2%
包体积(gzip) 0 +3.1kb +3.1kb
App.tsx 行数 68 12 -82.4%

迁移工作量:6 个 store,替换 43 个 useContext 调用点,删除 11 个 Provider,新增 9 个 selector 封装 hook。总耗时约 3 人日,其中 1 人日花在回归测试上。

有一个反直觉的点:包体积增加 3.1kb,但首屏反而更快了。原因是 Context 方案的 useMemo 依赖数组写得很复杂,每次渲染都在做深比较,CPU 时间省下来的远比 3kb 网络传输多。

七、总结

Context 不是坏东西,它适合"低频变更、层级深、不需要精确订阅"的场景,比如主题、i18n。但只要状态开始频繁变更、订阅者变多,就应该考虑 Zustand 这类外部 store。

几条来自实战的建议:

  1. 迁移不要一步到位。 我们保留了 ThemeContext,因为它一年改不了几次,没必要迁。
  2. selector 是收益的核心。 如果迁移后还是 const store = useStore() 全量取,那和 Context 没区别。
  3. 高频字段独立 store。 这是降低重渲染最有效的手段,比任何 memo 都管用。
  4. getState() 是隐藏福利。 拦截器、工具函数里直接拿状态,省掉大量 props 透传。

如果你们项目也在 Context 里挣扎,先跑一次 Profiler,看看 commit 阶段到底是谁在渲染。数据会告诉你该不该迁。