一、问题背景:Context 不是银弹

项目是一个中型电商前端,React 18.2 + TypeScript 5.3 + Vite 5.0。早期为了快速迭代,全局状态用的是「Props 传递 + 三个 Context」的组合:UserContext、CartContext、UIContext。

一开始没问题。当商品详情页的 SKU 选择器接入 UIContext 后,事情开始变味了。UIContext 里同时放了主题、弹窗栈、SKU 选中项、滚动位置四类状态。结果是:用户点一下「红色 / L 码」,整个页面所有 useContext(UIContext) 的组件全部重渲染。

Profiler 抓出来的数据很直白:

  • 单次 SKU 点击,commit 阶段耗时 320ms
  • 参与重渲染的组件 47 个,其中真正需要更新的只有 6 个
  • 列表页滚动时 scrollTop 写进 Context,导致每帧都在触发消费者更新

Context 的本质问题不是「慢」,而是没有选择器。Provider 的 value 一变,所有消费者无条件更新,你只能靠 useMemo 拆 Provider、靠 memo 包组件来手动止血,代码很快变成一坨互相耦合的 memo 墙。

所以决定迁移。目标很明确:细粒度订阅、避免 Provider 嵌套地狱、保留 DevTools 调试能力。

二、环境与版本

{
  "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",
  "zustand-devtools": "1.0.0"
}

Node 20.11.0,pnpm 8.15.1。构建产物用 rollup-plugin-visualizer 对比迁移前后 bundle 体积。

三、方案设计:为什么选 Zustand 而不是 Redux Toolkit / Jotai

三个候选:

方案 优势 我们的顾虑
Redux Toolkit 生态成熟、DevTools 强 样板代码多,团队 4 个人里 2 个没用过;bundle +12KB
Jotai 原子化,天然细粒度 状态分散,跨模块派生逻辑要写很多 atom 组合,调试时找不到「全局视图」
Zustand API 极简、selector 天然支持、无 Provider 社区规模比 Redux 小

选 Zustand 的决定性因素是两点:

  1. 无 Provider。store 是模块级单例,create() 出来直接 import 用,省掉了 Provider 嵌套,也避免了「Provider 位置放错导致子树拿不到值」这类低级 bug。
  2. selector + shallow 比较。useStore(s => s.sku) 只订阅这一个字段,字段不变就不重渲染,这正是 Context 缺的能力。

迁移策略上,我们没有一把梭,而是按模块灰度:

  • Phase 1:新建 stores/ 目录,把 UIContext 里的 SKU、弹窗栈拆成独立 store
  • Phase 2:CartContext 迁移,保留旧 Context 作为兼容层,内部读写转发到 store
  • Phase 3:删除旧 Context,清理所有 useContext 引用
  • Phase 4:UserContext 最后迁,因为涉及登录态持久化,风险最高

四、核心实现

4.1 store 定义(带 devtools 与 immer)

// stores/useSkuStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface SkuState {
  selectedColor: string | null;
  selectedSize: string | null;
  stock: Record;
  setColor: (color: string) => void;
  setSize: (size: string) => void;
  reset: () => void;
}

export const useSkuStore = create()(
  devtools(
    immer((set) => ({
      selectedColor: null,
      selectedSize: null,
      stock: {},
      setColor: (color) =>
        set((state) => {
          state.selectedColor = color;
          // 换颜色时尺码可能失效,顺手清掉
          state.selectedSize = null;
        }, false, 'sku/setColor'),
      setSize: (size) =>
        set((state) => { state.selectedSize = size; }, false, 'sku/setSize'),
      reset: () => set({ selectedColor: null, selectedSize: null }, false, 'sku/reset'),
    })),
    { name: 'SkuStore', enabled: import.meta.env.DEV }
  )
);

注意 set 的第三个参数是 action name,DevTools 里能看到具体是哪个 action 触发的更新,排查「谁改了我的状态」非常有用。enabled 只在 DEV 打开,生产环境 devtools 中间件会被 tree-shake 掉,实测 bundle 无增量。

4.2 组件侧订阅:selector 粒度是关键

// components/SkuSelector.tsx
import { useShallow } from 'zustand/react/shallow';
import { useSkuStore } from '@/stores/useSkuStore';

export function SkuSelector() {
  // 只订阅两个字段,用 useShallow 避免对象字面量每次新建引用
  const { selectedColor, selectedSize } = useSkuStore(
    useShallow((s) => ({ selectedColor: s.selectedColor, selectedSize: s.selectedSize }))
  );
  const setColor = useSkuStore((s) => s.setColor);
  const setSize = useSkuStore((s) => s.setSize);

  return (

      {/* ... */}

        {selectedColor ?? '-'} / {selectedSize ?? '-'}


  );
}

这里有个必须讲清楚的点:selector 返回对象时一定要用 useShallow。Zustand v4 默认用 Object.is 比较 selector 返回值,你写 s => ({ a: s.a, b: s.b }),每次 store 更新都会生成新对象,Object.is 恒为 false,等于全量订阅,性能直接退回 Context 水平。我们迁移初期就踩了这个坑,后面细说。

五、踩坑与优化

坑 1:selector 返回新对象导致「假订阅」。 上面说的 useShallow 问题。迁移后第一版测出来 SKU 切换还是 200ms+,Profiler 一看,SkuSelector 的兄弟组件全在重渲染。原因就是有人写了 useSkuStore(s => ({ color: s.selectedColor })),没加 shallow。全局 grep 出 9 处,统一改掉后降到 70ms 左右。这条建议写进团队 lint 规则:selector 返回非原始值时强制 useShallow。

坑 2:在 effect 里读 store 拿到旧值。 有个组件需要在 SKU 变化时上报埋点,写成了:

// 错误写法
const color = useSkuStore(s => s.selectedColor);
useEffect(() => {
  track('sku_change', { color: useSkuStore.getState().selectedColor });
}, [color]);

getState() 是同步快照,在 effect 执行时机上没问题,但这里真正的问题是依赖数组漏了 size。正确做法是从 selector 里一起取出来,或者用 useSkuStore.subscribe 做副作用订阅:

useEffect(() => {
  const unsub = useSkuStore.subscribe(
    (s) => [s.selectedColor, s.selectedSize],
    ([color, size]) => track('sku_change', { color, size }),
    { equalityFn: shallow }
  );
  return unsub;
}, []);

坑 3:immer 与 Map/Set。 stock 原本设计成 Map,immer 对 Map 的 draft 支持需要 enableMapSet(),忘了调就报 Cannot perform 'set' on a draft 。后来干脆改成普通对象 Record,省事。

优化:selector 拆分 + 计算属性外置。 把「当前 SKU 是否有货」这类派生值从组件内 useMemo 挪到 store 的 selector 函数里,多个组件复用同一个 selector,命中缓存:

export const selectInStock = (s: SkuState) => {
  const key = `${s.selectedColor}-${s.selectedSize}`;
  return (s.stock[key] ?? 0) > 0;
};
// 组件里:const inStock = useSkuStore(selectInStock);

六、效果数据

用 Chrome Performance + React Profiler 在 M1 MacBook / 中端安卓机(Redmi Note 12)各测 5 次取中位数:

指标 迁移前(Context) 迁移后(Zustand) 变化
商品详情页 TTI 3.2s 1.9s -40.6%
SKU 切换 commit 耗时 320ms 68ms -78.8%
单次交互重渲染组件数 47 6 -87.2%
列表页滚动 FPS(中端机) 42 58 +38%
bundle(gzip 后) 186KB 179KB -3.8%

bundle 反而小了一点,因为删掉了三层 Provider 包装和一堆 useMemo/memo 样板。中端机上的滚动 FPS 提升最明显——之前 scrollTop 写 Context 导致每帧全量更新,现在滚动状态干脆不进 store,留在组件本地 useState,只有需要跨组件共享的才进 store。

七、总结

Context 适合低频、粗粒度的全局状态(主题、locale、登录用户),一旦状态高频变化且有多个消费者,它的「无选择器」缺陷就会放大成性能问题。Zustand 的迁移成本比想象中低——store 定义 + 组件替换 selector,一个模块半天能搞定;真正的成本在于selector 粒度设计和团队规范(useShallow、禁止在 selector 里做重计算)。

如果重来一次,我会更早做两件事:一是迁移前先用 Profiler 把「哪些组件真的需要重渲染」列成清单,避免无脑替换;二是第一天就把 useShallow 写进 ESLint 自定义规则。状态管理库解决的是订阅粒度问题,不是架构问题——store 该拆多细、哪些状态根本不该全局化,这些还是得人来判断。