一、背景:当Context成为性能瓶颈,当Props开始“传家宝”

我们的项目是一个中后台管理系统,技术栈为React 19 + TypeScript 5.6(核心模块)与Vue 3.5(插件市场模块),代码量已突破10万行。早期为了快速迭代,全局状态(用户信息、权限、主题配置)均使用React Context或Vue的Provide/Inject,而业务数据则通过Props层层透传,最深可达8层。

痛点爆发于一次性能专项审查。使用React Profiler测得:当用户点击某个功能按钮触发Context value更新时,整个应用组件树(约1200个组件)全部重渲染,单次交互耗时从12ms飙升至210ms。而在Vue侧,虽然响应式追踪避免了全量渲染,但Props透传导致组件复用性极差——修改一个页面权限字段需要改动7个中间组件的Props定义。

此时我们意识到:状态管理不是“要不要用”的问题,而是“如何优雅迁移”的问题。本文以这次真实重构为蓝本,完整复盘方案对比、迁移步骤与性能收益。

二、方案选型:Zustand 5.0 vs Pinia 3.0 vs Jotai——我们不只要打败Context

环境版本锁定:
- React 19.0.0 (稳定版) + TypeScript 5.6.3
- Vue 3.5.12 + Vite 6.0.5
- 状态库:Zustand 5.0.2(React)、Pinia 3.0.1(Vue)

我们内部进行了三轮评审,核心维度如下表:

维度 Zustand 5.0 Pinia 3.0 说明
响应式原理 基于useSyncExternalStore 基于Vue Composition API React侧无额外依赖
选择器优化 需手动使用useShallow 天然组件级订阅 Pinia对Vue更友好
代码侵入性 极低(无Provider) 需安装piniacreatePinia() 迁移成本差距不大
包体积(gzip) +1.8KB +3.2KB 均可接受

最终结论:React侧选Zustand,Vue侧选Pinia。理由是Zustand的useSyncExternalStore与React 19并发特性(如useTransition)配合更好,且无需修改根组件结构;Pinia则原生集成Vue Devtools,保留了Vue的响应式心智模型。

三、迁移架构设计:模块隔离,灰度切换

我们确定“分模块渐进式迁移”策略,而非“Big Bang”重写。核心设计如下:

  1. 新建stores/目录,每个业务域一个store文件(如userStore.tspermissionStore.ts)。
  2. 保留旧Context作为“读兼容层”:迁移期间,Context仅提供默认值,并监听store变化触发自身更新(bridge模式),确保未迁移组件不会白屏。
  3. 数据流改造原则:凡是被超过3层透传的Props,或超过5个组件共享的状态,必须入store。

React侧核心store设计(Zustand v5):

// stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { subscribeWithSelector } from 'zustand/middleware';

interface UserState {
  userInfo: { id: string; name: string; roles: string[] } | null;
  permissions: Set;
  fetchUser: () => Promise;
  toggleRole: (role: string) => void;
}

export const useUserStore = create()(
  subscribeWithSelector(
    persist(
      (set, get) => ({
        userInfo: null,
        permissions: new Set(),
        fetchUser: async () => {
          // 模拟请求
          const res = await fetch('/api/user/me');
          const data = await res.json();
          set({ userInfo: data.user, permissions: new Set(data.permissions) });
        },
        toggleRole: (role) => {
          const { userInfo } = get();
          if (!userInfo) return;
          const hasRole = userInfo.roles.includes(role);
          set({
            userInfo: {
              ...userInfo,
              roles: hasRole
                ? userInfo.roles.filter(r => r !== role)
                : [...userInfo.roles, role]
            }
          });
        },
      }),
      { name: 'user-storage', partialize: (state) => ({ userInfo: state.userInfo }) } // 持久化部分字段
    )
  )
);

Vue侧核心store设计(Pinia 3.0):

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

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    // 使用ref保持响应式
    permMap: {} as Record,
    pageLoading: false,
  }),
  getters: {
    canAccess: (state) => (perm: string) => !!state.permMap[perm],
  },
  actions: {
    async loadPermissions(userId: string) {
      this.pageLoading = true;
      try {
        const res = await fetch(`/api/perms/${userId}`);
        const data = await res.json();
        // 直接赋值,Pinia内部保证响应式
        this.permMap = data.permMap;
      } finally {
        this.pageLoading = false;
      }
    },
    // 批量更新权限(性能优化关键)
    batchUpdatePermissions(newPerms: Record) {
      // 使用$patch且不触发无关watcher
      this.$patch((state) => {
        Object.assign(state.permMap, newPerms);
      });
    }
  },
});

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

第一步:接入Provider(Vue侧)与Bridge(React侧)

在Vue入口处安装Pinia:

// main.ts (Vue)
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const app = createApp(App);
app.use(createPinia()); // 全局注入,所有组件可访问store
app.mount('#app');

React侧无需Provider,但需处理旧Context与新store协同。我们写了一个桥接组件:

// providers/StoreBridge.tsx (React)
import { useUserStore } from '@/stores/userStore';
import { LegacyUserContext } from '@/legacy/contexts/UserContext';
import { useEffect, ReactNode } from 'react';

export function StoreBridge({ children }: { children: ReactNode }) {
  // 订阅store,当store变化时同步更新Context value
  const userInfo = useUserStore((state) => state.userInfo);
  const permissions = useUserStore((state) => state.permissions);

  // 仅当store值变化时,Context value才变化
  const legacyValue = { userInfo, permissions };

  return (

      {children}

  );
}

第二步:组件消费替换

替换前(Context):

const { userInfo } = useContext(LegacyUserContext);

替换后(Zustand + 精确selector):

// 只订阅userInfo,不订阅permissions,减少渲染次数
const userInfo = useUserStore((state) => state.userInfo);
// 若需对比旧值,可使用useShallow防止引用不稳定
const roles = useUserStore((state) => state.userInfo?.roles, shallow);

第三步:处理Props透传

我们使用codemod脚本扫描源码,找出所有超过3层的透传变量,生成迁移清单。以Vue侧为例,改造前:

  {{ permMap['edit'] }}


// 需要defineProps接收从父级传下来的permMap
defineProps({ permMap: Object });

改造后(直接引入store):

import { usePermissionStore } from '@/stores/permissionStore';
// 直接使用store,无需defineProps
const permStore = usePermissionStore();
const canEdit = permStore.canAccess('edit');

五、踩坑与优化:6个典型问题及解法(重点)

坑1:React 19 + Zustand 的StrictMode双调用
- 现象:fetchUser在开发模式下被调用两次。
- 原因:React 19 StrictMode会故意double-invoke reducer和初始化函数。
- 解法:在store外使用let fetchPromise: Promise | null = null,若已有pending请求则复用,或使用useEffect的空依赖+ref锁。

坑2:Pinia的响应式丢失(Vue 3.5新特性)
- 现象:将permMap整体赋值后,组件不更新。
- 原因:未使用$patch导致Vue无法追踪新对象。
- 解法:一律通过$patch或action内部赋值,禁止直接store.permMap = xxx(除非定义为ref)。

坑3:Zustand selector返回新对象导致死循环
- 问题代码:const user = useUserStore((state) => ({ ...state.userInfo })); 每次render都返回新对象,触发无限渲染。
- 解法:使用useShallow,或拆分selector只取基本类型字段。

坑4:迁移期间Context与Store数据不同步
- 场景:页面A使用Context,页面B使用Store,用户信息更新后两处不一致。
- 解法:采用第二节的Bridge模式,Context只读,Store负责写。若直接修改Context value,会触发全树更新,违背迁移初衷。

坑5:Vue Devtools无法调试Pinia状态
- 原因:未安装@pinia/plugin-devtools或未在createPinia()后挂载。
- 解法:安装并注册:pinia.use(DevtoolsPlugin()),且需Vue Devtools版本≥6.6。

坑6:持久化与SSR/Hydration冲突
- 场景:Zustand persist在客户端初始化时从localStorage读取,但服务端渲染(Next.js)时无window。
- 解法:使用skipHydration: true并在客户端useEffect中手动调用useUserStore.persist.rehydrate()

性能优化关键点:为Zustand store添加subscribeWithSelector中间件,使我们能在组件外精确订阅某字段:

// 在非组件文件中监听permissions变化
useUserStore.subscribe(
  (state) => state.permissions.size,
  (size, prevSize) => {
    console.log(`权限数从${prevSize}变为${size}`);
  }
);

六、效果数据与总结

迁移历时3周,分4个批次灰度上线。最终数据如下:

指标 迁移前 迁移后 提升幅度
React侧交互卡顿耗时(P95) 210ms 58ms 72.4%↓
Vue侧组件渲染次数(单次操作) 平均43次 平均11次 74.4%↓
首屏可交互时间(TTI) 2.8s 1.9s 32%↓
代码中Props定义行数 1,230行 410行 66.7%↓

最后的态度:不要为了用库而用库。如果你的Context只是全局且低频更新(如主题色),完全没必要迁移。但若你面临和我一样的场景——高频状态更新、Props透传深渊、性能告警,那么Zustand/Pinia是值得投入的。迁移不是重写,而是把“隐式依赖”变为“显式订阅”。过程中最大的阻力不是技术,而是团队成员“我就用Props传一下怎么了”的惯性思维——代码评审时,我们强制要求:超过3层透传必须入store。现在回看,这个规则的执行是收益最大的部分。

参考版本:React 19.0.0、Zustand 5.0.2、Vue 3.5.12、Pinia 3.0.1、Vite 6.0.5、TypeScript 5.6.3。