一、问题背景:Context 不是状态管理库,但我们一直当它是

项目是 2023 年启动的 React 18 + TypeScript 运营中台,初期只有 3 个页面,用 Context 传用户信息和权限足够了。但到今年 6 月,代码里已经有 47 个 createContext,嵌套层级最深 11 层。

具体症状:
- 首屏 React.lazy 加载后,Profiler 显示 commit 阶段耗时 3.8s
- 修改一个筛选条件,触发 200+ 组件 re-render(React DevTools 高亮)
- 每次新增一个全局状态,就要在 App.tsx 里加一层 Provider,代码 review 时被吐槽“Provider 地狱”
- 团队新同学改一个表单,不小心动了 Context value 的引用,导致整个页面白屏 0.5s

我们统计了 React.ProfileractualDuration,在 1000 条表格数据 + 5 个图表的面板页,Context 方案下每次状态更新平均耗时 186ms,而用户感知的卡顿阈值是 100ms。

二、环境与版本

  • React 18.2.0
  • TypeScript 5.2.2
  • Vite 4.4.5
  • 原方案:React Context + useReducer
  • 候选库:Zustand 4.4.1、Jotai 2.4.2、Valtio 1.11.0
  • 最终选择:Zustand 4.4.1(理由见下)

三、方案设计:为什么选 Zustand 而不是 Jotai/Valtio

我们列了一个对比表,重点看迁移成本和性能:

维度 Context Zustand Jotai Valtio
学习成本
迁移成本 - 低(API 类似 useReducer) 高(原子化拆分) 中(Proxy 心智)
重渲染控制 好(selector) 极好
包体积 0 1.2KB 3.1KB 2.8KB
异步支持 手动 内置 内置 内置
DevTools

选 Zustand 的核心原因:
1. 迁移时可以直接把 useReducerdispatch 替换成 store 的 action,代码改动最小
2. useStore(state => state.xxx) 的 selector 机制天然解决重渲染问题
3. 不需要包 Provider,直接 import store 就能用,删掉 47 层嵌套

四、核心实现:3 天迁移步骤与代码

第 1 天:抽离 Context,建立 store 骨架

原代码(简化):

// 旧:Context + useReducer
const FilterContext = createContext(null);

function FilterProvider({ children }) {
  const [state, dispatch] = useReducer(filterReducer, initialState);
  return (

      {children}

  );
}

// 组件里
const { state, dispatch } = useContext(FilterContext);

新代码:

// 新:Zustand store
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';

interface FilterState {
  keyword: string;
  dateRange: [string, string] | null;
  status: 'all' | 'pending' | 'done';
  setKeyword: (v: string) => void;
  setDateRange: (v: [string, string] | null) => void;
  reset: () => void;
}

export const useFilterStore = create()(
  devtools(
    subscribeWithSelector((set) => ({
      keyword: '',
      dateRange: null,
      status: 'all',
      setKeyword: (v) => set({ keyword: v }),
      setDateRange: (v) => set({ dateRange: v }),
      reset: () => set({ keyword: '', dateRange: null, status: 'all' }),
    })),
    { name: 'filter-store' }
  )
);

组件里改成:

const keyword = useFilterStore((s) => s.keyword);
const setKeyword = useFilterStore((s) => s.setKeyword);

关键点:不要写 const store = useFilterStore(),那会订阅整个 store,等于没优化。必须用 selector。

第 2 天:批量替换 47 个 Context

我们写了一个 codemod 脚本(jscodeshift),把 useContext(XxxContext) 自动替换成 useXxxStore((s) => s.xxx)。但遇到两个问题:

  1. 有些 Context 的 value 是对象,selector 要拆成多个字段
  2. 有些 Context 依赖 props 初始化,需要改成 store 的 init action

手动改了 12 个复杂 Context,剩下 35 个用脚本完成。当天跑通所有单测。

第 3 天:性能优化与边界处理

重点处理三个场景:

场景 1:派生状态
原来在 Context 里用 useMemo 算的,现在用 selector + useShallow

import { useShallow } from 'zustand/react/shallow';

const { pendingCount, doneCount } = useTaskStore(
  useShallow((s) => ({
    pendingCount: s.tasks.filter((t) => t.status === 'pending').length,
    doneCount: s.tasks.filter((t) => t.status === 'done').length,
  }))
);

场景 2:异步 action

const fetchTasks = async () => {
  set({ loading: true });
  try {
    const data = await api.getTasks();
    set({ tasks: data, loading: false });
  } catch (e) {
    set({ error: e.message, loading: false });
  }
};

场景 3:跨 store 通信
subscribeWithSelector 监听:

useFilterStore.subscribe(
  (s) => s.keyword,
  (keyword) => {
    useTaskStore.getState().fetchTasks({ keyword });
  }
);

五、踩坑与优化:5 个真实问题

  1. selector 返回新对象导致无限重渲染
    useStore((s) => ({ a: s.a, b: s.b })) 每次返回新引用,必须用 useShallow

  2. DevTools 里 action 名字不显示
    要在 set 时传第三个参数:set({ keyword: v }, false, 'filter/setKeyword')

  3. SSR 场景下 store 单例污染
    我们中台是 CSR,没遇到,但文档里建议用 createStore + useStore 配合 Context 做 per-request 隔离。

  4. TypeScript 类型推导在 middleware 下失效
    必须写 create()(devtools(...)),注意那个多出来的 ()

  5. 旧代码里 useContext 的默认值逻辑丢失
    Context 有 defaultValue,Zustand 没有,需要手动在 store 初始化时处理。

六、效果数据:首屏 3.8s → 1.4s,重渲染下降 92%

迁移前后用同一台机器(MacBook Pro M1, 16GB)跑 5 次取中位数:

指标 Context 方案 Zustand 方案 变化
首屏渲染耗时 3.8s 1.4s -63%
状态更新平均耗时 186ms 23ms -87%
单次更新重渲染组件数 200+ 16 -92%
App.tsx Provider 层数 47 0 -100%
包体积(gzip) 0 +1.2KB +1.2KB

React Profiler 截图显示,迁移后 commit 阶段从 186ms 降到 23ms,火焰图从“一片红”变成“零星几个黄块”。

七、总结

Context 适合低频、全局、不常变的状态(主题、用户信息),但一旦用来管理高频更新的业务状态,就是性能灾难。Zustand 的 selector 机制和零 Provider 设计,让迁移成本远低于 Jotai 的原子化重构。

如果你也在维护一个 30+ Context 的 React 项目,建议先做一件事:打开 React DevTools,勾选“Highlight updates when components render”,然后随便改一个输入框。如果满屏高亮,那就该迁了。

迁移不是重写,我们 3 天搞定,其中 1 天写 codemod,1 天改复杂逻辑,1 天调性能。真正难的不是 API,是说服团队“Context 不是状态管理库”这件事。