1. 问题背景:当Context成为性能瓶颈
事情要从一个客服工单管理系统说起。这个项目是React 18 + TypeScript,初期为了快速迭代,全局状态直接用了useContext + useReducer。当用户登录后,我们会把用户信息、权限列表、消息通知、主题设置等30多个状态放在一个GlobalContext里。
随着业务增长,问题开始浮现:
- 列表页输入框打字时,整个应用卡顿,DevTools Performance录制显示
App组件每次输入都重新渲染 - 因为
useContext的值变化会触发所有消费者组件更新,而我们的GlobalProvider包裹了所有路由组件 - 在Vue 3的另一个项目中,使用
provide/inject也存在类似问题——虽然Vue的响应式系统避免了整树渲染,但inject的依赖追踪在高频更新场景下仍会产生大量组件更新
于是,我们决定在两个项目中分别引入状态管理库:React端用Zustand,Vue端用Pinia。为什么选它们?往下看。
2. 环境与版本
| 项目 | 框架版本 | 状态管理库 | 构建工具 |
|---|---|---|---|
| React项目 | React 18.2.0 | Zustand 4.5.0 | Vite 5.0.0 |
| Vue项目 | Vue 3.4.0 | Pinia 2.1.7 | Vite 5.0.0 |
选型理由:
- Zustand:体积小(gzip后约1.5KB)、无Provider嵌套、支持选择器订阅(避免不必要的re-render)
- Pinia:Vue官方推荐、基于Composition API、天然支持TS类型推导、DevTools友好
3. 方案设计:从Context到Store的映射
3.1 React端:Context → Zustand
原始Context结构:
// 迁移前:Context + useReducer
const GlobalContext = createContext;
}>({ state: initialState, dispatch: () => {} });
// 组件中使用
const { state, dispatch } = useContext(GlobalContext);
const { user, permissions } = state;
迁移后的Zustand store:
// 迁移后:Zustand store
import { create } from 'zustand';
interface GlobalStore {
user: UserInfo | null;
permissions: string[];
theme: 'light' | 'dark';
setUser: (user: UserInfo) => void;
setPermissions: (perms: string[]) => void;
toggleTheme: () => void;
}
export const useGlobalStore = create((set) => ({
user: null,
permissions: [],
theme: 'light',
setUser: (user) => set({ user }),
setPermissions: (permissions) => set({ permissions }),
toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}));
组件消费方式的变化:
// 迁移前:useContext会订阅所有state变化
const { state, dispatch } = useContext(GlobalContext);
const user = state.user; // 即使只用了user,theme变化也会触发重渲染
// 迁移后:使用选择器精确订阅
const user = useGlobalStore((state) => state.user); // 只有user变化才会触发重渲染
const theme = useGlobalStore((state) => state.theme); // 独立订阅
3.2 Vue端:Provide/Inject → Pinia
// 迁移前:provide/inject
// 父组件
provide('globalState', {
user: ref(null),
permissions: ref([]),
setUser: (u) => { /* 手工更新逻辑 */ }
});
// 子组件
const { user, setUser } = inject('globalState');
// 迁移后:Pinia store
import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({
user: null as UserInfo | null,
permissions: [] as string[],
theme: 'light' as 'light' | 'dark',
}),
actions: {
setUser(user: UserInfo) {
this.user = user;
},
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
},
},
getters: {
isAdmin: (state) => state.permissions.includes('admin'),
},
});
4. 核心实现:迁移步骤拆解
整个迁移过程我们分四步走,每个项目耗时约2天:
Step 1:创建Store层(不删除旧代码)
- React:新建
stores/globalStore.ts,把原来reducer里的逻辑搬到actions中 - Vue:新建
stores/global.ts,把provide中的data/方法迁移到Pinia的state/actions
Step 2:替换消费点(按模块批量替换)
我们按路由模块逐个替换,而不是一次性全量替换。比如先替换Profile页面,验证无误后再替换Dashboard。替换时利用IDE的重构工具:
- React:把
useContext(GlobalContext)替换为useGlobalStore() - Vue:把
inject('globalState')替换为useGlobalStore()
Step 3:删除Provider/Inject代码
所有消费点替换完成后,删除GlobalProvider包装组件,React的main.tsx中移除`包裹;Vue的App.vue中移除provide`代码。
Step 4:处理边界情况
- 异步操作:原来在
useEffect里dispatch的异步请求,改为在Zustand/Pinia的actions中直接使用async/await - 跨组件通信:原来通过
Context传递的回调函数,改为直接在store中定义action
5. 踩坑与优化:三个值得注意的细节
坑1:Zustand的selector返回值引用类型
如果selector返回一个对象字面量,会导致无限重渲染:
// ❌ 错误:每次都会返回新引用
const user = useGlobalStore((state) => ({ name: state.user.name, age: state.user.age }));
// ✅ 正确:使用useShallow
import { useShallow } from 'zustand/react/shallow';
const user = useGlobalStore(
useShallow((state) => ({ name: state.user.name, age: state.user.age }))
);
坑2:Pinia在setup store中解构响应性丢失
使用setup语法时,直接解构state会丢失响应性:
// ❌ 错误
const { user, setUser } = useGlobalStore();
// user不是响应式的,模板中不会更新
// ✅ 正确:使用storeToRefs
import { storeToRefs } from 'pinia';
const store = useGlobalStore();
const { user, permissions } = storeToRefs(store);
const { setUser, toggleTheme } = store;
坑3:大列表页的持久化
迁移后,我们把用户偏好(主题、语言)持久化到localStorage。Zustand使用persist中间件,Pinia使用pinia-plugin-persistedstate。注意两者的存储格式不同,迁移时需清理旧的localStorage key。
6. 效果数据:迁移前后的性能对比
我们用Lighthouse + React Profiler + Vue Devtools性能分析录制了同一场景(登录后进入工单列表页,连续输入搜索关键词)的性能数据:
| 指标 | React Context | React Zustand | 变化 |
|---|---|---|---|
| 首屏渲染时间(ms) | 1240 | 1020 | ↓ 18% |
| 键盘输入FPS | 20 | 55 | ↑ 175% |
| 组件重渲染次数(输入一次) | 38 | 4 | ↓ 89% |
| JS执行时间(ms) | 340 | 110 | ↓ 68% |
| 指标 | Vue Provide/Inject | Vue Pinia | 变化 |
|---|---|---|---|
| 组件更新次数(输入一次) | 21 | 8 | ↓ 62% |
| 首屏渲染时间(ms) | 890 | 830 | ↓ 7% |
| 内存占用(MB) | 46.2 | 41.8 | ↓ 10% |
结论: 对于有大量全局状态且存在频繁更新的中后台项目,从Context/Provide迁移到Zustand/Pinia不是锦上添花,而是必要的架构优化。尤其是React端,Context的re-render问题在大型项目中几乎无解,Zustand的选择器订阅机制从根本上解决了这个问题。
如果你的项目目前全局状态较少(<5个),且不存在高频更新场景,那么用Context/Provide完全够用,不必盲目上状态管理库——毕竟维护成本也是成本。但如果你的项目已经出现卡顿,我建议立刻动手迁移,两天时间换回50%以上的性能提升,这笔账很划算。