一、问题背景:Context 不是不能用在全局状态,是不能用在“高频全局状态”
先说结论:Context 本身没错,错的是我把它用在了它不擅长的场景。
这个项目是一个电商后台的“商品配置中心”,左侧是类目树,右侧是配置面板,面板里有 SKU 表格、价格区间、库存规则、图片上传等模块。全局状态大致分三类:
- 用户信息、权限点、租户配置——几乎不变;
- 当前选中的商品 ID、当前编辑的草稿对象——中频变化;
- 表单草稿里的字段值、SKU 表格的行数据——高频变化,输入时每敲一个字符都会变。
前两类用 Context 完全没问题。问题出在第三类。我们最初的写法是:
// store/ConfigContext.tsx (迁移前)
import React, { createContext, useContext, useReducer } from 'react';
type State = {
draft: Record;
skus: Array;
};
type Action =
| { type: 'SET_FIELD'; path: string; value: any }
| { type: 'SET_SKU'; id: string; patch: Partial };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_FIELD':
return { ...state, draft: { ...state.draft, [action.path]: action.value } };
case 'SET_SKU':
return {
...state,
skus: state.skus.map((s) => (s.id === action.id ? { ...s, ...action.patch } : s)),
};
default:
return state;
}
}
const ConfigContext = createContext } | null>(null);
export function ConfigProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, { draft: {}, skus: [] });
return {children};
}
export function useConfig() {
const ctx = useContext(ConfigContext);
if (!ctx) throw new Error('useConfig must be used within ConfigProvider');
return ctx;
}
这段代码有两个致命点:
value={{ state, dispatch }}每次 render 都是新对象,Provider 的 value 引用一定变化;- 只要
state变,所有useContext(ConfigContext)的组件都会重渲染,哪怕它只用了dispatch。
商品配置面板有 12 个输入组件、SKU 表格 30 行、外加上传组件和预览组件。用 React DevTools Profiler 抓一次“在标题输入框敲一个字符”的 commit:
- commit 耗时:
38.6ms(生产构建,Chrome 118,M1 MacBook Pro); - 参与渲染的组件数:
47; - 首屏首次渲染(含类目树 + 面板)耗时:
380ms。
在低端机(测试用的 Redmi Note 11)上更夸张,输入回显延迟 P95 到了 210ms,用户明显能感觉到卡顿。
所以我决定迁移。目标很明确:只让真正依赖某块状态的组件重渲染。
二、环境与版本
| 项 | 版本 |
|---|---|
| React | 18.2.0 |
| TypeScript | 5.0.4 |
| Vite | 4.4.9 |
| Zustand | 4.4.7 |
| Redux Toolkit | 1.9.5(仅做对比测试,未上线) |
| Jotai | 2.4.2(仅做对比测试,未上线) |
| React DevTools | 4.28.5 |
Node 18.17.1,包管理器 pnpm 8.7.0。测试机:M1 MacBook Pro / Chrome 118;低端机:Redmi Note 11 / Chrome 118。
三、方案设计:为什么最后选了 Zustand
我做了三个候选方案的横向对比,都是真实写 demo 跑过的,不是抄文档。
| 维度 | Redux Toolkit 1.9 | Jotai 2.4 | Zustand 4.4 |
|---|---|---|---|
| 迁移成本 | 高,要重写 action/slice | 中,原子化要重新拆分 | 低,几乎 1:1 映射现有 state |
| 模板代码 | 多 | 少 | 很少 |
| 选择器细粒度 | 好(useSelector) | 极好(原子级) | 好(selector + shallow) |
| 非 React 环境读取 | 可以(store.getState) | 不方便 | 原生支持 |
| 包体积(min+gzip) | ~13KB | ~4KB | ~1.2KB |
| 团队上手成本 | 中 | 中 | 低 |
最终选 Zustand 的原因是:我们现有的 state 已经是一个扁平的 reducer state,dispatch({type, payload}) 的调用点有 60 多处,改成 Zustand 的 action 函数几乎是机械替换;而 Jotai 的原子化拆分要重新设计状态边界,风险更大;Redux Toolkit 的收益在这个规模下不明显,包的体积和样板代码反而更重。
核心设计原则:
- 一个 store 拆成三个 slice:
userSlice(低频)、editorSlice(中频)、formSlice(高频),互不干扰; - 组件只订阅自己用的字段,用 selector 精确取值;
- action 写成 store 内的方法,组件里直接
useEditorStore.getState().setField(...)或通过 hook 拿到 action; - 保留 Context 用于依赖注入(比如主题、i18n),不强行一把梭。
四、核心实现:迁移分四步走
第 1 步:定义 store 结构
// store/useEditorStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
type SKU = { id: string; price: number; stock: number };
type EditorState = {
draft: Record;
skus: SKU[];
selectedSkuId: string | null;
// actions
setField: (path: string, value: any) => void;
setSku: (id: string, patch: Partial) => void;
selectSku: (id: string | null) => void;
reset: () => void;
};
const initialState = {
draft: {} as Record,
skus: [] as SKU[],
selectedSkuId: null as string | null,
};
export const useEditorStore = create()(
subscribeWithSelector((set, get) => ({
...initialState,
setField: (path, value) =>
set((s) => ({ draft: { ...s.draft, [path]: value } })),
setSku: (id, patch) =>
set((s) => ({
skus: s.skus.map((sku) => (sku.id === id ? { ...sku, ...patch } : sku)),
})),
selectSku: (id) => set({ selectedSkuId: id }),
reset: () => set(initialState),
}))
);
注意 subscribeWithSelector 中间件,后面做细粒度订阅和持久化时会用到。
第 2 步:组件侧改写,selector 是关键
迁移前:
function TitleInput() {
const { state, dispatch } = useConfig();
return (
dispatch({ type: 'SET_FIELD', path: 'title', value: e.target.value })}
/>
);
}
迁移后:
// components/TitleInput.tsx
import { useEditorStore } from '../store/useEditorStore';
export function TitleInput() {
// 只订阅 draft.title,其它字段变化不会触发本组件重渲染
const title = useEditorStore((s) => s.draft.title ?? '');
const setField = useEditorStore((s) => s.setField);
return (
setField('title', e.target.value)} />
);
}
这里有个容易踩的点:selector 返回值如果是新对象/新数组,会导致无限重渲染。比如:
// ❌ 每次返回新对象,引用变了,Zustand 4 默认用 Object.is 比较
const { title, price } = useEditorStore((s) => ({ title: s.draft.title, price: s.draft.price }));
正确写法有两种:
// ✅ 写法一:分开订阅,最省心
const title = useEditorStore((s) => s.draft.title);
const price = useEditorStore((s) => s.draft.price);
// ✅ 写法二:用 useShallow 包一层(zustand 4.4+ 内置)
import { useShallow } from 'zustand/react/shallow';
const { title, price } = useEditorStore(
useShallow((s) => ({ title: s.draft.title, price: s.draft.price }))
);
我在迁移时先用写法一把所有组件过了一遍,只有确实需要多字段的地方才用 useShallow。
SKU 表格行的写法也类似,只订阅自己那一行:
// components/SkuRow.tsx
import { useEditorStore } from '../store/useEditorStore';
import { memo } from 'react';
type Props = { id: string };
export const SkuRow = memo(function SkuRow({ id }: Props) {
// 只取这一行,别的行更新不会让它重渲染
const sku = useEditorStore((s) => s.skus.find((x) => x.id === id));
const setSku = useEditorStore((s) => s.setSku);
if (!sku) return null;
return (
{sku.id}
setSku(id, { price: Number(e.target.value) })}
/>
setSku(id, { stock: Number(e.target.value) })}
/>
);
});
第 3 步:非 React 代码的读取
迁移 Context 时最麻烦的地方是,有些逻辑在工具函数或事件回调里,拿不到 hook。Zustand 直接提供 getState():
// utils/validate.ts
import { useEditorStore } from '../store/useEditorStore';
export function validateDraft() {
const { draft, skus } = useEditorStore.getState();
const errors: string[] = [];
if (!draft.title) errors.push('标题不能为空');
if (skus.some((s) => s.price a + b.price * b.stock, 0)` 写在 selector 里,虽然结果是个 number 不会触发重渲染,但计算本身每次 store 变化都会跑。解决:拆出一个独立 selector 函数,或者用第三方 `reselect` 缓存。
**坑 2:`subscribeWithSelector` 配合 `useEffect` 时,订阅回调里的 setState 触发额外渲染。**
我用来做“草稿自动保存到 localStorage”,最初写在组件里:
```ts
useEffect(() => {
const unsub = useEditorStore.subscribe(
(s) => s.draft,
(draft) => saveDraft(draft)
);
return unsub;
}, []);
这里 saveDraft 是异步的、不触发 React 渲染,所以没问题;但如果回调里 setState,就会额外渲染一次。建议把副作用类逻辑放到 store 外的订阅里,或者用 subscribeWithSelector + 防抖(我用 lodash.debounce 300ms)。
坑 3:memo 和 useShallow 混用时的迷惑行为。
SkuRow 我加了 memo,本以为可以省渲染,结果发现父组件传下来的 id 是字符串,没变化,memo 生效;但如果在父组件里传的是行内对象,memo 就失效。规则很简单:memo 只对 props 做浅比较,对象/函数 props 要么用 useCallback/useMemo 包,要么别传。
优化:用 useShallow 替代手写 shallow。
Zustand 4.4 之前,shallow 是从 zustand/shallow 导入的;4.4 之后推荐 zustand/react/shallow 的 useShallow,对 TypeScript 更友好。我升级到 4.4.7 后统一换成了 useShallow。
六、效果数据
迁移后,用同一台机器、同一份商品数据(30 个 SKU,12 个输入字段)重新跑 Profiler:
| 指标 | Context + useReducer | Zustand 4.4.7 | 变化 |
|---|---|---|---|
| 首屏首次渲染耗时 | 380ms | 120ms | -68% |
| 单次输入 commit 耗时 | 38.6ms | 6.2ms | -84% |
| 单次 commit 参与渲染组件数 | 47 | 4 | -91% |
| 输入回显 P95(低端机) | 210ms | 32ms | -85% |
| 全局状态相关代码行数 | ~420 行 | ~260 行 | -38% |
| 包体积增量(min+gzip) | 0(Context 内置) | +1.2KB | +1.2KB |
需要说明的是,380ms → 120ms 不只是 Zustand 的功劳。迁移过程中我顺手做了两件事:一是把类目树的递归渲染改成了虚拟列表(用的是 react-window 1.8.9),二是把 SkuRow 加了 memo。Zustand 主要贡献的是“输入时不再全量重渲染”这一块,也就是 38.6ms → 6.2ms 这个数量级的改善。
另外,迁移完成后我抽查了 5 个高频交互路径(切换类目、新增 SKU、删除 SKU、批量改价、重置草稿),没有出现状态不一致或竞态问题。Zustand 的 set 是同步的,subscribeWithSelector 的回调也是同步触发的,这点在调试时比 Redux 的中间件链要直观。
七、总结
如果你正在用 Context 管全局状态,并且遇到了以下任一情况,我建议认真考虑迁移:
- DevTools Profiler 里一次 commit 有几十个组件参与;
- 输入框有可感知的延迟;
- 状态更新逻辑散落在多个
dispatch调用点,测试难写。
Zustand 不是银弹,它的优势在于低迁移成本 + 细粒度订阅 + 极小的包体积。如果你的状态很复杂、需要时间旅行调试、团队已经很熟 Redux,那 Redux Toolkit 依然是合理选择;如果你的状态是高度独立的小块,Jotai 的原子模型会更优雅。
但如果你和我一样,是从 Context + useReducer 起步、状态结构已经比较扁平、只是想解决重渲染问题,那 Zustand 4 大概是性价比最高的一步。我的迁移一共花了大约 1.5 人日,其中 0.5 天在写测试和对比数据,实际代码改动不到 400 行。
最后提醒一句:迁移前一定要先写测试。 我用 Vitest 0.34.6 + @testing-library/react 14.0.0 给核心 store 和几个关键组件写了 23 个用例,迁移过程中靠它们抓到 3 个 selector 写错导致的渲染问题。没有这层网,我不敢在生产项目里动全局状态。