一、问题背景:Context 不是不能用,是不能这么用

先交代下项目情况。一个 React 18.2 + TypeScript 5.0 + Vite 4.3 的中型后台系统,路由用 react-router-dom 6.14,UI 是 Ant Design 5.4。全局状态大概分四类:

  • 用户信息与权限(登录后基本不变,但组件到处要读)
  • 主题与布局配置(暗色模式、侧边栏折叠、表格密度)
  • 业务字典(状态枚举、城市列表等,请求一次全局共享)
  • 少量跨页面 UI 状态(当前选中的项目、全局弹窗)

早期为了省事,全部塞进一个 AppContext,再通过 Props 往下传。问题在项目跑到 30 多个页面时集中爆发:

// 迁移前的典型写法,Provider 嵌套 7 层

最典型的性能问题是主题切换。ThemeContext 的 value 是一个对象字面量,每次渲染都新建引用,导致 useContext 的消费者全部重渲染。我们用一个 200 行的表格页测过:切换侧边栏折叠状态,表格行组件重渲染了 3 次,单次交互主线程耗时 180ms 左右,输入框有明显的掉帧感。

另一个隐性成本是 Props 透传。TableToolbar 需要 density 和 permissions,中间隔了 4 层组件,每层都得写一遍类型定义和透传。改一个字段要动 5 个文件,这种维护成本比性能更让人难受。

所以目标很明确:把"读得频繁、写得少、跨层级"的状态抽出去,Context 只保留真正适合它的场景(比如 i18n 这种几乎不变的注入)。

二、环境与版本

先列清楚,方便对照:

  • react 18.2.0 / react-dom 18.2.0
  • typescript 5.0.4
  • vite 4.3.9
  • zustand 4.4.1
  • @reduxjs/toolkit 1.9.5(对比用,未上线)
  • antd 5.4.0
  • 浏览器基准:Chrome 114,MacBook Pro M1,React DevTools Profiler 采样

状态管理库的选型对比我们做了个表,维度是团队最关心的几个:

方案 包体积(gzip) 学习成本 重渲染控制 异步/中间件 适用场景
Context 0 低 差(需手动拆分+Memo) 无 低频、稳定注入
Redux Toolkit ~13KB 中高 好(useSelector 精确订阅) 完善 大型团队、强规范
Zustand ~1.2KB 低 好(选择器订阅) 内置中间件 中小型、快速落地
Jotai ~3KB 中 好(原子级) 一般 细粒度派生状态

选 Zustand 的理由很实际:1.2KB、无 Provider、选择器天然支持精确订阅、API 就几个。Redux Toolkit 我们也试了,样板代码对 30 个页面的项目偏重;Jotai 的原子模型很好,但团队里一半人对"派生原子"不熟,迁移风险高。Zustand 的 create + selector 心智负担最低。

三、方案设计:Store 拆分与订阅边界

迁移不是把 Context 原样搬进一个 store。我们按"变更频率"和"消费范围"重新切了 4 个 store:

  1. useUserStore:用户信息、token、权限码。登录后写一次,读得极多。
  2. useAppStore:主题、侧边栏折叠、表格密度、语言。写频率中等。
  3. useDictStore:业务字典。初始化请求一次,之后只读。
  4. useProjectStore:当前选中项目、全局弹窗开关。写频率较高。

关键设计原则有三条:

  • 读用选择器,不用整个 store。useAppStore(s => s.theme) 而不是 useAppStore(),后者任何字段变化都会触发重渲染。
  • 写操作收敛到 action。组件里不直接 set,统一走 store 内定义的方法,方便加日志和防抖。
  • 派生数据放选择器外。像"是否有某权限"这种计算,用 useUserStore(s => s.permissions.includes('x')),Zustand 默认用 Object.is 比较返回值,布尔值天然稳定。

还有一个容易忽略的点:Context 里那些"几乎不变但被到处读"的值,迁移后收益最大。因为 Context 的消费者一旦 Provider value 变,全部重渲染;而 Zustand 的选择器只让真正相关的组件更新。

四、核心实现

4.1 Store 定义(含持久化与选择器)

先看 useAppStore,带 persist 中间件,主题和布局配置需要跨刷新保留:

// stores/appStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

type Theme = 'light' | 'dark';
type Density = 'default' | 'middle' | 'small';

interface AppState {
  theme: Theme;
  collapsed: boolean;
  density: Density;
  setTheme: (t: Theme) => void;
  toggleCollapsed: () => void;
  setDensity: (d: Density) => void;
}

export const useAppStore = create()(
  persist(
    (set) => ({
      theme: 'light',
      collapsed: false,
      density: 'default',
      setTheme: (theme) => set({ theme }),
      toggleCollapsed: () => set((s) => ({ collapsed: !s.collapsed })),
      setDensity: (density) => set({ density }),
    }),
    {
      name: 'app-store',          // localStorage key
      storage: createJSONStorage(() => localStorage),
      partialize: (s) => ({       // 只持久化需要的字段
        theme: s.theme,
        collapsed: s.collapsed,
        density: s.density,
      }),
      version: 1,                 // 配合 migrate 处理结构变更
    }
  )
);

用户 store 里我加了个 hasPermission 的派生方法,但注意它不能直接在选择器里返回新对象,否则每次都是新引用,等于没优化:

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

interface UserState {
  userInfo: { id: string; name: string } | null;
  permissions: string[];
  setUser: (u: UserState['userInfo'], p: string[]) => void;
  reset: () => void;
}

export const useUserStore = create((set) => ({
  userInfo: null,
  permissions: [],
  setUser: (userInfo, permissions) => set({ userInfo, permissions }),
  reset: () => set({ userInfo: null, permissions: [] }),
}));

// 单独导出选择器,返回原始值,引用稳定
export const useHasPermission = (code: string) =>
  useUserStore((s) => s.permissions.includes(code));

4.2 组件改造:从 useContext 到选择器订阅

迁移前:

const { theme, collapsed } = useContext(AppContext);

迁移后,只订阅用到的字段:

// components/Sidebar.tsx
import { useAppStore } from '@/stores/appStore';

export function Sidebar() {
  // 只订阅 collapsed,theme 变化不会触发本组件重渲染
  const collapsed = useAppStore((s) => s.collapsed);
  const toggle = useAppStore((s) => s.toggleCollapsed);

  return (

      toggle

  );
}

这里有个细节:toggle 是 store 内定义的函数,引用永远不变,所以订阅它不会引起额外重渲染。但如果你写 useAppStore(s => ({ collapsed: s.collapsed })) 返回对象,就必须配合 useShallow:

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

// 返回多个字段时用 useShallow,避免每次新对象引用
const { theme, density } = useAppStore(
  useShallow((s) => ({ theme: s.theme, density: s.density }))
);

useShallow 是 Zustand 4.4 提供的,做浅比较。我们项目里凡是返回对象/数组的选择器,统统套上它,这一条救了不少重渲染。

4.3 迁移步骤(实际执行顺序)

我们没搞大爆炸式重构,而是分 5 步,每步可回滚:

  1. 建 store 骨架,先只放 user 和 app 两个,保持 Context 继续工作,双写。
  2. 替换读取点:全局搜索 useContext(AppContext),逐个改成选择器,一次一个模块。
  3. 替换写入点:把 setXxx 调用改成 store action,删掉对应的 Context dispatch。
  4. 清理 Provider:确认某 Provider 无消费者后删除,从最内层开始拆。
  5. 回归测试:重点测权限、主题持久化、字典初始化时序。

双写阶段大概持续了一周,期间两套状态并存,靠一个 syncBridge 临时同步,避免不一致:

// 临时桥接,迁移完成后删除
useEffect(() => {
  const unsub = useAppStore.subscribe((s) => {
    // 把新 store 的值同步回旧 Context,保证未迁移组件正常
    legacySetApp({ theme: s.theme, collapsed: s.collapsed });
  });
  return unsub;
}, []);

五、踩坑与优化

坑 1:选择器返回新对象导致无限重渲染。 早期写了 useAppStore(s => s.permissions.filter(...)),每次返回新数组,React 认为状态变了,直接死循环。解决:要么用 useShallow,要么把派生逻辑放到组件 useMemo 里,选择器只返回原始引用。

坑 2:persist 水合时序。 首屏渲染时 localStorage 还没读完,theme 是默认值,闪一下白再变暗。解决:用 onRehydrateStorage 加个 hydrated 标记,根组件等水合完成再渲染,或者给 `` 加个内联脚本先读 localStorage 设 class。

坑 3:字典 store 的并发请求。 多个页面同时 useEffect 里调 fetchDict,发了 6 个重复请求。解决:store 内加 loading 和 loaded 标记,action 里判重:

fetchDict: async () => {
  const { loaded, loading } = get();
  if (loaded || loading) return;
  set({ loading: true });
  const data = await api.getDict();
  set({ dict: data, loaded: true, loading: false });
},

优化:selector 粒度再细一点。 表格页原来订阅整个 density 对象,改成只订阅 s.density 字符串后,主题切换不再触发表格重渲染。另外把 useAppStore 的订阅从"整个 store"改成字段级,配合 React DevTools Profiler 看,单次交互的组件渲染数从 47 降到 18。

六、效果数据

用 React DevTools Profiler 在同一台机器、同一份 200 行表格数据下测了三组场景,每组采样 20 次取中位数:

场景 迁移前 迁移后 变化
切换侧边栏折叠,主线程耗时 180ms 68ms -62%
切换主题,重渲染组件数 47 18 -62%
表格页输入框输入延迟 约 45ms/键 约 16ms/键 -64%
首屏 JS 体积(gzip) 基准 +1.2KB 可接受
Provider 嵌套层数 7 2 —

需要说明的是,这些数字有场景局限性:状态越分散、消费组件越多,收益越明显;如果项目只有两三个全局状态,迁移的性价比要重新算。另外 Zustand 的 1.2KB 是纯库体积,加上 persist 中间件和类型定义,实际打包增量约 3KB,对首屏影响可以忽略。

还有一个非量化收益:Props 透传没了。TableToolbar 现在直接读 store,中间 4 层组件的类型定义和传参全删了,那个模块的代码行数少了约 120 行,改字段只动一个文件。

七、总结

这次迁移的核心结论不是"Zustand 比 Context 好",而是Context 适合低频、稳定的注入,不适合高频、跨层级的共享状态。我们保留了 i18n 和 Ant Design 的 ConfigProvider,因为它们本来就该是 Context;把用户、主题、字典、项目状态抽到 Zustand,用选择器控制订阅边界。

如果你们项目也到了 Provider 嵌套 5 层以上、改一个全局字段要动好几个文件的阶段,可以按我们的路子来:先建 store 双写、逐模块替换读取点、用 useShallow 兜住对象选择器、最后拆 Provider。迁移周期大概一到两周,风险可控,收益在交互频繁的页面上最直观。

最后一个建议:别为了迁移而迁移。先上 Profiler 测出瓶颈,确认是 Context 引用变化导致的重渲染,再动手。我们最初的 180ms 里,其实有 40ms 是表格本身没做虚拟滚动,迁移只解决了其中一部分——状态管理不是性能问题的万能药,它只是把"不该重渲染的组件"从渲染树里摘出去。