一、问题背景: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:
useUserStore:用户信息、token、权限码。登录后写一次,读得极多。useAppStore:主题、侧边栏折叠、表格密度、语言。写频率中等。useDictStore:业务字典。初始化请求一次,之后只读。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 步,每步可回滚:
- 建 store 骨架,先只放
user和app两个,保持 Context 继续工作,双写。 - 替换读取点:全局搜索
useContext(AppContext),逐个改成选择器,一次一个模块。 - 替换写入点:把
setXxx调用改成 store action,删掉对应的 Context dispatch。 - 清理 Provider:确认某 Provider 无消费者后删除,从最内层开始拆。
- 回归测试:重点测权限、主题持久化、字典初始化时序。
双写阶段大概持续了一周,期间两套状态并存,靠一个 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 是表格本身没做虚拟滚动,迁移只解决了其中一部分——状态管理不是性能问题的万能药,它只是把"不该重渲染的组件"从渲染树里摘出去。