一、问题背景:Context 不是银弹,我们把它用成了全局 store
先说结论:Context 的设计目标是「依赖注入」,不是「高频状态共享」。我们踩的坑,基本都源于把这两件事混为一谈。
项目是一个 React 18.2 + TypeScript 5.2 的中后台系统,包含权限、租户、字典、工作流、表格筛选等 12 个业务域。早期为了赶进度,全局状态用了一个 AppContext,把所有东西塞进同一个 value:
// 迁移前的写法(反面教材)
const AppContext = createContext(null);
export function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [tenant, setTenant] = useState(null);
const [dicts, setDicts] = useState({});
const [filters, setFilters] = useState({});
const [collapsed, setCollapsed] = useState(false);
const value = { user, tenant, dicts, filters, collapsed, setUser, setTenant, setDicts, setFilters, setCollapsed };
return {children};
}
问题在三个地方爆发:
- value 每次都是新对象,任何一次
setCollapsed都会让所有useContext(AppContext)的组件重渲染。我们用 React DevTools Profiler 抓了一次侧边栏折叠操作:单次渲染耗时 340ms,涉及 213 个组件,其中真正需要更新的只有 3 个。 - Provider 嵌套 7 层(AppProvider → ThemeProvider → I18nProvider → PermissionProvider → ...),新人接手时根本理不清状态归属。
- Props 透传深度达到 6 层,一个
tenantId从页面传到表格单元格,中间 4 个组件纯粹是搬运工。
我们统计了一下:全项目 useContext 调用 156 处,Props 透传参数中 38% 是"只为传递、自身不用"的。这就是要动手的信号。
二、环境与版本
- React 18.2.0,ReactDOM 18.2.0(并发特性已开启)
- TypeScript 5.2.2,
strict: true - Vite 4.4.9,构建产物 gzip 后 412KB
- 现有状态方案:Context + Props,无任何状态库
- 候选库版本:zustand 4.4.1、@reduxjs/toolkit 1.9.5、jotai 2.4.2
- 性能采集:React DevTools Profiler 4.28 + 自建
PerformanceObserver埋点(LCP、INP)
三、方案设计:为什么最终选 Zustand
我们拉了个对比表,维度是团队最关心的四项:
| 维度 | Context | Redux Toolkit | Zustand 4.4 | Jotai 2.4 |
|---|---|---|---|---|
| 重渲染控制 | 无(value 变即全量) | useSelector 精确 | selector + shallow 精确 | atom 级精确 |
| 样板代码 | 少 | 多(slice/thunk) | 极少 | 少 |
| 异步/副作用 | 手写 | createAsyncThunk | 直接在 action 里 async | 需配合 utils |
| 迁移成本 | - | 高(要改 dispatch 心智) | 低(hook 直接调用) | 中(要拆 atom) |
| 包体积(gzip) | 0 | ~13KB | ~1.2KB | ~4KB |
排除法:Redux Toolkit 对我们这种"只想把 Context 换掉"的诉求太重,团队 6 个人要重新建立 dispatch/action 心智,收益不匹配;Jotai 的 atom 模型很优雅,但我们需要把大量现成的"对象型 state + 方法"平移过来,atom 拆分会让迁移周期翻倍。Zustand 的 store 本质就是一个带订阅的闭包对象,useStore(selector) 天然支持精确订阅,且可以在组件外调用(比如 axios 拦截器里读 token),这点对我们特别关键。
迁移策略定为渐进式、按域拆分,不做一次性大爆炸:
- 先建
stores/目录,按业务域建 6 个 store:userStore、tenantStore、dictStore、uiStore、filterStore、permissionStore。 - 保持 Context 外壳不动,内部实现逐步替换,避免一次性改 156 处调用点。
- 每个 store 用
devtools+persist中间件,persist 只对userStore、uiStore开启(partialize白名单)。 - 建立 ESLint 规则:新代码禁止从
AppContext导入,老代码加// TODO(migrate)标记。
四、核心实现:store 定义与 selector 拆分
先看一个典型的 store。这里用 create + devtools + persist,注意 partialize 只持久化必要字段,避免把 loading 这种瞬时态写进 localStorage:
// stores/userStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface UserState {
token: string | null;
profile: { id: string; name: string; roles: string[] } | null;
loading: boolean;
login: (username: string, password: string) => Promise;
logout: () => void;
}
export const useUserStore = create()(
devtools(
persist(
immer((set, get) => ({
token: null,
profile: null,
loading: false,
login: async (username, password) => {
set({ loading: true }, false, 'user/loginStart');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error(`login failed: ${res.status}`);
const data = await res.json();
set({ token: data.token, profile: data.profile }, false, 'user/loginSuccess');
} finally {
set({ loading: false }, false, 'user/loginEnd');
}
},
logout: () => set({ token: null, profile: null }, false, 'user/logout'),
})),
{
name: 'app-user',
partialize: (s) => ({ token: s.token, profile: s.profile }), // 不持久化 loading
version: 1,
}
),
{ name: 'UserStore', enabled: import.meta.env.DEV }
)
);
关键点在 selector。迁移前组件里是 const { user, dicts, collapsed } = useContext(AppContext),迁移后必须改成细粒度订阅,否则等于没换。我们用 useShallow(zustand 4.4 内置的 shallow 比较 hook)处理"一次取多个字段"的场景:
// components/Sidebar.tsx —— 迁移后
import { useShallow } from 'zustand/react/shallow';
import { useUIStore } from '@/stores/uiStore';
import { useUserStore } from '@/stores/userStore';
export function Sidebar() {
// 只订阅 collapsed 和 toggle,其它字段变化不触发本组件重渲染
const { collapsed, toggle } = useUIStore(
useShallow((s) => ({ collapsed: s.collapsed, toggle: s.toggle }))
);
// 只取 name,profile 其它字段变化不影响这里
const userName = useUserStore((s) => s.profile?.name ?? '未登录');
return (
toggle
{userName}
);
}
配套的 uiStore 很简单,但要注意:不要把方法写进 selector 的返回对象里再 shallow 比较——函数引用是稳定的,没问题;但如果你在 selector 里 map/filter 生成新数组,必须用 useShallow 或 useMemo,否则每次返回新引用会导致无限重渲染。我们就在 filterStore 上踩过这个坑(见下一节)。
组件外读取(axios 拦截器)也顺手解决了,这是 Context 做不到的:
// utils/http.ts
import { useUserStore } from '@/stores/userStore';
http.interceptors.request.use((config) => {
const token = useUserStore.getState().token; // 非 hook 调用,无需组件上下文
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
五、踩坑与优化:三个真实问题
坑 1:selector 返回新对象导致无限渲染。 filterStore 里有个 getActiveFilters 方法,我一开始写成 useFilterStore((s) => s.filters.filter(f => f.enabled)),filter 每次返回新数组,Object.is 判定不等,React 直接报 "Maximum update depth exceeded"。解决:要么用 useShallow,要么把派生逻辑放进 store 用 useMemo 缓存。我们最终在 store 里维护了一个 activeFilters 派生字段,在 set 时同步更新,读的时候直接取引用。
坑 2:persist 的 hydration 时序。 首屏渲染时 localStorage 还没读完,token 是 null,导致路由守卫把已登录用户踢到登录页。Zustand persist 提供 onRehydrateStorage 和 hasHydrated,我们加了一个 useHydration hook,在 hasHydrated 为 false 时渲染骨架屏:
export function useHydration() {
const [hydrated, setHydrated] = useState(useUserStore.persist.hasHydrated());
useEffect(() => {
const unsub = useUserStore.persist.onFinishHydration(() => setHydrated(true));
return unsub;
}, []);
return hydrated;
}
坑 3:devtools 在生产环境拖慢性能。 一开始 enabled 没加环境判断,生产包里 devtools 中间件仍在跑序列化,INP 反而涨了 40ms。改成 enabled: import.meta.env.DEV 后恢复正常。另外 set 的第三个参数(action name)在 devtools 里很有用,但在生产会被 tree-shake 掉,不影响体积。
优化点:把 6 个 store 里高频更新的 filterStore 单独做了 subscribeWithSelector,在表格组件里用 subscribe 做命令式更新,绕开 React 渲染,滚动筛选时帧率从 42fps 回到 58fps。
六、效果数据
迁移分 3 个迭代、共 11 个工作日完成,改动 89 个文件。用同一台机器(M1 MacBook,Chrome 118,4x CPU 降速)跑 Profiler,取 10 次中位数:
| 指标 | 迁移前(Context) | 迁移后(Zustand) | 变化 |
|---|---|---|---|
| 侧边栏折叠单次渲染组件数 | 213 | 27 | -87.3% |
| 折叠操作渲染耗时 | 340ms | 46ms | -86.5% |
| 首屏可交互延迟(INP) | 620ms | 190ms | -69.4% |
| 列表筛选滚动帧率 | 42fps | 58fps | +38% |
| 全局状态相关代码行数 | 1,240 | 610 | -50.8% |
| 包体积(gzip) | 412KB | 413.2KB | +1.2KB |
包体积只涨了 1.2KB,基本可忽略。代码行数减半主要来自删掉了大量 Props 透传和 Provider 嵌套。团队反馈最直观的一点:新人现在看 stores/ 目录就知道全局状态有哪些,不用再顺着 Provider 往下扒。
七、总结
这次迁移给我最大的教训是:Context 适合"低频、稳定"的依赖注入(主题、i18n、权限常量),不适合"高频、多变"的共享状态。我们当初把两者混在一起,才付出了 620ms 的代价。
如果你们项目也在考虑类似迁移,几点建议:
- 先量化再动手,用 Profiler 抓一次典型操作的重渲染组件数,数据比直觉可靠。
- 按业务域拆 store,别造一个巨型 store,否则只是把 Context 的问题换个地方复现。
- selector 一定要细,
useShallow该用就用,否则迁移等于白做。 - 渐进式迁移,保留 Context 外壳做过渡,别一次性改上百个调用点。
- persist 的 hydration 时序一定要处理,这是上线后最容易出线上事故的点。
Zustand 不是唯一答案,Jotai 在细粒度场景下更优雅。但对我们这种"从 Context 平移对象型 state"的诉求,它的迁移成本、体积和心智负担的平衡点是最好的。选型没有最优,只有最匹配当下团队和代码库的那一个。