一、问题背景:Context 不是状态管理库,但我们一直当它是
项目是 2023 年启动的 React 18 + TypeScript 运营中台,初期只有 3 个页面,用 Context 传用户信息和权限足够了。但到今年 6 月,代码里已经有 47 个 createContext,嵌套层级最深 11 层。
具体症状:
- 首屏 React.lazy 加载后,Profiler 显示 commit 阶段耗时 3.8s
- 修改一个筛选条件,触发 200+ 组件 re-render(React DevTools 高亮)
- 每次新增一个全局状态,就要在 App.tsx 里加一层 Provider,代码 review 时被吐槽“Provider 地狱”
- 团队新同学改一个表单,不小心动了 Context value 的引用,导致整个页面白屏 0.5s
我们统计了 React.Profiler 的 actualDuration,在 1000 条表格数据 + 5 个图表的面板页,Context 方案下每次状态更新平均耗时 186ms,而用户感知的卡顿阈值是 100ms。
二、环境与版本
- React 18.2.0
- TypeScript 5.2.2
- Vite 4.4.5
- 原方案:React Context + useReducer
- 候选库:Zustand 4.4.1、Jotai 2.4.2、Valtio 1.11.0
- 最终选择:Zustand 4.4.1(理由见下)
三、方案设计:为什么选 Zustand 而不是 Jotai/Valtio
我们列了一个对比表,重点看迁移成本和性能:
| 维度 | Context | Zustand | Jotai | Valtio |
|---|---|---|---|---|
| 学习成本 | 低 | 低 | 中 | 低 |
| 迁移成本 | - | 低(API 类似 useReducer) | 高(原子化拆分) | 中(Proxy 心智) |
| 重渲染控制 | 差 | 好(selector) | 极好 | 好 |
| 包体积 | 0 | 1.2KB | 3.1KB | 2.8KB |
| 异步支持 | 手动 | 内置 | 内置 | 内置 |
| DevTools | 无 | 有 | 有 | 有 |
选 Zustand 的核心原因:
1. 迁移时可以直接把 useReducer 的 dispatch 替换成 store 的 action,代码改动最小
2. useStore(state => state.xxx) 的 selector 机制天然解决重渲染问题
3. 不需要包 Provider,直接 import store 就能用,删掉 47 层嵌套
四、核心实现:3 天迁移步骤与代码
第 1 天:抽离 Context,建立 store 骨架
原代码(简化):
// 旧:Context + useReducer
const FilterContext = createContext(null);
function FilterProvider({ children }) {
const [state, dispatch] = useReducer(filterReducer, initialState);
return (
{children}
);
}
// 组件里
const { state, dispatch } = useContext(FilterContext);
新代码:
// 新:Zustand store
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
interface FilterState {
keyword: string;
dateRange: [string, string] | null;
status: 'all' | 'pending' | 'done';
setKeyword: (v: string) => void;
setDateRange: (v: [string, string] | null) => void;
reset: () => void;
}
export const useFilterStore = create()(
devtools(
subscribeWithSelector((set) => ({
keyword: '',
dateRange: null,
status: 'all',
setKeyword: (v) => set({ keyword: v }),
setDateRange: (v) => set({ dateRange: v }),
reset: () => set({ keyword: '', dateRange: null, status: 'all' }),
})),
{ name: 'filter-store' }
)
);
组件里改成:
const keyword = useFilterStore((s) => s.keyword);
const setKeyword = useFilterStore((s) => s.setKeyword);
关键点:不要写 const store = useFilterStore(),那会订阅整个 store,等于没优化。必须用 selector。
第 2 天:批量替换 47 个 Context
我们写了一个 codemod 脚本(jscodeshift),把 useContext(XxxContext) 自动替换成 useXxxStore((s) => s.xxx)。但遇到两个问题:
- 有些 Context 的 value 是对象,selector 要拆成多个字段
- 有些 Context 依赖 props 初始化,需要改成 store 的
initaction
手动改了 12 个复杂 Context,剩下 35 个用脚本完成。当天跑通所有单测。
第 3 天:性能优化与边界处理
重点处理三个场景:
场景 1:派生状态
原来在 Context 里用 useMemo 算的,现在用 selector + useShallow:
import { useShallow } from 'zustand/react/shallow';
const { pendingCount, doneCount } = useTaskStore(
useShallow((s) => ({
pendingCount: s.tasks.filter((t) => t.status === 'pending').length,
doneCount: s.tasks.filter((t) => t.status === 'done').length,
}))
);
场景 2:异步 action
const fetchTasks = async () => {
set({ loading: true });
try {
const data = await api.getTasks();
set({ tasks: data, loading: false });
} catch (e) {
set({ error: e.message, loading: false });
}
};
场景 3:跨 store 通信
用 subscribeWithSelector 监听:
useFilterStore.subscribe(
(s) => s.keyword,
(keyword) => {
useTaskStore.getState().fetchTasks({ keyword });
}
);
五、踩坑与优化:5 个真实问题
-
selector 返回新对象导致无限重渲染
useStore((s) => ({ a: s.a, b: s.b }))每次返回新引用,必须用useShallow。 -
DevTools 里 action 名字不显示
要在set时传第三个参数:set({ keyword: v }, false, 'filter/setKeyword')。 -
SSR 场景下 store 单例污染
我们中台是 CSR,没遇到,但文档里建议用createStore+useStore配合 Context 做 per-request 隔离。 -
TypeScript 类型推导在 middleware 下失效
必须写create()(devtools(...)),注意那个多出来的()。 -
旧代码里
useContext的默认值逻辑丢失
Context 有defaultValue,Zustand 没有,需要手动在 store 初始化时处理。
六、效果数据:首屏 3.8s → 1.4s,重渲染下降 92%
迁移前后用同一台机器(MacBook Pro M1, 16GB)跑 5 次取中位数:
| 指标 | Context 方案 | Zustand 方案 | 变化 |
|---|---|---|---|
| 首屏渲染耗时 | 3.8s | 1.4s | -63% |
| 状态更新平均耗时 | 186ms | 23ms | -87% |
| 单次更新重渲染组件数 | 200+ | 16 | -92% |
| App.tsx Provider 层数 | 47 | 0 | -100% |
| 包体积(gzip) | 0 | +1.2KB | +1.2KB |
React Profiler 截图显示,迁移后 commit 阶段从 186ms 降到 23ms,火焰图从“一片红”变成“零星几个黄块”。
七、总结
Context 适合低频、全局、不常变的状态(主题、用户信息),但一旦用来管理高频更新的业务状态,就是性能灾难。Zustand 的 selector 机制和零 Provider 设计,让迁移成本远低于 Jotai 的原子化重构。
如果你也在维护一个 30+ Context 的 React 项目,建议先做一件事:打开 React DevTools,勾选“Highlight updates when components render”,然后随便改一个输入框。如果满屏高亮,那就该迁了。
迁移不是重写,我们 3 天搞定,其中 1 天写 codemod,1 天改复杂逻辑,1 天调性能。真正难的不是 API,是说服团队“Context 不是状态管理库”这件事。