一、问题背景:Context 和 Props 在中期项目里有多痛

我们维护的是一个后台管理系统,前端由两个子应用组成:React 18.2 负责数据看板与配置中心,Vue 3.3 负责表单流程与权限管理。项目初期,状态管理方案很“原生”:

  • React 侧:全局状态用 Context + useReducer,局部状态用 Props 逐层传递。
  • Vue 侧:跨层级通信用 provide/inject,兄弟组件通信用 Props + emit。

一开始没问题,直到组件树深度超过 5 层、全局状态超过 12 个切片后,问题集中爆发:

  1. React Context 的“全量重渲染”:只要 Context value 变化,所有 useContext 的组件都会 re-render。我们的 AppContext 里同时放着用户信息、主题、权限、通知,改一个通知未读数,整个页面都跟着渲染。
  2. Vue provide/inject 的响应式陷阱:inject 的值如果直接解构,会丢失响应性;如果不解构,模板里写 user.name 又容易在 undefined 时炸掉。
  3. Props 钻透(prop drilling):一个 handleSubmit 从页面组件传到第 4 层子组件,中间两层完全不关心这个函数,却被迫声明并传递。
  4. 调试困难:状态变更没有时间旅行,没有 devtools 快照,排查“谁改了状态”全靠 console.log

我们决定迁移,目标很明确:减少无效渲染、降低组件耦合、提升可调试性

二、环境与版本

  • React:18.2.0
  • Vue:3.3.4
  • 构建工具:Vite 4.4.9
  • 包管理:pnpm 8.6.12
  • 原方案:React Context + useReducer;Vue provide/inject + Props
  • 新方案:Zustand 4.4.1(React);Pinia 2.1.6(Vue)
  • 性能测试工具:React DevTools Profiler、Vue DevTools、Chrome Performance 面板
  • 测试页面:权限配置页(约 120 个表单项,嵌套 4 层组件)

选择 Zustand 和 Pinia 的理由:

  • Zustand:无 Provider 包裹,API 极简,支持选择器(selector)细粒度订阅,包体积约 1.2KB(gzip)。
  • Pinia:Vue 官方推荐,TypeScript 支持好,支持 setup 语法,devtools 集成完善,包体积约 1.5KB(gzip)。

我们没有选 Redux Toolkit 和 Vuex,原因是:Redux Toolkit 模板代码仍偏多,Vuex 在 Vue 3 中已进入维护模式,Pinia 更符合组合式 API 的心智模型。

三、方案设计:迁移策略与边界

迁移不是“一刀切”。我们定了三条规则:

  1. 全局共享状态 → 状态管理库。例如用户信息、权限、主题、通知。
  2. 跨 3 层以上且频繁更新的状态 → 状态管理库。
  3. 纯 UI 局部状态(如弹窗开关、输入框临时值)→ 继续用 useState / ref

React 侧设计:

  • 按业务域拆 store:useUserStoreusePermissionStoreuseNotificationStore
  • 所有组件通过 useStore(selector) 订阅,避免全量订阅。
  • 异步逻辑放在 store 的 action 中,不写在组件里。

Vue 侧设计:

  • 按业务域拆 store:useUserStoreusePermissionStoreuseFormStore
  • 使用 storeToRefs 保持响应性。
  • 表单草稿状态放入 Pinia,利用 $subscribe 做自动保存。

四、核心实现:代码与迁移步骤

4.1 React:从 Context 到 Zustand

迁移前(Context + useReducer):

// AppContext.jsx
import { createContext, useReducer, useContext } from 'react';

const AppContext = createContext();

const initialState = {
  user: null,
  notifications: [],
  theme: 'light',
};

function reducer(state, action) {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'ADD_NOTIFICATION':
      return { ...state, notifications: [...state.notifications, action.payload] };
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    default:
      return state;
  }
}

export function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (

      {children}

  );
}

export function useApp() {
  return useContext(AppContext);
}

组件里使用:

function NotificationBadge() {
  const { state } = useApp();
  return {state.notifications.length};
}

问题:state 整个对象变化时,NotificationBadge 也会因为 usertheme 变化而重渲染。

迁移后(Zustand):

// stores/useAppStore.js
import { create } from 'zustand';

export const useAppStore = create((set, get) => ({
  user: null,
  notifications: [],
  theme: 'light',

  setUser: (user) => set({ user }),
  addNotification: (n) =>
    set((state) => ({ notifications: [...state.notifications, n] })),
  setTheme: (theme) => set({ theme }),

  // 异步 action
  fetchUser: async () => {
    const res = await fetch('/api/user');
    const user = await res.json();
    set({ user });
  },
}));

组件里使用选择器:

import { useAppStore } from './stores/useAppStore';

function NotificationBadge() {
  const count = useAppStore((s) => s.notifications.length);
  return {count};
}

function ThemeToggle() {
  const theme = useAppStore((s) => s.theme);
  const setTheme = useAppStore((s) => s.setTheme);
  return (
     setTheme(theme === 'light' ? 'dark' : 'light')}>
      {theme}

  );
}

关键点:useAppStore((s) => s.notifications.length) 只在长度变化时触发重渲染,usertheme 变化不影响它。

迁移步骤(React 侧):

  1. 安装:pnpm add zustand@4.4.1
  2. 按业务域创建 store 文件,把 Context 中的 state 和 dispatch 逻辑搬进 store。
  3. 删除 AppProvider,在 main.jsx 中不再包裹 Provider。
  4. 逐组件替换 useApp()useAppStore(selector),优先替换高频更新组件。
  5. 用 React DevTools Profiler 对比迁移前后的渲染次数。

4.2 Vue:从 provide/inject 到 Pinia

迁移前(provide/inject + Props):

import { provide, ref } from 'vue';
const user = ref(null);
const permissions = ref([]);
provide('user', user);
provide('permissions', permissions);
import { inject } from 'vue';
const user = inject('user');



  {{ user?.name }}

问题:inject 的值如果被解构会丢失响应性;user 为 null 时模板需要可选链;跨组件修改状态没有统一入口。

迁移后(Pinia):

// stores/useUserStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useUserStore = defineStore('user', () => {
  const user = ref(null);
  const permissions = ref([]);

  const isAdmin = computed(() =>
    permissions.value.includes('admin')
  );

  async function fetchUser() {
    const res = await fetch('/api/user');
    user.value = await res.json();
  }

  function setPermissions(list) {
    permissions.value = list;
  }

  return { user, permissions, isAdmin, fetchUser, setPermissions };
});

组件里使用:

import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/useUserStore';

const userStore = useUserStore();
const { user, isAdmin } = storeToRefs(userStore);

userStore.fetchUser();



  {{ user?.name }}
  管理员

迁移步骤(Vue 侧):

  1. 安装:pnpm add pinia@2.1.6
  2. main.js 中注册:app.use(createPinia())
  3. 按业务域创建 store,把 provide/inject 的 ref 和修改逻辑搬进 store。
  4. 删除 provide 调用,组件改为 useXxxStore() + storeToRefs
  5. 用 Vue DevTools 的 Timeline 对比迁移前后的组件更新次数。

五、踩坑与优化

坑 1:Zustand 选择器返回新对象导致无限重渲染。

错误写法:

const { user, theme } = useAppStore((s) => ({ user: s.user, theme: s.theme }));

每次返回新对象,浅比较失败,组件无限渲染。解决:用 useShallow 或分开写两个选择器。

import { useShallow } from 'zustand/react/shallow';
const { user, theme } = useAppStore(
  useShallow((s) => ({ user: s.user, theme: s.theme }))
);

坑 2:Pinia 解构丢失响应性。

错误写法:

const { user } = useUserStore(); // user 不是响应式的

解决:必须用 storeToRefs

坑 3:异步 action 中的竞态。

fetchUser 中,如果连续调用两次,后返回的可能覆盖先返回的。我们在 store 里加了请求 ID:

let requestId = 0;
async function fetchUser() {
  const id = ++requestId;
  const res = await fetch('/api/user');
  const data = await res.json();
  if (id === requestId) user.value = data;
}

优化:持久化。 Zustand 用 persist 中间件,Pinia 用 pinia-plugin-persistedstate,把主题和用户偏好存入 localStorage,减少首屏请求。

六、效果数据

在权限配置页(120 个表单项,4 层嵌套)上,用 React DevTools Profiler 和 Vue DevTools 测量:

指标 迁移前 迁移后 变化
首屏渲染时间 1.8s 1.2s -33%
复杂表单页无效重渲染次数 约 180 次/操作 约 50 次/操作 -72%
状态管理相关代码行数 约 620 行 约 400 行 -35%
包体积(gzip) 0(原生) +1.2KB / +1.5KB 可接受
状态调试耗时(主观) 明显改善

首屏渲染提升主要来自:去掉了 Context Provider 的包裹层级,减少了全量重渲染。无效重渲染下降主要来自:选择器订阅和 storeToRefs 的细粒度更新。

七、总结

从 Context/Props 迁移到 Zustand/Pinia,不是“为了用库而用库”,而是当组件树变深、全局状态变多、性能开始报警时,原生方案的心智负担和渲染开销会超过引入库的成本。

我们的建议:

  1. 小项目、状态少于 5 个切片,Context 和 provide/inject 够用,不必过早引入。
  2. 中型以上项目,优先按业务域拆 store,不要建一个巨大的全局 store。
  3. React 侧务必用选择器,Vue 侧务必用 storeToRefs,否则性能优势发挥不出来。
  4. 迁移可以渐进式,先迁高频更新组件,用 Profiler 验证收益后再全面铺开。

这次迁移后,我们的状态管理代码更集中、调试更轻松,性能数据也达到了预期。如果你正在经历类似的痛点,希望这篇记录能帮你少踩几个坑。