一、问题背景:Context 和 Props 不是不能用,而是用久了会疼

我们维护的是一个中型后台管理系统,技术栈分两部分:新页面用 React 18 + TypeScript + Vite,老页面逐步迁移到 Vue 3 + TypeScript + Vite。两个技术栈并存,状态管理方式也各不相同。

React 侧最初采用 Context + useReducer 管理全局状态,比如用户信息、权限、主题、侧边栏折叠等。局部状态则通过 props 逐层传递。Vue 侧则大量使用 props + emit,偶尔用 provide/inject 跨层级传值。

一开始没问题。但随着页面增多,问题逐渐暴露:

  1. React Context 的“全量更新”问题:只要 Context value 变化,所有消费该 Context 的组件都会重新渲染。我们的用户信息 Context 和权限 Context 放在同一个 Provider 里,切换主题时会导致权限相关组件也重渲染。
  2. Props drilling 层级过深:一个表格页面,从页面组件到表格单元格,最多传了 6 层 props。改一个字段名,要改 6 个文件。
  3. Vue 侧 provide/inject 类型推断弱:注入的 key 是字符串,重构时容易漏改,且 inject 的默认值处理不优雅。
  4. 性能可观测性差:React Profiler 显示,在 1000 条数据的列表页,一次筛选操作触发的重渲染耗时约 180ms,其中约 60ms 花在 Context 引起的无关组件更新上。

于是我们决定:React 侧引入 Zustand 4.5,Vue 侧引入 Pinia 2.1。选型理由后面细说。

二、环境与版本

  • React:18.2.0
  • Vue:3.3.4
  • TypeScript:5.2.2
  • Vite:4.4.9
  • Zustand:4.5.0
  • Pinia:2.1.6
  • 性能测试工具:React DevTools Profiler、Vue DevTools Performance、Chrome Performance 面板
  • 测试数据:1000 条用户列表,每行 8 列,支持筛选、排序、分页

三、方案设计:为什么选 Zustand 和 Pinia

React 侧对比:

方案 包体积 学习成本 重渲染控制 适合场景
Context + useReducer 0 低频更新、层级浅
Redux Toolkit 约 13KB 大型、复杂状态
Zustand 约 1.2KB 中小型、按需订阅
Jotai 约 3KB 原子化状态

我们选 Zustand 的原因:API 极简,不需要 Provider 包裹,支持选择器(selector)按需订阅,且可以直接在组件外读取/修改状态。对于后台系统这种“全局状态不多,但更新频率中等”的场景,Zustand 的性价比最高。

Vue 侧对比:

方案 学习成本 类型推断 模块化 适合场景
props + emit 父子组件
provide/inject 跨层级少量数据
Vuex 4 老项目
Pinia 2 新项目

Pinia 是 Vue 官方推荐,TypeScript 推断好,且支持组合式 API 写法。我们直接选了 Pinia。

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

4.1 React 侧:从 Context 到 Zustand

迁移前(Context):

// UserContext.tsx
import { createContext, useContext, useReducer } from 'react';

const UserContext = createContext(null);

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

      {children}

  );
}

export function useUser() {
  return useContext(UserContext);
}

// 某个深层组件
function UserName() {
  const { state } = useUser();
  return {state.name};
}

问题:state 任何字段变化,UserName 都会重渲染。

迁移后(Zustand):

// useUserStore.ts
import { create } from 'zustand';

interface UserState {
  name: string;
  permissions: string[];
  theme: 'light' | 'dark';
  setName: (name: string) => void;
  toggleTheme: () => void;
}

export const useUserStore = create((set) => ({
  name: '',
  permissions: [],
  theme: 'light',
  setName: (name) => set({ name }),
  toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
}));

// 组件中按需订阅
function UserName() {
  const name = useUserStore((s) => s.name);
  return {name};
}

function ThemeToggle() {
  const theme = useUserStore((s) => s.theme);
  const toggleTheme = useUserStore((s) => s.toggleTheme);
  return {theme};
}

关键点:useUserStore((s) => s.name) 只订阅 nametheme 变化不会触发 UserName 重渲染。

迁移步骤:

  1. 安装 zustand@4.5.0
  2. 按业务域拆分 store:useUserStoreusePermissionStoreuseTableStore
  3. 将 Context 中的 state 和 dispatch 方法迁移到 store 的 state 和 action。
  4. 删除 Provider,组件内改用 useXxxStore(selector)
  5. 对于需要计算的值,用 useMemo 或 store 内的派生函数,避免在 selector 中返回新对象。

踩坑:selector 返回新对象导致无限重渲染。

错误写法:

const { name, theme } = useUserStore((s) => ({ name: s.name, theme: s.theme }));

因为每次返回新对象,Zustand 默认用 Object.is 比较,会认为状态变了。正确做法是用 shallow

import { shallow } from 'zustand/shallow';

const { name, theme } = useUserStore(
  (s) => ({ name: s.name, theme: s.theme }),
  shallow
);

或者分开订阅:

const name = useUserStore((s) => s.name);
const theme = useUserStore((s) => s.theme);

4.2 Vue 侧:从 props/provide 到 Pinia

迁移前(props + provide):

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




import { inject } from 'vue';
const user = inject('user');

迁移后(Pinia):

// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useUserStore = defineStore('user', () => {
  const name = ref('');
  const permissions = ref([]);
  const isAdmin = computed(() => permissions.value.includes('admin'));

  function setName(newName: string) {
    name.value = newName;
  }

  return { name, permissions, isAdmin, setName };
});
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();



  {{ userStore.name }}
  管理员

迁移步骤:

  1. 安装 pinia@2.1.6,在 main.tsapp.use(createPinia())
  2. 按业务域创建 store 文件,使用组合式 API 写法。
  3. 将 provide/inject 的 key 替换为 store 的引用。
  4. 将 props 传递的字段改为直接从 store 读取。
  5. 对于需要持久化的状态,加 pinia-plugin-persistedstate

踩坑:store 在组件外使用。

在路由守卫或 axios 拦截器中,需要先确保 Pinia 已安装。正确做法:

// main.ts
const pinia = createPinia();
app.use(pinia);

// 在守卫中
import { useUserStore } from '@/stores/user';
router.beforeEach((to) => {
  const userStore = useUserStore(); // 此时 pinia 已激活
  if (!userStore.name && to.name !== 'login') return { name: 'login' };
});

如果顺序反了,会报 “getActivePinia was called with no active Pinia”。

五、性能变化与效果数据

React 侧:

  • 迁移前:1000 条列表筛选,Profiler 显示 commit 阶段耗时约 180ms,其中 Context 引起的无关组件重渲染约 60ms。
  • 迁移后:同一操作 commit 阶段耗时约 45ms,无关组件重渲染基本消除。
  • 首屏 JS 体积:增加约 1.2KB(gzip 后约 0.6KB),可忽略。
  • 代码量:删除了 3 个 Context 文件、约 200 行 Provider 逻辑,组件内 props 传递减少约 35%。

Vue 侧:

  • 迁移前:一个跨 4 层组件的表单,修改字段需要改 4 个文件,平均每次改动约 15 分钟。
  • 迁移后:直接改 store,平均每次改动约 3 分钟。
  • 组件通信代码减少约 40%。
  • 运行时性能:Pinia 的响应式基于 Vue 的 reactive,性能与 provide/inject 相当,无明显下降。

可观测性提升:

Zustand 和 Pinia 都支持 DevTools。React 侧用 Redux DevTools 查看 Zustand 的 action 和 state 变化;Vue 侧用 Vue DevTools 的 Pinia 面板。排查状态问题时,不再需要到处打 console.log。

六、总结

从 Context/Props 迁移到 Zustand/Pinia,我们的核心收获不是“状态管理库一定更好”,而是:

  1. 按需订阅是性能关键。Context 的全量更新在中等规模应用里就是瓶颈,Zustand 的 selector 和 Pinia 的 computed 都能精准控制更新范围。
  2. 迁移成本可控。我们分两周完成,先迁全局状态,再迁局部跨层级状态。每个 store 独立迁移,不影响其他页面。
  3. 不要过度设计。如果项目只有两三个全局状态且更新频率低,Context 和 provide/inject 完全够用。引入状态管理库的时机是:出现性能问题、props 层级超过 4 层、或者多人协作需要明确的状态边界。
  4. 版本锁定很重要。Zustand 4.x 和 5.x 的 API 有差异,Pinia 2.x 和 3.x 也有变化,建议在 package.json 中锁定小版本,避免自动升级带来意外。

如果你也在犹豫是否迁移,建议先用 React Profiler 或 Vue DevTools 测一下当前的重渲染耗时。如果 commit 阶段超过 100ms 且与状态更新相关,那 Zustand/Pinia 值得一试。