一、问题背景: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、派生状态不进。守住这两条,迁移基本不会翻车。

最后提醒一句:别为了迁移而迁移。我们第一批只迁了订单模块,观察了两周线上错误率和性能数据稳定后才推第二批。状态管理库换错成本不低,小步验证比一步到位靠谱。