**
一、问题背景:Context 不是银弹
项目是一个运营后台,核心页面是“活动配置页”:左侧是活动基础信息表单(约 25 个字段),右侧是奖品列表(可增删改,每行 6 个字段),底部是实时预览。组件层级大概是:
Page
├── BasicInfoForm (25 fields)
├── PrizeList
│ └── PrizeRow × N (6 fields each)
└── Preview
最初的状态管理很“朴素”:Page 用 useState 持有整个 formData,通过 props 往下传,深层组件用 useContext 拿一些全局配置(用户信息、权限、字典)。随着迭代,Context 从 1 个涨到 11 个,Page 的 props drilling 到了第 4 层。
问题在 Q3 变得不可忽视:
- 输入卡顿:在基础信息表单里每敲一个字,
Page的formData变化 → 所有 Consumer 重渲染。用 React DevTools Profiler 测,单次按键 commit 阶段 18ms,其中PrizeList的 30 行全部重渲染,尽管它们的数据没变。 - Context 嵌套地狱:11 个 Provider 套在
App里,新人接手第一句话是“这玩意儿从哪拿的?” - 无法在组件外读取状态:埋点、请求拦截器里想拿当前表单快照,只能把 ref 一层层传出去。
我们决定做一次状态管理重构,目标很明确:按域拆分状态、消除无关重渲染、允许组件外访问。
二、环境与版本
- React 18.2.0 / TypeScript 5.2.2
- Vite 4.4.9,构建产物分析用
rollup-plugin-visualizer5.9.2 - 现有依赖:无 Redux,Context 自研
- 候选库:
zustand@4.5.2、@reduxjs/toolkit@1.9.7+react-redux@8.1.3、jotai@2.6.0 - 性能测量:React DevTools Profiler 4.28、Chrome 118 Performance 面板、
web-vitals@3.5.0
三、方案设计:四种方案横向对比
我们列了一张对比表,维度是团队最关心的几个点:
| 维度 | Context + useMemo | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|
| 学习成本 | 低 | 中高 | 低 | 中 |
| 样板代码 | 中(拆分 Provider) | 高(slice/store/hook) | 极低 | 低 |
| 选择性订阅 | 需手动拆 Context | useSelector |
selector + shallow | atom 级别天然细粒度 |
| 组件外访问 | 不支持 | store.getState() |
store.getState() |
store.get() |
| 包体积(gzip) | 0 | ~13KB | ~1.2KB | ~3.5KB |
| DevTools | 无 | 优秀 | 有(middleware) | 有 |
| 异步/副作用 | 自己写 | createAsyncThunk | 直接在 action 里写 | 需配合 |
结论:
- Context + useMemo:能缓解但不能根治。要真正做选择性订阅,得把状态拆成十几个 Context,维护成本高,且组件外访问依然无解。排除。
- Redux Toolkit:生态最成熟,但我们的状态是“页面级表单 + 少量全局配置”,不是复杂业务流,样板代码性价比低。作为备选。
- Jotai:atom 粒度最细,理论上性能最好,但团队没人用过,且表单场景下 atom 数量会爆炸(30 行 × 6 字段 = 180 个 atom),心智负担大。
- Zustand:API 极简、支持 selector 选择性订阅、
getState()可在任何地方调用、体积 1.2KB。选它。
架构上我们按“域”拆成三个 store:
useGlobalStore:用户信息、权限、字典(低频变更,全局共享)useFormStore:活动基础信息(高频变更,页面级)usePrizeStore:奖品列表(高频变更,数组操作多)
四、核心实现:从 Context 到 Zustand 的三步迁移
第一步:先拆 Context,止血
迁移不是一步到位的。我们先做了一件“无风险”的事:把原来 11 个 Context 按域合并成 3 个,并用 useMemo 包住 value。
// 迁移前:每次渲染都创建新对象,所有 Consumer 必重渲染
// 迁移后:依赖不变则引用不变
const globalValue = useMemo(
() => ({ user, permission, dict }),
[user, permission, dict]
);
这一步就把单次按键的 commit 时间从 18ms 降到 12ms,算是先稳住线上体验。
第二步:按域迁移到 Zustand
以 useFormStore 为例。关键点是 selector 必须返回稳定引用,否则 useStore 的默认 Object.is 比较会失效。我们用了 useShallow(Zustand 4.4+ 提供)来处理对象选择器。
// stores/formStore.ts
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
interface FormState {
formData: Record;
setField: (key: string, value: unknown) => void;
reset: () => void;
}
export const useFormStore = create((set) => ({
formData: {},
setField: (key, value) =>
set((state) => ({
formData: { ...state.formData, [key]: value },
})),
reset: () => set({ formData: {} }),
}));
// 组件内:只订阅自己需要的字段
function TitleField() {
const title = useFormStore((s) => s.formData.title as string);
const setField = useFormStore((s) => s.setField);
return (
setField('title', e.target.value)}
/>
);
}
// 需要多个字段时用 useShallow,避免每次返回新对象导致重渲染
function SummaryBar() {
const { title, status } = useFormStore(
useShallow((s) => ({
title: s.formData.title,
status: s.formData.status,
}))
);
return {title} / {status};
}
usePrizeStore 稍微复杂一点,因为奖品是数组,增删改都要保证引用变化正确:
// stores/prizeStore.ts
import { create } from 'zustand';
interface Prize {
id: string;
name: string;
count: number;
}
interface PrizeState {
prizes: Prize[];
updatePrize: (id: string, patch: Partial) => void;
addPrize: () => void;
removePrize: (id: string) => void;
}
export const usePrizeStore = create((set) => ({
prizes: [],
updatePrize: (id, patch) =>
set((state) => ({
prizes: state.prizes.map((p) => (p.id === id ? { ...p, ...patch } : p)),
})),
addPrize: () =>
set((state) => ({
prizes: [
...state.prizes,
{ id: crypto.randomUUID(), name: '', count: 1 },
],
})),
removePrize: (id) =>
set((state) => ({
prizes: state.prizes.filter((p) => p.id !== id),
})),
}));
注意这里 updatePrize 用了 map,只有被修改的那一行对象引用变了,其余行引用不变。配合 PrizeRow 里只订阅自己那一行:
function PrizeRow({ id }: { id: string }) {
// 只订阅这一行,别的行变化不触发本组件重渲染
const prize = usePrizeStore(
useShallow((s) => s.prizes.find((p) => p.id === id))
);
const updatePrize = usePrizeStore((s) => s.updatePrize);
if (!prize) return null;
return (
updatePrize(id, { name: e.target.value })}
/>
);
}
第三步:删除 Provider,组件外访问
App.tsx 里 11 层 Provider 全部删掉,换成直接 useStore。同时埋点代码可以直接 useFormStore.getState():
// utils/track.ts —— 不再需要传 ref 或从 props 拿
import { useFormStore } from '../stores/formStore';
import { usePrizeStore } from '../stores/prizeStore';
export function trackSave() {
const form = useFormStore.getState().formData;
const prizes = usePrizeStore.getState().prizes;
window.__TRACK__?.('save', { form, prizeCount: prizes.length });
}
五、踩坑与优化
坑 1:selector 返回新对象导致无限重渲染。 一开始写 useFormStore((s) => ({ a: s.formData.a, b: s.formData.b })),每次返回新对象,Object.is 永远 false,组件疯狂重渲染。解决:用 useShallow,或拆成两个独立 selector。这个坑在 Redux 里是常识,在 Zustand 里新手很容易踩。
坑 2:find 在每次渲染都执行,数组大时有开销。 s.prizes.find(...) 在 30 行时没问题,压测到 200 行时,每次 store 更新所有行都要执行一次 find。解决:在 PrizeRow 外层用 useMemo 缓存 id → index 的映射,或者把 prizes 改成 Record 结构。我们选了后者,最终压测 200 行时单次按键 6ms。
坑 3:DevTools 时间旅行对表单不友好。 开启 devtools middleware 后,每次输入都记录一个 action,内存涨得快。解决:只在 import.meta.env.DEV 下挂 devtools,生产构建剔除。
坑 4:迁移顺序错了会双写。 我们试过“新旧并存”过渡,结果同一个字段在 Context 和 Zustand 各存一份,同步逻辑写错导致数据不一致。后来改成按页面迁移:一个页面要么全 Context,要么全 Zustand,迁移完立刻删旧代码,不做双写。
六、效果数据
迁移前后用同一台机器(M1 MacBook Pro,Chrome 118)测同一页面:
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 单次按键 commit 耗时(Profiler) | 18ms | 4ms | -78% |
| 30 行奖品列表输入时重渲染组件数 | 31 | 2 | -94% |
| 200 行压测单次按键耗时 | 41ms | 6ms | -85% |
| 长列表滚动 FPS(Performance 面板) | 42 | 58 | +38% |
| 首屏 JS 体积(gzip) | 186KB | 174KB | -12KB |
App.tsx 行数 |
142 | 61 | -57% |
首屏体积减少主要来自删掉了自研 Context 的样板代码和部分 useMemo 包装,Zustand 本身只增加了 1.2KB。
七、总结
这次迁移最大的收获不是“换了个库”,而是想清楚了一件事:Context 适合低频、全局、结构稳定的数据(主题、用户、权限),不适合高频变更的表单状态。 如果你的项目里 Context 超过 5 个、或者有输入卡顿,优先考虑按域拆分 + 选择性订阅的方案。
选型上,Zustand 对我们的场景是甜点:API 少、体积小、组件外可访问、selector 粒度可控。如果业务流复杂(多步审批、撤销重做),Redux Toolkit 的 DevTools 和时间旅行仍然更香;如果状态是高度细粒度的独立单元,Jotai 值得一试。
最后给两条实操建议:一是迁移按页面粒度做,不要新旧双写;二是selector 一律先想清楚返回的是不是稳定引用,这一条能省掉 80% 的“为什么还在重渲染”的 debug 时间。