一、问题背景: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 配置,对中小项目偏重。
设计原则定了三条:
- 按领域拆 store,不搞一个大 store。用户、字典、UI 状态各一个。
- 组件只订阅自己需要的字段,用 selector,必要时配
shallow。 - 异步逻辑放 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 记录数据,用数字说话,别凭感觉。