一、问题背景:Context 不是不能用在全局状态,是不能用在“高频全局状态”

先说结论:Context 本身没错,错的是我把它用在了它不擅长的场景。

这个项目是一个电商后台的“商品配置中心”,左侧是类目树,右侧是配置面板,面板里有 SKU 表格、价格区间、库存规则、图片上传等模块。全局状态大致分三类:

  1. 用户信息、权限点、租户配置——几乎不变;
  2. 当前选中的商品 ID、当前编辑的草稿对象——中频变化;
  3. 表单草稿里的字段值、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 的收益在这个规模下不明显,包的体积和样板代码反而更重。

核心设计原则:

  1. 一个 store 拆成三个 sliceuserSlice(低频)、editorSlice(中频)、formSlice(高频),互不干扰;
  2. 组件只订阅自己用的字段,用 selector 精确取值;
  3. action 写成 store 内的方法,组件里直接 useEditorStore.getState().setField(...) 或通过 hook 拿到 action;
  4. 保留 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:memouseShallow 混用时的迷惑行为。

SkuRow 我加了 memo,本以为可以省渲染,结果发现父组件传下来的 id 是字符串,没变化,memo 生效;但如果在父组件里传的是行内对象,memo 就失效。规则很简单:memo 只对 props 做浅比较,对象/函数 props 要么用 useCallback/useMemo 包,要么别传。

优化:用 useShallow 替代手写 shallow

Zustand 4.4 之前,shallow 是从 zustand/shallow 导入的;4.4 之后推荐 zustand/react/shallowuseShallow,对 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 写错导致的渲染问题。没有这层网,我不敢在生产项目里动全局状态。