一、问题背景:一个 Context 拖垮了整个页面
先说结论:Context 不是状态管理库,它是依赖注入工具。这句话我在迁移完之后才真正理解。
我们的项目是一个中后台的数据看板,Vue 3 + React 18 混合(历史原因,老模块是 Vue,新模块用 React 重写)。出问题的是 React 侧的「指标分析」页面,结构大致是:
// 持有筛选条件、时间范围、维度配置
// 消费 filter
// 消费 filter
// 每个都消费 filter
// 消费 filter + 分页
FilterProvider 的 value 是一个对象:
const value = { filter, timeRange, dimensions, setFilter, setTimeRange, setDimensions };
问题出在两处:
- value 每次渲染都是新对象,
useMemo的依赖里带了filter,而filter本身在父组件里是useState存的对象,改一个字段就换引用,useMemo白做。 - 所有消费方都用
useContext(FilterContext)拿整个对象,哪怕组件只用到timeRange,filter 一变它也重渲染。
实测数据(React DevTools Profiler,生产构建,Chrome 120,M2 MacBook Air):
| 指标 | 数值 |
|---|---|
| 单次筛选交互总耗时 | 180ms |
| commit 阶段 | 120ms |
| 参与渲染组件数 | 340 |
| 实际需要更新的组件数 | 约 30 |
也就是说,90% 的渲染是浪费的。用户拖动时间滑块时明显卡顿,这在 60fps 下意味着掉到 5 帧左右。
试过几个不换库的方案:拆成多个 Context、useMemo + 自定义 useContextSelector、React.memo 包一层。前两个能缓解但代码急剧膨胀(拆出 7 个 Context,Provider 嵌套成俄罗斯套娃),第三个对 children 传递的组件无效。最终决定引入状态管理库。
二、环境与版本
先把版本钉死,避免"我这里能跑"的扯皮:
{
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "5.3.3",
"vite": "5.0.10",
"zustand": "4.5.0",
"immer": "10.0.3",
"vue": "3.4.15",
"pinia": "2.1.7"
}
Vue 侧我们用的是 Pinia,本来就是库,没这个问题,所以本文重点在 React 侧。但方案对比里会把 Vue 生态一起讲,因为我们还有一个 Vue 子应用要做同样的决策。
三、方案设计:为什么最后选了 Zustand
候选四个,逐个说真实取舍,不是抄官网对比表。
Redux Toolkit:团队里有人熟,DevTools 好用,RTK Query 能顺带解决数据请求。但样板代码对我们这种"就是存几个筛选条件"的场景太重,一个 slice + store 配置 + Provider,迁移 60 个组件要改的 import 太多。而且 useSelector 默认用 Object.is 比较,返回对象时还是得自己写 shallowEqual,坑没少。
Jotai:原子化思路很契合"每个字段独立订阅",useAtom 天然细粒度。缺点是我们的状态有派生关系(dimensions 依赖 filter.type),原子多了之后派生链不好维护,团队学习成本偏高,且当时 2.x 的 atomFamily 在 StrictMode 下有重复创建的问题(后来修了,但我们不敢赌)。
Zustand:API 极简,create 一个 store,组件里 useStore(selector) 直接订阅切片,不需要 Provider(这点对迁移是决定性的——不用改组件树结构)。支持 subscribeWithSelector、immer 中间件。体积 1.2KB gzip。
Vue 侧对照结论:Pinia 本身就是这个思路(defineStore + storeToRefs 解构保证响应式),Vue 的响应式系统天然细粒度依赖追踪,所以 Vue 项目里"Context 全量广播"的问题本质不存在——除非你用 provide/inject 传大对象还不解构。这一点在迁移时也提醒了我们:React 的问题不是状态放哪,而是订阅粒度。
最终选 Zustand,核心原因三条:
- 无需 Provider,组件树零改动;
- selector 天然支持细粒度订阅,配
useShallow解决对象返回; - 可以在 store 外部(比如埋点、请求拦截器)用
store.getState()直接读,不用 hooks。
四、核心实现:迁移分四步走
步骤 1:先建 store,不改组件
把 Context 里的状态原样搬进 store,先保证行为一致,别急着优化。
// store/filterStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export interface FilterState {
filter: { keyword: string; status: 'all' | 'active' | 'archived' };
timeRange: [number, number];
dimensions: string[];
setKeyword: (kw: string) => void;
setTimeRange: (range: [number, number]) => void;
toggleDimension: (dim: string) => void;
}
export const useFilterStore = create()(
subscribeWithSelector(
immer((set, get) => ({
filter: { keyword: '', status: 'all' },
timeRange: [Date.now() - 7 * 864e5, Date.now()],
dimensions: ['pv', 'uv'],
setKeyword: (kw) =>
set((s) => {
s.filter.keyword = kw; // immer 直接改,不用展开
}),
setTimeRange: (range) => set({ timeRange: range }),
toggleDimension: (dim) =>
set((s) => {
const i = s.dimensions.indexOf(dim);
if (i >= 0) s.dimensions.splice(i, 1);
else s.dimensions.push(dim);
// 派生:维度变了,时间范围保持,但这里可以联动
if (s.dimensions.length === 0) s.dimensions = ['pv'];
}),
}))
)
);
注意 immer 中间件和 subscribeWithSelector 的嵌套顺序:subscribeWithSelector(immer(...)),反了会报类型错误。这个顺序我踩过一次,TS 报错信息极其晦涩(Mutate, ...> 不匹配),查了半小时 issue。
步骤 2:组件层替换,用 selector 拆订阅
这是性能提升的关键。Context 时代组件写 const { filter, timeRange } = useContext(...),现在改成按需订阅:
// 改造前
function ChartCard({ id }: { id: string }) {
const { filter, timeRange, dimensions } = useContext(FilterContext);
return ;
}
// 改造后:只订阅真正用到的切片
function ChartCard({ id }: { id: string }) {
const filter = useFilterStore((s) => s.filter);
const timeRange = useFilterStore((s) => s.timeRange);
const dimensions = useFilterStore((s) => s.dimensions);
return ;
}
但这里有个坑:如果 selector 返回对象或数组,必须配 useShallow,否则每次 s.dimensions 引用没变还好,一旦 selector 里做 ({ a: s.a, b: s.b }) 这种组合,Object.is 永远不等,等于没优化。
import { useShallow } from 'zustand/react/shallow';
// 组合多个字段时
const { status, keyword } = useFilterStore(
useShallow((s) => ({ status: s.filter.status, keyword: s.filter.keyword }))
);
// 或者直接订阅数组本身(引用不变则安全)
const dimensions = useFilterStore((s) => s.dimensions);
步骤 3:Action 从 props 里拔出来
Context 时代 setFilter 是 value 里的函数,组件通过 props 往下传。迁移后组件直接从 store 拿 action,props 链路少了两层:
function FilterBar() {
const keyword = useFilterStore((s) => s.filter.keyword);
const setKeyword = useFilterStore((s) => s.setKeyword);
// setKeyword 引用稳定(immer 下每次 create 时生成一次),不会引起子组件重渲染
return setKeyword(e.target.value)} />;
}
setKeyword 这类 action 在 store 创建时生成,引用恒定,所以就算组件订阅了它也不会因为状态变化而重渲染——这点比 Context 里每次 useMemo 生成新函数靠谱得多。
步骤 4:删掉 Provider,清理 Context
FilterProvider 直接从组件树移除。这一步要注意:如果有组件在 Provider 外被测试或 Storybook 单独渲染,之前依赖 Context 默认值的地方会直接报错。我们的做法是保留一个 __mocks__/filterStore.ts,在 Jest 里 jest.mock 掉。
// __mocks__/filterStore.ts
import { create } from 'zustand';
export const useFilterStore = create(() => ({
filter: { keyword: '', status: 'all' },
timeRange: [0, 0],
dimensions: ['pv'],
setKeyword: jest.fn(),
setTimeRange: jest.fn(),
toggleDimension: jest.fn(),
}));
五、踩坑与优化
坑 1:useShallow 的引入路径。 Zustand 4.5 里在 zustand/react/shallow,4.4 之前是 zustand/shallow,升级时 import 报错找了一会儿。另外 useShallow 是 4.4 才加的,之前叫 shallow 比较函数配 useStore(selector, shallow) 的第二个参数,现在那个签名还在但推荐用 useShallow。
坑 2:immer 的 set 里返回新对象 vs 修改。 immer 中间件下 set((s) => { s.x = 1 }) 是修改;但如果你写成 set((s) => ({ x: 1 })) 返回对象,immer 会当成新 state 整体替换,s.filter 之类就丢了。团队里有人混用,排查了一次数据丢失。
坑 3:批量更新。 一次交互里连续调 setKeyword + setTimeRange,会触发两次通知。Zustand 4.x 在 React 18 的 automatic batching 下会合并,但在事件处理器外(比如 setTimeout 里) 不一定。我们用 store.setState 一次性合并:
useFilterStore.setState((s) => {
s.filter.keyword = kw;
s.timeRange = range;
});
坑 4:DevTools。 Zustand 的 devtools 中间件要放在最外层:devtools(subscribeWithSelector(immer(...))),而且 action 名要手动传 { name: 'setKeyword' } 才能在面板里看清,不然全是 setState。
优化:派生值用 selector 算,别存。 比如"是否有筛选生效"这种,别往 store 里塞 isDirty 字段,用 selector 派生:
const isDirty = useFilterStore(
(s) => s.filter.keyword !== '' || s.filter.status !== 'all'
);
六、效果数据
迁移前后同一台机器、同一份生产构建、DevTools Profiler 录制"拖动时间范围滑块一次":
| 指标 | Context | Zustand | 变化 |
|---|---|---|---|
| 交互总耗时 | 180ms | 42ms | -77% |
| commit 阶段 | 120ms | 28ms | -77% |
| 渲染组件数 | 340 | 61 | -82% |
| 平均帧率(拖动时) | 5fps | 58fps | — |
| 首屏 JS(gzip) | — | +1.2KB | 可忽略 |
组件数从 340 降到 61,是因为 ChartCard 现在只订阅 timeRange 和 dimensions,keyword 变化时它们完全不参与。61 里大部分是真正需要更新的图表容器。
代码层面:删掉 7 个 Context 文件、约 400 行 Provider 嵌套代码,FilterProvider 的 useMemo 依赖数组从 6 项缩到 0(没了)。新增 store 一个文件 80 行。
Vue 侧的对照:我们把同样的页面在 Vue 3 + Pinia 下测了一遍,storeToRefs 解构后拖动滑块耗时 38ms,和 Zustand 基本持平——印证了前面说的,Vue 的响应式系统本来就没这个问题,React 侧迁移本质是把订阅粒度从"组件树"降到"字段"。
七、总结
几个可以直接带走的结论:
- Context 适合低频、粗粒度、几乎不变的值(主题、i18n、用户信息),不适合高频交互的业务状态。判断标准:如果 value 里有
useState且更新频率 > 1 次/秒,就该考虑换。 - 迁移顺序很重要:先原样搬 store 保证行为一致,再改 selector 优化订阅,最后删 Provider。反过来做会同时面对"行为不对"和"性能不对"两个变量。
- selector 返回对象必配
useShallow,这是 Zustand 迁移里最容易漏的一步,漏了等于没迁移。 - Zustand 不需要 Provider 是迁移的最大红利,组件树零改动意味着可以按模块灰度迁移,不用一次性切完。
如果项目里 Context 已经嵌套超过 3 层、或者有组件因为 Context 全量广播被迫 React.memo 包了一层又一层,那基本可以确定该动手了。我们这次迁移总共花了约 3 人日(含测试),收益是一次交互从 180ms 到 42ms,值。
最后一句忠告:别为了"用状态管理库"而迁移,先看 Profiler。如果 Context 的 commit 阶段占比不到 10%,那问题可能根本不在状态管理,而在某个没 memo 的列表渲染。数据先行,工具跟上。