一、问题背景:Context不是不能用,是不能这么用
项目是一个React 18.2 + TypeScript 5.3的中后台系统,包含订单、库存、报表三大模块,约140个组件。早期为了赶进度,全局状态用的是最朴素的组合拳:跨层级用Context,相邻层级用Props。
问题在第三个月集中爆发。最典型的是订单列表页:一个筛选条件变更,需要更新filterContext,而这个Context的value是一个包含12个字段的大对象,且每次更新都重新创建引用。结果就是所有useContext(FilterContext)的组件全部重渲染,哪怕它只关心其中一个字段。我们用React Profiler抓了一次"修改订单状态筛选"的commit,重渲染组件数124个,commit耗时186ms,低端机上肉眼可见地卡。
更麻烦的是Props透传。OrderTable到OrderStatusBadge中间隔了4层,每层都得写一遍status、onStatusChange这些参数,改一个字段名要动5个文件。团队里已经有人在Props里塞...rest来偷懒了,这是要出事的信号。
所以目标很明确:找一个能按字段订阅、不需要Provider包裹、迁移成本低的状态方案。
二、环境与版本
- React 18.2.0,ReactDOM.createRoot并发模式
- TypeScript 5.3.3,
strict: true - Vite 5.0.10 构建
- 原状态方案:React Context(无第三方库)
- 候选库:Zustand 4.5.2、Jotai 2.6.3、Redux Toolkit 2.2.1
- 测试设备:MacBook Pro M1(开发机)+ 红米Note 11(低端安卓,用于真机验证)
三、方案设计:为什么最后选了Zustand
三个候选我们都做了半天的原型验证,结论如下。
Redux Toolkit:生态最全,DevTools体验最好,但样板代码多。我们的场景是"读多写少、按字段订阅",RTK的selector需要配合useSelector加浅比较,团队里对shallowEqual不熟的同学很容易写出全量订阅。而且迁移一个Context要拆slice、配store、改dispatch,工作量偏大。
Jotai:原子化思路很优雅,按字段拆分atom天然解决重渲染问题。但我们的状态有较强的"模块内聚合"特征(比如订单筛选的12个字段经常一起初始化、一起重置),拆成12个atom后,重置逻辑要写12遍或者写一个resetAll手动遍历,反而绕。
Zustand:单store、按selector订阅、不需要Provider、set时可以做浅合并。关键是迁移路径最平滑——原来的Context可以近乎一对一地改成一个store,useContext(X)改成useStore(s => s.x),改动面小。另外它只有1.2KB gzip,对首屏友好。
最终选Zustand,并约定两条规范:selector必须返回原子值或稳定引用,跨模块共享的状态才进全局store,纯UI状态留在组件内。
四、核心实现:分批迁移的6个步骤
我们没有一次性全量替换,而是按模块分三批,每批走同样的流程。
第1步:给现有Context做"字段清单"。用脚本扫出每个Context的消费点,列出"谁在用哪个字段"。这一步很土但很关键,后面拆selector全靠它。
第2步:建store,按模块分文件。以订单模块为例:
// stores/orderStore.ts
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
interface OrderFilter {
status: string;
keyword: string;
dateRange: [string, string] | null;
page: number;
pageSize: number;
}
interface OrderState {
filter: OrderFilter;
selectedIds: string[];
setFilter: (patch: Partial) => void;
resetFilter: () => void;
toggleSelect: (id: string) => void;
}
const defaultFilter: OrderFilter = {
status: 'all',
keyword: '',
dateRange: null,
page: 1,
pageSize: 20,
};
export const useOrderStore = create((set) => ({
filter: defaultFilter,
selectedIds: [],
// 注意:这里用函数式更新 + 浅合并,避免整个filter对象被替换导致引用变化
setFilter: (patch) =>
set((state) => ({ filter: { ...state.filter, ...patch } })),
resetFilter: () => set({ filter: defaultFilter, selectedIds: [] }),
toggleSelect: (id) =>
set((state) => ({
selectedIds: state.selectedIds.includes(id)
? state.selectedIds.filter((x) => x !== id)
: [...state.selectedIds, id],
})),
}));
第3步:改消费端,这是迁移的核心。原来的写法是const { status, setFilter } = useContext(OrderFilterContext),改成按字段订阅:
// components/OrderStatusFilter.tsx
import { useOrderStore } from '@/stores/orderStore';
import { useShallow } from 'zustand/react/shallow';
export function OrderStatusFilter() {
// 只订阅 status 和 setFilter,status 变化才重渲染
const status = useOrderStore((s) => s.filter.status);
const setFilter = useOrderStore((s) => s.setFilter);
return (
setFilter({ status: e.target.value, page: 1 })}>
全部
已支付
已发货
);
}
// 需要一次取多个字段时,用 useShallow 做浅比较,避免每次返回新对象导致重渲染
export function OrderToolbar() {
const { keyword, page, pageSize } = useOrderStore(
useShallow((s) => ({ keyword: s.filter.keyword, page: s.filter.page, pageSize: s.filter.pageSize }))
);
// ...
}
这里有个细节:Zustand v4 里多字段订阅如果直接返回对象字面量,每次set都会产生新引用,导致组件重渲染,所以必须用useShallow(v4.4+ 提供,v4.5.2 已稳定)。这是迁移期最容易踩的坑。
第4步:删除旧Context和Provider。确认模块内无引用后,删掉OrderFilterContext.tsx,并把``从路由组件上摘掉。我们按模块分批删,每批跑一次全量单测。
第5步:处理"派生状态"。原来有些Context里存的是计算结果(比如filteredOrders),迁移时不要塞进store,改用selector在组件里算,或者放进useMemo。store只存"源状态"。
第6步:加DevTools和持久化(可选)。开发环境挂上devtools中间件方便调试,筛选条件用persist存localStorage:
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
export const useOrderStore = create()(
devtools(
persist(
(set) => ({ /* ...同上... */ }),
{ name: 'order-store', partialize: (s) => ({ filter: s.filter }) }
),
{ name: 'OrderStore', enabled: import.meta.env.DEV }
)
);
partialize一定要写,否则selectedIds这种临时选择状态也会被持久化,刷新后出现"幽灵选中"。
五、踩坑与优化
坑1:selector返回新对象。迁移第一批时有3个组件用了useOrderStore(s => ({ a: s.filter.a, b: s.filter.b })),结果重渲染次数不降反升。原因是每次store更新,selector都返回新对象,Zustand的默认Object.is比较必然判定为变化。统一改用useShallow后解决。这条后来写进了团队的CR checklist。
坑2:在组件渲染期间调用set。有个同事在useEffect依赖里漏了字段,又在渲染路径上写了条件set,触发"Infinite loop"警告。Zustand不像Redux有严格的dispatch时机限制,反而更容易写出循环更新。规范是:set只允许在事件回调和useEffect里调用。
坑3:store文件循环依赖。orderStore引了userStore的getState(),userStore又反向引,Vite热更新时报"Cannot access before initialization"。解决办法是跨store读取一律用useXxxStore.getState()在函数体内调用,不要在模块顶层解构。
优化:给高频更新的selectedIds单独拆了一个useOrderSelectionStore,因为勾选操作很频繁,和筛选条件放一起会让订阅filter的组件也被牵连。拆完后勾选单行的commit耗时从12ms降到3ms。
六、效果数据
用同一份数据(订单列表200条、筛选条件12个)在红米Note 11上测了三轮取中位数:
| 指标 | Context/Props | Zustand 4.5.2 | 变化 |
|---|---|---|---|
| 单次筛选commit重渲染组件数 | 124 | 18 | -85.5% |
| 单次筛选commit耗时 | 186ms | 41ms | -78% |
| 首屏TTI(Lighthouse移动端) | 3.4s | 1.9s | -44% |
| 订单模块代码行数 | 2840 | 2510 | -11.6% |
| 构建产物体积(gzip) | 218KB | 219.2KB | +1.2KB |
产物体积基本没变(Zustand 1.2KB,抵消了删掉的Context样板),但运行时表现提升明显。代码行数下降主要来自Props透传参数的删除。需要说明的是,TTI的改善有一部分来自我们顺手删掉了两个无用的Context Provider,不全是Zustand的功劳,这里不夸大。
七、总结
Context不是原罪,问题是"大对象Context + 全量订阅"这个组合。如果你的项目里也有一个value包含十几个字段、消费点超过20个的Context,并且已经出现"改一个筛选条件卡一下"的症状,那迁移是值得的。
Zustand适合我们这种"模块聚合、按字段读、写操作集中在事件回调"的中后台场景。如果状态是高度细碎的独立开关,Jotai可能更合适;如果团队强依赖时间旅行调试和严格action流,RTK仍是稳妥选择。迁移的关键不在库本身,而在那两条规范——selector返回原子值或稳定引用、源状态进store、派生状态不进。守住这两条,迁移基本不会翻车。
最后提醒一句:别为了迁移而迁移。我们第一批只迁了订单模块,观察了两周线上错误率和性能数据稳定后才推第二批。状态管理库换错成本不低,小步验证比一步到位靠谱。