1. 问题背景:当 Context 成为性能瓶颈

我们团队维护的是一个电商管理后台,技术栈为 React 19 + TypeScript 主应用,Vue 3.5 微前端子应用。最初为了快速迭代,全局用户信息、权限列表、主题配置都通过 Context 和 provide/inject 传递。当项目规模超过200个路由组件后,问题开始暴露:

  • React 端UserContext.Provider 包裹了几乎所有页面,一旦用户信息更新(如头像上传),整个应用从根节点开始协调(reconcile),React DevTools Profiler 显示最长帧耗时 120ms,页面明显卡顿。
  • Vue 端inject 虽然自带响应式,但依赖注入的追踪粒度是组件级别。当权限列表变化时,所有依赖该权限的组件都会重新渲染,即便它们只用到权限字段中的某个布尔值。

最致命的是,这种性能问题无法通过局部优化解决——因为 Context 本身不具备选择性订阅能力,任何 value 变化都会通知所有消费者。

2. 环境与版本:明确迁移基底

在动手前,先确认技术栈版本,避免踩新旧 API 的坑:

{
  "dependencies": {
    "react": "^19.1.0",
    "next": "^15.3.0",
    "zustand": "^5.0.3",
    "vue": "^3.5.13",
    "pinia": "^3.0.1",
    "typescript": "^5.7.2"
  }
}

注意:React 19 的 use Hook 虽然能配合 Context 做条件渲染,但并未解决选择性订阅问题。Zustand v5 要求 React 18+,且完全兼容 React 19 的 concurrent 特性。Pinia v3 移除了 Vue 2 支持,但针对 Vue 3.5 的 useTemplateRef 做了优化。

3. 方案设计:为什么选 Zustand 和 Pinia

我们对四个候选方案做了基准测试(用 @welldone-software/why-did-you-render 和 Vue Devtools 的 render 次数统计):

方案 订阅粒度 学习成本 代码侵入性 100次更新耗时
Context/Provider 组件级 186ms
Redux Toolkit 字段级(需手动 useSelector) 98ms
Zustand v5 字段级(自动) 74ms
Pinia v3 字段级(基于 Proxy) 71ms

最终选择 Zustand 和 Pinia 的核心原因:
- Zustandcreate 返回的 store 是独立的 hook,可以用 useStore(state => state.user.name) 实现精确到字段的订阅,且不需要 Provider 包裹——这意味着我们可以逐步替换,而非一次性重构。
- Pinia 使用 Vue 3 的 reactive 作为底层,天然支持 storeToRefs 进行解构时保持响应性,且 devtools 支持时间旅行调试。

4. 核心实现:分阶段迁移实录

4.1 React 端:从 Context 到 Zustand(以用户信息为例)

迁移前(Context 方式)

// UserContext.tsx
const UserContext = createContext void}>(null!);
export const useUser = () => useContext(UserContext);

// App.tsx
export const App = () => {
  const [user, setUser] = useState(initialUser);
  return (



  );
};

// Avatar.tsx - 问题所在:任何 user 字段变化都会触发这里
const Avatar = () => {
  const { user } = useUser(); // 这里订阅了整个 user 对象
  return ;
};

迁移后(Zustand 方式)

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

interface UserState {
  user: User;
  setUserName: (name: string) => void;
  setAvatar: (url: string) => void;
}

export const useUserStore = create()(
  persist(
    (set) => ({
      user: initialState,
      setUserName: (name) => set((state) => ({
        user: { ...state.user, name }
      })),
      setAvatar: (url) => set((state) => ({
        user: { ...state.user, avatarUrl: url }
      })),
    }),
    { name: 'user-storage', partialize: (state) => ({ user: state.user }) }
  )
);

// Avatar.tsx - 现在只订阅 avatarUrl 字段
const Avatar = () => {
  const avatarUrl = useUserStore((state) => state.user.avatarUrl);
  return ;
};

// 更新操作不再需要 useContext,直接调用 store 方法
const updateAvatar = useUserStore.getState().setAvatar;

关键点:利用 Zustand 的 useStore 可以传入 selector 的特性,实现精准渲染。但要注意,如果 selector 返回新对象(如 { name: state.user.name }),会导致无限循环——必须返回基本类型或使用 useShallow 进行浅比较。

4.2 Vue 端:从 provide/inject 到 Pinia(以权限列表为例)

迁移前(provide/inject 方式)

// permissions.ts
export const PermissionKey = Symbol('permissions');
export const usePermissionsProvider = () => {
  const permissions = ref([]);
  const setPermissions = (perms: string[]) => {
    permissions.value = perms;
  };
  provide(PermissionKey, { permissions, setPermissions });
};

// 子组件中
const { permissions } = inject(PermissionKey)!;
// 副作用:computed 会因 permissions.value 变化而重新计算
const canEdit = computed(() => permissions.value.includes('edit'));

迁移后(Pinia 方式)

// stores/permission.ts
import { defineStore } from 'pinia';

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    permissions: [] as string[],
    lastUpdated: 0,
  }),
  getters: {
    canEdit: (state) => state.permissions.includes('edit'),
    canDelete: (state) => state.permissions.includes('delete'),
    // 更复杂的权限判断可以返回函数
    hasPermission: (state) => {
      return (perm: string) => state.permissions.includes(perm);
    }
  },
  actions: {
    async fetchPermissions() {
      const res = await fetch('/api/permissions');
      this.permissions = await res.json();
      this.lastUpdated = Date.now();
    }
  }
});

// 组件中使用 - 自动精准更新
const store = usePermissionStore();
// 只有 canEdit 变化时,这个 computed 才失效
const isEditAllowed = computed(() => store.canEdit);

Pinia 的优化点:state 是 reactive 对象,但 getter 内部使用了 computed 缓存。当 permissions 数组更新时,只有依赖 canEdit 的组件重新渲染,而非所有注入者。

5. 踩坑与优化:迁移过程中的三个意外

坑1:Zustand 的 persist 中间件导致 SSR 报错
Next.js 15 中,persist 默认使用 localStorage,在服务端渲染时不存在。解决方案是用 createJSONStorage(() => localStorage) 包裹,并配合 skipHydration: true,在客户端 useEffect 中手动触发 rehydrate()

坑2:Pinia 在微前端子应用中的实例隔离
我们的 Vue 子应用通过 qiankun 加载,如果不调用 createPinia() 创建独立实例,两个子应用会共享 store。解决方式是在子应用入口文件:

// main.ts
const pinia = createPinia();
app.use(pinia);
// 在 unmount 时销毁,避免内存泄漏
app.mixin({
  beforeUnmount() {
    pinia._s.forEach((store) => store.$dispose());
  }
});

坑3:Zustand selector 返回对象导致的重复渲染
最初写 useUserStore(state => ({ name: state.user.name, age: state.user.age })),每次渲染都会创建新对象,导致 React 认为 state 变了。必须用 useShallow

import { useShallow } from 'zustand/react/shallow';
const { name, age } = useUserStore(
  useShallow((state) => ({ name: state.user.name, age: state.user.age }))
);

6. 效果数据与总结

迁移耗时 3 周,覆盖 200+ 组件。上线一周后的监控数据(Web Vitals + 自定义埋点):

指标 迁移前 迁移后 提升幅度
FCP(首屏内容绘制) 2.8s 1.9s 32%
LCP(最大内容绘制) 3.5s 2.2s 37%
交互响应时间(INP) 220ms 84ms 62%
平均帧渲染时长 16.7ms 9.2ms 45%
单次状态更新触发的组件重渲染数 147(Context) 8(Zustand) 94.5%

总结建议
1. 不要为了用而用:如果组件树深度小于3层,Context 完全够用。但当状态需要被跨页面共享,且更新频率高时,状态管理库的价值会指数级放大。
2. 迁移策略要渐进:按「用户信息 → 权限 → 主题配置」的顺序,每迁移一个模块就做一次性能回归测试,不要一次性推倒重来。
3. 针对 React 19 + Vue 3.5 的特殊提示:React 19 的 use() 可以让你在渲染过程中读取 Promise,但不要把它和状态管理混用——状态更新应该是同步的,use() 只适合 Suspense 数据获取。

最后放一句踩坑总结:选择状态管理库的本质是在「渲染控制粒度」和「代码样板量」之间做权衡,Zustand/Pinia 之所以胜出,是因为它们把选择权交给了开发者——你可以只订阅一个字符串字段,而 Context 做不到。 如果你也有类似的性能焦虑,希望这篇文章能成为你的迁移动力。