一、问题背景:Context 不是不能用,是先被我们用坏了
项目是一个运营中后台,React 18.2 + TypeScript 5.0 + Vite 4.3,大概 180 个页面组件,全局状态包括:用户信息、权限树、主题配置、字典缓存、全局弹窗、通知列表、租户配置等。
最初的设计很"标准":每个领域一个 Context,App 根部用嵌套 Provider 包起来。
// 迁移前的 App.tsx(简化)
问题是在迭代半年后集中爆发的:
- Provider Hell:嵌套 7 层,新人看 App.tsx 要滚两屏。
- 重渲染失控:Context value 只要变,所有
useContext的组件全部重渲染。我们的DictContext存了 300+ 条字典,任意一次字典更新都会让整个表格页闪一下。 - Props 透传:有些深层组件拿不到 Context(比如 Portal 渲染的 Modal 在 Provider 树外),只能一层层传 props,一个组件接了 12 个 props。
- 调试困难: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 失效。比如把 theme 和 userInfo 放一起,改主题就会让所有订阅 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。
几条来自实战的建议:
- 迁移不要一步到位。 我们保留了
ThemeContext,因为它一年改不了几次,没必要迁。 - selector 是收益的核心。 如果迁移后还是
const store = useStore()全量取,那和 Context 没区别。 - 高频字段独立 store。 这是降低重渲染最有效的手段,比任何 memo 都管用。
getState()是隐藏福利。 拦截器、工具函数里直接拿状态,省掉大量 props 透传。
如果你们项目也在 Context 里挣扎,先跑一次 Profiler,看看 commit 阶段到底是谁在渲染。数据会告诉你该不该迁。