一、问题背景:Context 不是状态管理库

先说结论:Context 是依赖注入工具,不是状态管理方案。这个认知我们花了半年才用血泪换来。

项目是一个运营后台,React 18.2 + TypeScript 4.9 + Vite 4.3。初期为了"轻量",团队约定用 Context 管全局状态:用户信息一个 Context、权限一个、主题一个、字典数据一个……后来业务膨胀,每个页面又各自加 Context,最终有 40 多个 Provider 嵌套在 App 里。

真正的痛点有三个:

第一,任何 Context value 变化,所有 useContext 的消费者全部重渲染,哪怕它只用了 value 里的一个字段。我们的 AppContext 同时装着用户信息、全局 loading、字典缓存,用户改个昵称,整个页面树重渲染。

第二,Props 透传。一个弹窗组件在 7 层深处,需要拿到当前选中行的 id,中间 6 层组件被迫接收并传递这个 prop,改个字段名要动 6 个文件。

第三,调试困难。状态更新散落在各组件里,出了问题只能靠 console.log 加断点。

压垮骆驼的最后一根稻草是一次筛选功能:给表格加了个"仅看我的"筛选,结果每次点击,Performance 面板显示交互耗时 180ms,重渲染组件数 320 个,其中 280 个跟筛选毫无关系。

二、环境与版本

  • React 18.2.0 / react-dom 18.2.0
  • TypeScript 4.9.5
  • Vite 4.3.9
  • 迁移前:原生 Context + Props
  • 迁移后:zustand 4.4.1
  • 辅助:use-sync-external-store 1.2.0(zustand 内部依赖)
  • 性能测量:React DevTools Profiler 4.27 + Chrome Performance 面板

三、方案设计:为什么是 Zustand

我们对比了四个方案,用一个真实页面(订单列表页,约 60 个组件)做 PoC:

方案 包体积(gzip) 学习成本 重渲染控制 迁移成本 结论
Context 0 低 差 - 现方案,放弃
Redux Toolkit 1.9 13.2KB 中高 好(需 useSelector) 高 团队已有 RTK 项目,但样板代码多
Zustand 4.4 1.1KB 低 好(selector + shallow) 低 选中
Jotai 2.3 3.4KB 中 极好(原子级) 中 原子化适合细粒度,但团队不熟

选 Zustand 的理由很实在:API 极简(一个 create 搞定)、不需要 Provider 包裹、selector 天然支持细粒度订阅、可以从组件外部读写 store(方便在 axios 拦截器里改状态)、迁移成本低。Redux Toolkit 我们另一个项目在用,写一个 slice 要 actions + reducers + store 配置,对中小项目偏重。

设计原则定了三条:

  1. 按领域拆 store,不搞一个大 store。用户、字典、UI 状态各一个。
  2. 组件只订阅自己需要的字段,用 selector,必要时配 shallow。
  3. 异步逻辑放 store 的 action 里,组件不感知请求细节。

四、核心实现

4.1 定义 store

以用户 store 为例,迁移前是 UserContext 里放 userInfo、permissions、setUserInfo:

// stores/userStore.ts
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';

interface UserInfo {
  id: string;
  name: string;
  avatar: string;
  deptId: string;
}

interface UserState {
  userInfo: UserInfo | null;
  permissions: string[];
  loading: boolean;
  fetchUser: () => Promise;
  setUserInfo: (info: Partial) => void;
}

export const useUserStore = create((set, get) => ({
  userInfo: null,
  permissions: [],
  loading: false,

  fetchUser: async () => {
    set({ loading: true });
    try {
      const res = await fetch('/api/user/current').then(r => r.json());
      set({ userInfo: res.data, permissions: res.data.permissions, loading: false });
    } catch (e) {
      set({ loading: false });
      throw e;
    }
  },

  setUserInfo: (info) =>
    set((state) => ({
      userInfo: state.userInfo ? { ...state.userInfo, ...info } : null,
    })),
}));

4.2 组件订阅

迁移前组件里是 const { userInfo, loading } = useContext(UserContext),现在改成 selector:

// components/UserAvatar.tsx
import { useUserStore } from '@/stores/userStore';

export function UserAvatar() {
  // 只订阅 name 和 avatar,userInfo 其他字段变化不触发重渲染
  const name = useUserStore((s) => s.userInfo?.name);
  const avatar = useUserStore((s) => s.userInfo?.avatar);

  if (!name) return null;
  return (


      {name}

  );
}

这里有个关键点:selector 返回的是基本类型,直接比较即可。如果返回对象,必须配 shallow,否则每次都是新引用,等于没优化:

// 返回多个字段时用 shallow,避免对象引用变化导致重渲染
const { name, deptId } = useUserStore(
  (s) => ({ name: s.userInfo?.name, deptId: s.userInfo?.deptId }),
  shallow
);

4.3 迁移步骤

我们按"先易后难、按页面灰度"推进,两周完成:

第 1 步:盘点 Context。用脚本扫 createContext 和 useContext,列出每个 Context 的字段和消费者数量,产出迁移清单。

第 2 步:建 store,保留 Context 兼容层。给每个 Context 包一层兼容 hook,内部读 store,外部 API 不变:

// 兼容层:迁移期间让老代码继续工作
export function useUserContext() {
  const userInfo = useUserStore((s) => s.userInfo);
  const setUserInfo = useUserStore((s) => s.setUserInfo);
  return { userInfo, setUserInfo };
}

这样老组件一行不改就能跑在新 store 上,降低迁移风险。

第 3 步:逐组件替换。按页面分批,每次迁移一个页面,用 Feature Flag 控制,出问题能快速回滚。每迁一个页面跑一遍 Profiler 记录数据。

第 4 步:删除兼容层和 Context。所有消费者迁完后,删掉兼容 hook 和 Provider,App 树从 40 层 Provider 变成 0 层。

第 5 步:清理 Props。把只用于透传的 props 删掉,组件直接从 store 取。这一步删了约 300 行透传代码。

五、踩坑与优化

坑 1:selector 返回新对象导致无限重渲染。 一开始写了 useUserStore((s) => ({ ...s.userInfo })),每次都是新对象,React 认为一直变了,直接死循环。教训:selector 返回对象必须配 shallow,或者拆成多个 selector。

坑 2:在渲染中调用 action 修改状态。 Zustand 的 set 是同步的,如果在 render 里调用 set 会触发 React 警告。我们把所有 fetchUser 之类的调用挪到 useEffect 或事件回调里。

坑 3:shallow 和数组的配合。 订阅 permissions 数组时,shallow 只比较一层,如果数组元素是对象仍会漏判。我们的权限是 string 数组,没问题;字典数据是对象数组,改成了订阅具体 id 字段。

优化 1:细粒度 selector。 把 useUserStore((s) => s.userInfo) 拆成 useUserStore((s) => s.userInfo?.name),让不相关的字段更新不触发渲染。这一个改动就让用户信息区域的重渲染从 45 次/交互降到 3 次。

优化 2:非 React 环境读写。 axios 拦截器里 401 要清空用户状态,以前得通过 Context,现在直接:

import { useUserStore } from '@/stores/userStore';

axios.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401) {
      // 组件外直接操作 store,无需 Provider
      useUserStore.setState({ userInfo: null, permissions: [] });
      window.location.href = '/login';
    }
    return Promise.reject(err);
  }
);

优化 3:devtools 接入。 加了 devtools 中间件,配合 Redux DevTools 能看到每次状态变更和 diff,调试效率明显提升。

六、效果数据

以订单列表页为基准,同一台机器(M1 MacBook,Chrome 120),Profiler 记录 10 次筛选交互取平均:

指标 迁移前(Context) 迁移后(Zustand) 变化
单次交互耗时 180ms 57ms -68.3%
重渲染组件数 320 102 -68.1%
Provider 嵌套层数 40+ 0 -
页面首屏 JS(gzip) 基线 +1.1KB 可忽略
状态相关代码行数 ~1200 ~780 -35%
透传 props 代码 ~300 行 0 -100%

交互耗时从 180ms 降到 57ms,用户感知就是从"点击后明显卡一下"变成"秒响应"。重渲染组件从 320 降到 102,剩下的 102 个主要是表格本身的数据行,属于必要渲染。

值得一提的是包体积:换掉 Context 方案反而只增加了 1.1KB(gzip),因为删掉的兼容层和样板代码抵消了一部分。首屏没有明显变化。

七、总结

这轮迁移最大的收获不是性能数字,而是认知:Context 解决的是"跨层级传递依赖",不是"管理频繁变化的状态"。用它管全局状态,在状态简单时没问题,一旦状态变多、更新变频繁,重渲染就会失控。

Zustand 适合我们这种中型后台:API 少、无 Provider、selector 细粒度订阅、能脱离 React 读写。如果是超大型项目、需要严格的时间旅行调试和团队规范,Redux Toolkit 仍然更稳;如果是高度细粒度的交互状态(比如画布编辑器),Jotai 的原子模型更合适。

给准备迁移的团队三条建议:一是先做兼容层再逐页迁移,别一次性全改;二是 selector 一定要细,返回对象记得配 shallow;三是每迁一个页面就用 Profiler 记录数据,用数字说话,别凭感觉。