一、为什么要动 Context
项目是一个运营后台,2022 年立项时只有 8 个页面,全局状态用 Context + useReducer 就够了。到 2024 年初膨胀到 60 多个页面,问题集中爆发:
AppContext一个 Provider 里塞了用户信息、权限、主题、字典缓存、全局 loading、通知列表,任何字段变化都会让所有useContext的组件重渲染。用 React DevTools Profiler 抓一次「切换侧边栏折叠」的操作,重渲染组件 214 个,耗时 380ms。- 一个字典下拉框组件订阅了整个 Context,用户打字过滤时每次按键触发 6 次无关列表重渲染。
useReducer的 action 类型已经有 90 多个,reducer.ts单文件 1200 行,改一个字段要翻半天。
当时有两条路:继续拆 Context,或者引入状态管理库。我们决定先做方案对比,再迁一个模块试点。
二、环境与候选方案
版本约束:
- React 18.2.0、TypeScript 5.3.3、Vite 5.0.12
- 已有依赖:
react-router-dom@6.22.0、@tanstack/react-query@5.20.5(服务端状态已交给它) - 包体预算:gzip 后新增不超过 5KB
候选三个:
| 方案 | 版本 | gzip 体积 | 心智模型 | 适用场景 |
|---|---|---|---|---|
| 拆分 Context + useMemo | 无 | 0 | 手动优化 | 状态少、更新低频 |
| Zustand | 4.5.0 | 1.2KB | 单 store + selector | 全局共享、读写频繁 |
| Jotai | 2.6.2 | 3.8KB | 原子化、自底向上 | 细粒度、派生状态多 |
注意我们没有考虑 Redux Toolkit,因为 @tanstack/react-query 已经接管了异步数据,剩下的纯客户端状态不值得上 Redux 那套模板代码。
试点选了「用户偏好 + 字典缓存」这个模块,因为它同时有高频读(字典)和低频写(主题)。
三、方案设计:两套并存
最终不是二选一,而是按状态特征分工:
- Zustand:全局单例、需要
getState()在组件外读取的状态。比如权限判断要在路由守卫(非组件环境)里用,Zustand 的store.getState()天然合适。 - Jotai:组件树内、派生关系复杂的状态。比如字典下拉框,
keywordAtom派生filteredOptionsAtom,只有订阅了这个 atom 的组件才更新。
这个划分不是拍脑袋,是试点后根据调试数据定的。
四、核心实现
4.1 Zustand 承接权限与用户偏好
迁移前 Context 写法(简化):
// 旧: AppContext.tsx
const AppContext = createContext(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return {children};
}
// 组件里
const { user, permissions } = useContext(AppContext)!;
迁移后 Zustand:
// store/appStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface AppState {
user: User | null;
permissions: string[];
theme: 'light' | 'dark';
setUser: (u: User | null) => void;
setTheme: (t: 'light' | 'dark') => void;
hasPermission: (code: string) => boolean;
}
export const useAppStore = create()(
persist(
(set, get) => ({
user: null,
permissions: [],
theme: 'light',
setUser: (user) => set({ user, permissions: user?.permissions ?? [] }),
setTheme: (theme) => set({ theme }),
hasPermission: (code) => get().permissions.includes(code),
}),
{
name: 'app-store',
storage: createJSONStorage(() => localStorage),
// 只持久化 theme,user 每次登录重新拉
partialize: (s) => ({ theme: s.theme }),
version: 2,
}
)
);
关键点是用 selector 订阅,不要解构整个 store:
// ❌ 错误:任何字段变化都重渲染
const { theme, user } = useAppStore();
// ✅ 正确:只订阅需要的字段
const theme = useAppStore((s) => s.theme);
const user = useAppStore((s) => s.user);
路由守卫里直接读,不依赖组件:
// router/guard.ts
import { useAppStore } from '@/store/appStore';
export function checkPermission(code: string) {
// 非组件环境也能读,这是选 Zustand 的核心原因
return useAppStore.getState().hasPermission(code);
}
4.2 Jotai 处理字典与派生状态
字典模块迁移前是 Context 里一个大对象,迁移后拆成原子:
// store/dictAtoms.ts
import { atom } from 'jotai';
import { atomWithQuery } from 'jotai-tanstack-query';
// 基础原子:字典原始数据
export const dictMapAtom = atom>({});
// 派生原子:当前选中的字典 key
export const activeDictKeyAtom = atom('');
// 派生原子:搜索关键词
export const keywordAtom = atom('');
// 派生原子:过滤结果,只有订阅它的组件才更新
export const filteredOptionsAtom = atom((get) => {
const map = get(dictMapAtom);
const key = get(activeDictKeyAtom);
const kw = get(keywordAtom).trim().toLowerCase();
const list = map[key] ?? [];
if (!kw) return list;
return list.filter((i) => i.label.toLowerCase().includes(kw));
});
组件里按需订阅:
// components/DictSelect.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import { filteredOptionsAtom, keywordAtom, activeDictKeyAtom } from '@/store/dictAtoms';
export function DictSelect({ dictKey }: { dictKey: string }) {
const options = useAtomValue(filteredOptionsAtom);
const setKeyword = useSetAtom(keywordAtom);
const setKey = useSetAtom(activeDictKeyAtom);
useEffect(() => {
setKey(dictKey);
}, [dictKey, setKey]);
return (
setKeyword(e.target.value)} />
{options.map((o) => (
{o.label}
))}
);
}
filteredOptionsAtom 是派生原子,Jotai 内部做了依赖追踪,keywordAtom 变化只会让读了这个派生原子的组件重渲染,其他组件纹丝不动。
五、迁移步骤
我们按模块灰度迁移,没有一次性大改:
- 建 store 目录,先写 Zustand 的
appStore,与旧 Context 并存。 - 加适配层:旧 Context 里从 Zustand 读值,保证老组件不报错。
tsx // 过渡期:AppProvider 内部转发 export function AppProvider({ children }: { children: ReactNode }) { const user = useAppStore((s) => s.user); const permissions = useAppStore((s) => s.permissions); const theme = useAppStore((s) => s.theme); const value = useMemo(() => ({ user, permissions, theme }), [user, permissions, theme]); return {children}; } - 按页面替换
useContext(AppContext)为useAppStore(selector),每替换一个页面跑一次单测。 - 字典模块单独迁 Jotai,先迁读,再迁写。
- 删除旧 Context,
AppContext.tsx从 480 行删到 0。
整个过程用了 3 个迭代,约 6 人日。
六、踩坑与优化
坑 1:Zustand 里放非序列化对象导致 persist 报错。 一开始把 Date 对象直接塞进 store 并持久化,JSON.stringify 后变成字符串,读回来类型不对。解决:partialize 只持久化必要字段,或自定义 serialize/deserialize。
坑 2:Jotai 的 atomWithQuery 版本不匹配。 jotai-tanstack-query@0.8.0 要求 @tanstack/react-query@5.x,我们锁的是 5.20.5 没问题,但同事装了 4.x 的 query 直接白屏。解决:在 package.json 用 overrides 锁定。
坑 3:selector 返回新对象导致无限重渲染。
// ❌ 每次返回新数组,触发重渲染
const list = useAppStore((s) => s.permissions.filter(Boolean));
// ✅ 用 useShallow
import { useShallow } from 'zustand/react/shallow';
const list = useAppStore(useShallow((s) => s.permissions.filter(Boolean)));
坑 4:Jotai atom 在组件外读取。 有次在工具函数里想读 atom 值,发现 Jotai 没有 getState。解决:这类场景统一走 Zustand,这也印证了前面「按状态特征分工」的设计。
优化点:给 Zustand 的 devtools 中间件加上 enabled: import.meta.env.DEV,生产环境不加载,省了约 0.4KB。
七、效果数据
用 React DevTools Profiler 和 performance.mark 在同一个页面测了迁移前后各 10 次取中位数:
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 首屏渲染(FCP) | 1.9s | 1.2s | -37% |
| 侧边栏折叠重渲染组件数 | 214 | 31 | -85% |
| 字典搜索每次按键重渲染 | 6 次 | 0 次 | -100% |
| 包体 gzip 增量 | — | +3.1KB | 可接受 |
AppContext.tsx 行数 |
480 | 0 | 删除 |
首屏提升主要来自两处:一是 AppProvider 不再每次渲染创建新对象;二是字典模块的派生原子避免了整棵子树重渲染。
八、总结
如果重来一次,我的选择不会变:Zustand 管全局单例和组件外读取,Jotai 管组件树内的细粒度派生,两者不冲突。Context 不是不能用,而是当它承担了「全局状态中心」的角色时,性能和维护成本会随规模非线性上升。
给准备迁移的同学三条建议:
- 先做试点,用 Profiler 量化收益,别凭感觉。
- 用适配层灰度迁移,别一次性删 Context。
- selector 写法是性能关键,
useShallow和派生原子要提前和团队对齐规范。
代码已开源在内部仓库 fe-state-migration,有需要的同学可以对照着看。