一、问题背景:Context 不是状态管理库

先说结论:Context 是依赖注入工具,不是状态管理方案。 这句话我在迁移完之后才真正理解。

项目是一个 B2C 电商前端,React 18.2 + TypeScript 5.3 + Vite 5.0,商品详情页有 SKU 选择器、库存提示、促销标签、购物车角标、推荐列表等模块。早期为了省事,全局状态用 Context + useReducer 一把梭:

// 旧代码:GlobalContext.tsx
const GlobalContext = createContext(null);

export function GlobalProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (

      {children}

  );
}

问题在商品详情页集中爆发。SKU 切换时 dispatch 一个新 state,Provider 的 value 引用变了,所有 useContext(GlobalContext) 的组件全部重渲染,哪怕它只关心 cart.count

用 React DevTools Profiler 录了一次 SKU 切换:

  • 提交耗时:186ms
  • 重渲染组件数:47
  • 其中真正需要更新的:5

低端安卓机(骁龙 665)上直接掉到 28fps,SKU 点击有明显延迟。更糟的是 useMemo/useCallback 到处补,代码已经没法看了。

二、环境与版本

react: 18.2.0
react-dom: 18.2.0
typescript: 5.3.3
vite: 5.0.10
zustand: 4.5.0
@reduxjs/toolkit: 2.0.1
jotai: 2.6.0
valtio: 1.12.0

对比测试都在同一台 M1 MacBook Pro 上跑 Chrome 120,低端机数据来自真机 Redmi Note 8。

三、方案设计:四个候选的取舍

我拉了个对比表,重点看四件事:重渲染粒度、样板代码量、DevTools、学习成本

方案 重渲染粒度 样板代码 DevTools 心智负担
Redux Toolkit 选择器级 极好
Zustand 选择器级 极低
Jotai 原子级 一般
Valtio 属性级(Proxy) 极低 一般

排除过程:

  • Redux Toolkit:生态最成熟,但 slice + thunk + hook 三层样板,我们团队 6 个人,没必要为这点状态上重武器。
  • Jotai:原子模型很优雅,但我们的状态是「购物车」「用户」这种领域对象,拆成原子反而割裂。
  • Valtio:Proxy 写法舒服,但 SSR 和快照调试踩过坑,团队没人熟。
  • Zustand:选择器订阅、无 Provider、TS 推导好,代码量最少

最终选 Zustand 4.5.0,理由很朴素:迁移成本最低,重渲染问题能直接解决

四、核心实现:三步迁移

4.1 第一步:拆 store

把原来一个巨型 Context 拆成三个独立 store:cartStoreuserStoreskuStore

// stores/cartStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface CartItem {
  skuId: string;
  count: number;
  price: number;
}

interface CartState {
  items: CartItem[];
  count: number;
  addItem: (item: CartItem) => void;
  removeItem: (skuId: string) => void;
}

export const useCartStore = create()(
  subscribeWithSelector((set, get) => ({
    items: [],
    count: 0,
    addItem: (item) =>
      set((state) => {
        const exist = state.items.find((i) => i.skuId === item.skuId);
        const items = exist
          ? state.items.map((i) =>
              i.skuId === item.skuId ? { ...i, count: i.count + item.count } : i
            )
          : [...state.items, item];
        return {
          items,
          count: items.reduce((s, i) => s + i.count, 0),
        };
      }),
    removeItem: (skuId) =>
      set((state) => {
        const items = state.items.filter((i) => i.skuId !== skuId);
        return { items, count: items.reduce((s, i) => s + i.count, 0) };
      }),
  }))
);

注意 create()(...) 那个空调用,这是 Zustand 4.x + TS 的固定写法,漏了会推导不出类型。

4.2 第二步:组件改造

旧代码:

const { state } = useContext(GlobalContext);
return ;

新代码:

const count = useCartStore((s) => s.count);
return ;

关键点:选择器返回原始值,Zustand 默认用 Object.is 比较,只订阅了 count,其他字段变化不触发重渲染。

如果选择器返回对象,必须上 shallow

import { shallow } from 'zustand/shallow';

const { items, count } = useCartStore(
  (s) => ({ items: s.items, count: s.count }),
  shallow
);

不加 shallow 会每次返回新对象,触发无限重渲染——这个坑后面细说。

4.3 第三步:清理 Provider

删掉 `,main.tsx` 里少了一层嵌套。这是 Zustand 相比 Context 最爽的地方:store 就是个模块级单例,不需要 Provider 树

五、踩坑与优化

坑 1:选择器返回新对象导致无限渲染

迁移第一版,我写了个计算购物车总价的 hook:

// ❌ 错误写法
const total = useCartStore((s) => ({
  price: s.items.reduce((sum, i) => sum + i.price * i.count, 0),
}));

每次渲染选择器都返回新对象,Object.is 判定不等,触发重渲染,重渲染又调选择器……直接栈溢出。

正确姿势二选一:

// ✅ 方案 A:返回原始值
const total = useCartStore((s) =>
  s.items.reduce((sum, i) => sum + i.price * i.count, 0)
);

// ✅ 方案 B:用 shallow 比较
import { shallow } from 'zustand/shallow';
const { total } = useCartStore(
  (s) => ({ total: s.items.reduce((sum, i) => sum + i.price * i.count, 0) }),
  shallow
);

方案 A 更好,计算逻辑放选择器里,只返回标量

坑 2:跨 store 联动

加购成功后要清空 SKU 选择,涉及 cartStore 和 skuStore。不要在 cartStore 里 import skuStore,会循环依赖。用订阅:

// 在模块初始化处
useCartStore.subscribe(
  (s) => s.items.length,
  (len, prevLen) => {
    if (len > prevLen) {
      useSkuStore.getState().resetSelection();
    }
  }
);

subscribeWithSelector 中间件是关键,没它 subscribe 拿不到选择器。

坑 3:DevTools 时间旅行

Zustand 的 devtools 中间件要手动加:

import { devtools } from 'zustand/middleware';

export const useCartStore = create()(
  devtools(
    subscribeWithSelector((set) => ({ /* ... */ })),
    { name: 'cartStore', enabled: import.meta.env.DEV }
  )
);

注意中间件顺序:devtools(subscribeWithSelector(...)),写反了 subscribe 的选择器会失效。

六、效果数据

迁移前后对比(商品详情页,Chrome Performance 录制,取 10 次中位数):

指标 Context 版本 Zustand 版本 变化
SKU 切换重渲染组件数 47 6 -87%
SKU 切换提交耗时 186ms 32ms -83%
加购重渲染组件数 47 6 -87%
首屏 TTI(M1) 3.4s 2.1s -38%
低端机帧率(SKU 切换) 28fps 58fps +107%
GlobalContext 代码行数 412 行 0 删除
三个 store 总行数 - 268 行 -

首屏 TTI 的改善主要来自去掉 Provider 嵌套减少不必要的重渲染,不是 Zustand 本身快。

Bundle 体积上,zustand@4.5.0 + shallow 压缩后 1.2KB(gzip),Redux Toolkit 是 13KB,这也是选它的原因之一。

七、总结

几点体会:

  1. Context 不该管高频变化的状态。低频的 theme、locale 用 Context 没问题,购物车这种秒级变化的状态必须上选择器订阅。
  2. Zustand 的迁移成本比想象的低。6 个页面、3 个 store,两个人两天迁完,中间还踩了选择器返回对象的坑。
  3. 选择器返回原始值是铁律。返回对象必须配 shallow,否则就是无限渲染。
  4. 不要为了统一而统一。用户信息变化频率低,我甚至留了一个 Context 没迁,没必要。

如果你的项目也在被 Context 全量重渲染折磨,且状态结构是领域对象型(不是细粒度 UI 状态),Zustand 是性价比最高的选择。Jotai 更适合表单、可视化编辑器那种原子化场景,选型时想清楚状态形状再动手。