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%以上的性能提升,这笔账很划算。