一、问题背景:Context用着用着就不够用了
项目是一个React 18.2 + TypeScript 4.9的中后台系统,日活大概3万,页面结构是典型的「左侧菜单 + 顶部用户信息 + 主内容区」。早期为了快速上线,全局状态就用了最朴素的两套方案:
- 用户信息、权限、主题这类低频变更的数据,放在一个
AppContext里; - 页面级数据靠 props 一层层往下传,最深的地方传了5层。
一开始没问题,毕竟页面不多。但随着业务迭代,AppContext 里塞进了越来越多东西:用户信息、权限列表、全局loading、通知未读数、当前选中的组织架构……任何一个字段变化,所有 useContext 的组件都会重渲染。最夸张的一次,通知未读数每30秒轮询更新一次,结果整个侧边栏菜单跟着闪了一下。
我们用 React DevTools Profiler 录了一次列表页的交互,数据很直观:
- 打开列表页,Context Provider 的值变化触发了 47次 组件重渲染,其中真正需要更新的只有9个;
- 列表页在「待办 / 已办 / 我发起」三个Tab之间切换,平均耗时 180ms,用户能明显感觉到卡顿;
- 有个表单页 props 透传了5层,中间三层组件仅仅是为了把
onSubmit传下去,代码里全是({ onSubmit }) => ...这种噪音。
到这一步,结论很明确:Context 适合「低频、全局、读多写少」的场景,但我们把它当成了万能状态容器,props 又承担了本不该承担的跨层级通信。是时候引入一个真正的状态管理库了。
二、环境与版本:先盘点家底
迁移前先把环境钉死,避免后面排查问题时版本对不上:
{
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "4.9.5",
"vite": "4.4.9",
"zustand": "4.4.1",
"immer": "10.0.2",
"react-router-dom": "6.15.0"
}
选型阶段对比了三个候选方案,都是社区主流:
| 方案 | 包体积(gzip) | 重渲染控制 | 学习成本 | 是否引入Provider | 适合场景 |
|---|---|---|---|---|---|
| React Context | 0(内置) | 差,值变全量更新 | 低 | 是 | 低频全局数据 |
| Redux Toolkit 1.9 | ~13KB | 好,需selector | 中高 | 是 | 大型团队、强约束 |
| Zustand 4.4 | ~1.2KB | 好,天然selector | 低 | 否 | 中小型项目、渐进迁移 |
我们最终选 Zustand,理由很实在:
- 不需要 Provider,store 就是个 hook,迁移时可以一个模块一个模块换,不用一次性改根组件;
- selector 天然支持细粒度订阅,
useStore(s => s.user.name)只在这个字段变的时候重渲染; - 包体积小,1.2KB 对中后台来说几乎无感;
- 团队里没人写过 Redux 的 boilerplate,Zustand 的 API 半小时就能上手。
顺带提一句 Vue 侧:如果项目是 Vue 3,对应的选择通常是 Pinia。我们另一个 Vue 3.3 项目就是从 provide/inject 迁到 Pinia 的,思路和本文基本一致——provide/inject 的问题和 Context 一模一样:注入的值一变,所有注入方全更新,且没有 selector 机制。所以下面的迁移思路对 Vue 开发者同样有参考价值。
三、方案设计:按「变更频率」拆分 store
迁移不是把所有 Context 一股脑搬进一个 store,那样只是换了个地方制造重渲染。我们的拆分原则是按变更频率和生命周期切分:
useUserStore:用户信息、权限、token,登录后写一次,几乎不变;useAppStore:主题、侧边栏折叠、全局loading,低频变更;useNotifyStore:未读数、通知列表,30秒轮询,高频但只影响顶部铃铛;- 页面级状态(列表筛选条件、分页)不进全局 store,留在组件内或走 URL query,避免全局 store 膨胀。
关键设计点有三个:
第一,selector 必须精确。 禁止 const state = useAppStore() 这种全量订阅,ESLint 里加了一条自定义规则,code review 时也重点盯。
第二,异步逻辑放 store 内部。 之前用户信息是在组件 useEffect 里请求再 setState 到 Context,迁移后把 fetchUser 直接写进 store 的 action,组件只管调用。
第三,对外暴露的 hook 做一层封装。 不让业务组件直接 import useAppStore,而是从 stores/index.ts 导出语义化的 hook,比如 useUserInfo()、useUnreadCount(),这样以后换库(虽然不太可能)只改一处。
四、核心实现:从 Context 到 Zustand 的代码
先看迁移前的 Context 写法,这是问题根源:
// 迁移前:AppContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';
interface AppState {
user: { id: string; name: string; avatar: string } | null;
permissions: string[];
unreadCount: number;
theme: 'light' | 'dark';
}
const AppContext = createContext({} as AppState);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [permissions, setPermissions] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [theme, setTheme] = useState('light');
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(data => {
setUser(data.user);
setPermissions(data.permissions);
});
// 30秒轮询未读数,这里就是重渲染的罪魁祸首
const timer = setInterval(() => {
fetch('/api/notify/unread').then(r => r.json()).then(d => setUnreadCount(d.count));
}, 30000);
return () => clearInterval(timer);
}, []);
// 任意一个字段变化,value 都是新对象,所有消费者全部重渲染
return (
{children}
);
}
export const useAppContext = () => useContext(AppContext);
迁移后,按 store 拆分。先看用户 store,包含异步 action:
// stores/userStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface UserInfo {
id: string;
name: string;
avatar: string;
}
interface UserState {
user: UserInfo | null;
permissions: string[];
loading: boolean;
fetchUser: () => Promise;
hasPermission: (code: string) => boolean;
logout: () => void;
}
export const useUserStore = create()(
devtools(
(set, get) => ({
user: null,
permissions: [],
loading: false,
fetchUser: async () => {
set({ loading: true }, false, 'user/fetchStart');
try {
const res = await fetch('/api/user');
const data = await res.json();
set({ user: data.user, permissions: data.permissions, loading: false }, false, 'user/fetchSuccess');
} catch (e) {
set({ loading: false }, false, 'user/fetchError');
throw e;
}
},
hasPermission: (code) => get().permissions.includes(code),
logout: () => set({ user: null, permissions: [] }, false, 'user/logout'),
}),
{ name: 'UserStore', enabled: import.meta.env.DEV }
)
);
// 语义化封装,业务组件只用这个
export const useUserInfo = () => useUserStore((s) => s.user);
export const usePermissions = () => useUserStore((s) => s.permissions);
再看通知 store,这里用了 selector + shallow 比较来处理对象/数组返回值,这是迁移里最容易踩坑的地方:
// stores/notifyStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
interface NotifyState {
unreadCount: number;
list: Array;
setUnread: (n: number) => void;
markRead: (id: string) => void;
}
export const useNotifyStore = create()(
subscribeWithSelector((set) => ({
unreadCount: 0,
list: [],
setUnread: (n) => set({ unreadCount: n }),
markRead: (id) =>
set((state) => ({
list: state.list.map((item) => (item.id === id ? { ...item, read: true } : item)),
unreadCount: Math.max(0, state.unreadCount - 1),
})),
}))
);
// 只订阅 unreadCount,list 变化不会触发使用该 hook 的组件
export const useUnreadCount = () => useNotifyStore((s) => s.unreadCount);
// 返回对象时必须用 useShallow,否则每次都是新引用,等于没优化
export const useNotifyActions = () =>
useNotifyStore(
useShallow((s) => ({ setUnread: s.setUnread, markRead: s.markRead }))
);
轮询逻辑从 Context 的 useEffect 移到了根布局里,只调用 action,不订阅状态:
// layouts/RootLayout.tsx 片段
import { useEffect } from 'react';
import { useNotifyStore } from '@/stores/notifyStore';
export function RootLayout({ children }: { children: React.ReactNode }) {
useEffect(() => {
const timer = setInterval(async () => {
const res = await fetch('/api/notify/unread');
const data = await res.json();
// getState() 直接取 action,不引起订阅
useNotifyStore.getState().setUnread(data.count);
}, 30000);
return () => clearInterval(timer);
}, []);
return {children};
}
组件里的调用从 const { user } = useAppContext() 变成 const user = useUserInfo(),改起来是机械劳动,用 IDE 的正则替换加人工核对,一个下午能改完 60 多个文件。
五、踩坑与优化:三个必须说的细节
坑一:selector 返回新对象导致无限重渲染。 迁移第一天就遇到 Maximum update depth exceeded。原因是写了 useNotifyStore(s => ({ count: s.unreadCount })),每次 render 都返回新对象,Zustand 用 Object.is 比较永远不相等。解决办法就是上面代码里的 useShallow,或者干脆拆成多个单字段 selector。这个坑在 Vue 的 Pinia 里对应的是 storeToRefs,本质一样。
坑二:devtools 中间件在生产环境要关掉。 一开始没加 enabled: import.meta.env.DEV,打出来的包体积多了 8KB,而且 devtools 序列化在频繁更新时有额外开销。加上开关后,生产包 gzip 只比迁移前多了 1.1KB。
坑三:Context 里的「派生状态」不要直接搬。 原来 Context 里有个 canEdit 是 permissions.includes('edit') 算出来的,迁移时有人直接把它存进了 store,导致权限更新后 canEdit 不同步。正确做法是用 selector 派生:const canEdit = useUserStore(s => s.permissions.includes('edit')),或者用 hasPermission action。派生状态永远不要存。
还有一个优化点:列表页的筛选条件我们最终没放进 Zustand,而是同步到了 URL query。这样刷新页面能恢复状态,分享链接也带筛选条件,比塞全局 store 更合理。全局 store 只放真正跨页面共享的东西。
六、效果数据:Profiler 说了算
迁移上线后,用同样的 React DevTools Profiler 场景复测,对比数据如下:
| 指标 | Context + Props | Zustand 4.4 | 变化 |
|---|---|---|---|
| 列表页首屏重渲染次数 | 47次 | 6次 | -87% |
| Tab切换平均耗时 | 180ms | 52ms | -71% |
| 通知轮询触发的重渲染组件数 | 全部Context消费者(约23个) | 1个(铃铛组件) | -96% |
| 生产包 gzip 体积 | 基准 | +1.1KB | 可接受 |
| 表单页 props 透传层数 | 5层 | 0层(直接读store) | — |
另外有两个体感上的变化:一是侧边栏不再因为未读数轮询而闪动,二是表单页的中间组件删掉了大量透传 props,代码行数少了大概 200 行,可读性明显提升。
需要说明的是,这些数字是在我们项目特定场景下测的,不代表 Zustand 一定比 Context 快多少倍——Context 在低频场景下完全够用。真正带来收益的是「细粒度订阅」这个机制,而不是库本身。
七、总结
回头看这次迁移,几点经验值得记下来:
- Context 不是不能用,是别当万能容器。 低频、全局、读多写少的数据,Context 依然是好选择,我们迁移后主题切换还留在 Context 里(因为要配合 CSS 变量,且变更极少)。
- 拆分 store 按变更频率来,不是按页面来。 一个巨大的 store 只是把 Context 的问题换了个马甲。
- selector 是性能的关键,不是库。 全量订阅
useStore()的话,Zustand 一样会重渲染,useShallow和单字段 selector 要写进团队规范。 - 渐进迁移完全可行。 因为 Zustand 不需要 Provider,我们是按模块一个个换的,两周内完成,期间新旧方案并存,没有大爆炸式重构的风险。
- Vue 项目同理,
provide/inject迁 Pinia 的思路基本一致,注意storeToRefs解构保持响应性即可。
如果你们项目也出现了「改一个未读数全页面闪」或者「props 传了五层」的症状,可以先用 Profiler 录一段看看重渲染次数,数据会告诉你答案。