一、问题背景: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 的决定性因素是两点:
- 无 Provider。store 是模块级单例,
create()出来直接 import 用,省掉了 Provider 嵌套,也避免了「Provider 位置放错导致子树拿不到值」这类低级 bug。 - 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 该拆多细、哪些状态根本不该全局化,这些还是得人来判断。