一、问题背景: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:cartStore、userStore、skuStore。
// 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,这也是选它的原因之一。
七、总结
几点体会:
- Context 不该管高频变化的状态。低频的 theme、locale 用 Context 没问题,购物车这种秒级变化的状态必须上选择器订阅。
- Zustand 的迁移成本比想象的低。6 个页面、3 个 store,两个人两天迁完,中间还踩了选择器返回对象的坑。
- 选择器返回原始值是铁律。返回对象必须配
shallow,否则就是无限渲染。 - 不要为了统一而统一。用户信息变化频率低,我甚至留了一个 Context 没迁,没必要。
如果你的项目也在被 Context 全量重渲染折磨,且状态结构是领域对象型(不是细粒度 UI 状态),Zustand 是性价比最高的选择。Jotai 更适合表单、可视化编辑器那种原子化场景,选型时想清楚状态形状再动手。