**

一、问题背景: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 变得不可忽视:

  1. 输入卡顿:在基础信息表单里每敲一个字,Page 的 formData 变化 → 所有 Consumer 重渲染。用 React DevTools Profiler 测,单次按键 commit 阶段 18ms,其中 PrizeList 的 30 行全部重渲染,尽管它们的数据没变。
  2. Context 嵌套地狱:11 个 Provider 套在 App 里,新人接手第一句话是“这玩意儿从哪拿的?”
  3. 无法在组件外读取状态:埋点、请求拦截器里想拿当前表单快照,只能把 ref 一层层传出去。

我们决定做一次状态管理重构,目标很明确:按域拆分状态、消除无关重渲染、允许组件外访问。

二、环境与版本

  • React 18.2.0 / TypeScript 5.2.2
  • Vite 4.4.9,构建产物分析用 rollup-plugin-visualizer 5.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 时间。