一、问题背景:Context 不是不能用,是不该被当成 Redux 用

先说结论:Context 本身没问题,问题是我们把它当成了全局状态管理器。

项目是一个运营后台,React 18.2 + TypeScript 5.2 + Vite 4.5。两年前起步时为了“轻量”,全局状态用 Context 拆分成了 UserContext、PermissionContext、ThemeContext、GlobalFilterContext 几个文件。听起来挺合理,但业务一长就变味了:

  1. Context 嵌套到 5 层,App.tsx 里一眼望过去全是 Provider,新人进来第一句话是“这个值到底从哪来的”。
  2. 一个 Context 塞太多字段。GlobalFilterContext 里同时放了筛选条件、筛选项列表、加载状态、分页信息。改一个 loading,所有消费该 Context 的组件全部 rerender。
  3. Props 透传严重。有个 DataTable 组件,从页面到表格中间隔了 4 层,中间层组件根本不用这些 props,纯当搬运工。
  4. 性能肉眼可见地差。用 React DevTools Profiler 抓了一次“切换筛选条件”的交互:commit 阶段 rerender 组件数 200+,其中真正需要更新的不到 20 个。

最典型的一次线上反馈:用户在一个列表页快速切换 Tab,页面会卡顿 300ms 左右。Profiler 显示是 GlobalFilterContext 的 value 每次都是新对象(useMemo 依赖写漏了),导致整棵子树重渲染。

所以我们的目标很明确:把“全局共享状态”从 Context 里剥离出来,Context 只保留真正适合它的东西(主题、i18n 这类低频、稳定的值)。

二、环境与版本

  • React 18.2.0 / react-dom 18.2.0
  • TypeScript 5.2.2
  • Vite 4.5.0
  • 迁移前:纯 Context + Props,无任何状态库
  • 迁移后:Zustand 4.4.7
  • 辅助:React DevTools 4.28、React Profiler API
  • 测试机型:MacBook Pro M1 / Chrome 120,以及一台 i5-8250U 的办公本

选型阶段我们对比了三个候选:

方案 包体积(gzip) 学习成本 选择性订阅 是否需 Provider 结论
Redux Toolkit 2.0 ~13KB 中高 需 useSelector 需要 团队已有 RTK 经验,但对这个项目偏重
Jotai 2.6 ~4KB 低 原子级天然支持 需要 Provider(可选) 原子化思路好,但从 Context 迁移心智转换大
Zustand 4.4.7 ~1.2KB 低 selector 天然支持 不需要 最终选择

选 Zustand 的核心理由:API 极简、不需要 Provider、selector 天然解决“只订阅需要的字段”这个问题,而且从 Context 迁移过来几乎是逐字段平移,改动面可控。

三、方案设计:先分类,再迁移

我们没有一上来就改代码,而是先做了一次状态盘点,把所有状态分成三类:

  1. 真正的全局状态:用户信息、权限、全局筛选条件 → 迁到 Zustand store。
  2. 局部状态:弹窗开关、表单输入 → 留在组件内 useState。
  3. 适合 Context 的:主题、i18n → 保留 Context,不动。

store 的拆分原则是按业务域拆,不按页面拆:

src/stores/
  userStore.ts      // 用户信息、登录态
  permissionStore.ts // 权限码、菜单
  filterStore.ts    // 全局筛选条件、分页

每个 store 只放数据 + 操作数据的方法,不放 UI 状态(比如“表格是否 loading”我们放在了组件本地,因为它只影响当前表格)。

迁移顺序上,我们按“依赖少 → 依赖多”推进:先迁 userStore(几乎无耦合),再迁 permissionStore,最后迁最复杂的 filterStore。每迁一个就跑一次 Profiler,保证可回滚。

四、核心实现:从 Context 到 Zustand 的平移

先看迁移前的典型写法:

// 迁移前:GlobalFilterContext.tsx
import { createContext, useContext, useMemo, useState } from 'react';

interface FilterState {
  keyword: string;
  status: string;
  page: number;
  pageSize: number;
  loading: boolean;
}

const FilterContext = createContext) => void;
} | null>(null);

export function FilterProvider({ children }: { children: React.ReactNode }) {
  const [filter, setFilterState] = useState({
    keyword: '',
    status: '',
    page: 1,
    pageSize: 20,
    loading: false,
  });

  const setFilter = (patch: Partial) =>
    setFilterState(prev => ({ ...prev, ...patch }));

  // 注意这里:value 每次渲染都是新对象
  const value = useMemo(() => ({ filter, setFilter }), [filter]);

  return {children};
}

export function useGlobalFilter() {
  const ctx = useContext(FilterContext);
  if (!ctx) throw new Error('useGlobalFilter must be used within FilterProvider');
  return ctx;
}

问题就在 useGlobalFilter() 返回整个 filter 对象——任何字段变化,所有调用方都 rerender。

迁移后:

// 迁移后:src/stores/filterStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface FilterState {
  keyword: string;
  status: string;
  page: number;
  pageSize: number;
}

interface FilterActions {
  setFilter: (patch: Partial) => void;
  resetFilter: () => void;
}

const initialState: FilterState = {
  keyword: '',
  status: '',
  page: 1,
  pageSize: 20,
};

export const useFilterStore = create()(
  devtools(
    set => ({
      ...initialState,
      setFilter: patch =>
        set(state => ({ ...state, ...patch }), false, 'filter/setFilter'),
      resetFilter: () => set(initialState, false, 'filter/resetFilter'),
    }),
    { name: 'FilterStore', enabled: import.meta.env.DEV },
  ),
);

组件里的用法从“解构整个对象”改成“按字段订阅”:

// 迁移前
const { filter, setFilter } = useGlobalFilter();
// filter.keyword、filter.status 任一变化都会 rerender

// 迁移后
const keyword = useFilterStore(s => s.keyword);
const status = useFilterStore(s => s.status);
const setFilter = useFilterStore(s => s.setFilter);
// 只有 keyword 变化时,这个组件才 rerender

这里有个关键细节:selector 返回的是原始值(string/number),不是对象。如果非要返回对象,必须配合 useShallow,否则每次都是新引用,等于没优化:

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

// 需要同时拿多个字段时,用 useShallow 做浅比较
const { page, pageSize } = useFilterStore(
  useShallow(s => ({ page: s.page, pageSize: s.pageSize })),
);

另一个高频场景是“在组件外读取/写入 store”,比如 axios 拦截器里要拿 token:

// 迁移前需要把 context 值挂到 ref 或全局变量上,很别扭
// 迁移后直接调用 getState
import { useUserStore } from '@/stores/userStore';

axios.interceptors.request.use(config => {
  const token = useUserStore.getState().token;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

这一点在迁移中帮我们删掉了好几个“为了在非组件环境拿状态而存在的 hack”。

五、踩坑与优化

坑 1:devtools 中间件在 production 也打包了。 一开始没加 enabled 参数,构建产物多了约 3KB。改成 enabled: import.meta.env.DEV 后恢复正常。

坑 2:在 selector 里做计算,导致每次返回新数组。 有同事写了 useFilterStore(s => s.list.filter(x => x.active)),结果每次渲染都返回新数组,rerender 反而更多。改成在 store 里派生状态,或者用 useShallow + 原始数据在组件内 useMemo。

坑 3:迁移过程中 Context 和 store 并存,出现“双写”。 我们在过渡期让 setFilter 同时更新 Context 和 store,结果有一处漏同步,筛选条件出现闪烁。后来定了规矩:一个字段只能有一个来源,迁移一个字段就删一个字段的 Context 写入,不做双写。

坑 4:set 的第三个参数(action name)在 devtools 里才有意义,生产环境传了也没用,但传了不会报错,团队统一保留,方便调试。

优化点:我们把原来放在 Context 里的 loading 状态移到了组件本地,store 只管数据。这一改动单独就减少了约 30% 的无效 rerender,因为 loading 切换频率远高于数据变化。

六、效果数据

用 React Profiler 和 Performance 面板,在同一个“切换筛选条件”的交互上对比(i5-8250U 办公本,Chrome 120,采样 5 次取中位数):

指标 迁移前(Context) 迁移后(Zustand) 变化
首屏 TTI 2.8s 1.9s -32%
单次交互 rerender 组件数 47 6 -87%
单次交互 commit 耗时 86ms 21ms -76%
状态相关代码行数 ~1240 ~810 -35%
Provider 嵌套层数 5 2 —
构建产物(状态部分 gzip) 0 +1.2KB 可接受

首屏 TTI 的改善主要来自两点:一是少了多层 Provider 的初始化和 useMemo 计算;二是列表组件不再因为无关字段变化而重渲染,首屏挂载时的 commit 次数明显减少。

代码量下降则是因为删掉了大量 Props 透传的中间层和 Context 样板代码。

七、总结

这次迁移最大的收获不是“换了个库”,而是重新想清楚了什么状态该放哪:

  • Context 适合低频、稳定、跨层级的值(主题、i18n、当前用户这种几乎不变的)。
  • 高频变化、需要精细订阅的全局状态,交给带 selector 的状态库。
  • 局部 UI 状态别往全局塞,这是 rerender 的头号来源。

如果你们项目也处在“Context 越写越像 Redux”的阶段,建议先做一次状态盘点再动手,迁移本身不难,难的是想清楚边界。Zustand 的 getState、selector、useShallow 这几个 API 吃透,基本能覆盖 90% 的场景。至于要不要上 Redux Toolkit,看团队已有积累——对我们这个项目来说,Zustand 的 1.2KB 和零 Provider 就是最合适的答案。