一、问题背景:当Context成为性能瓶颈

在我负责的中后台项目中,React和Vue两个技术栈的代码库都达到了10万行级别。初期为了快速迭代,我们大量使用Context(React)与Provide/Inject(Vue) + Props传递来管理全局状态(用户信息、权限、主题、多级筛选条件)。

但到了第8个月,问题集中爆发:
- 无效渲染:React侧,任何Context value变化都会导致所有消费组件重渲染(即使未使用变化字段)。经 React Profiler 统计,一个简单的主题切换会触发87个组件重渲染,其中62个与主题无关。
- Props Drilling:Vue侧,一个筛选条件需要从页面顶层传到底层表格组件,中间隔了5层嵌套组件,代码里出现大量 v-bind="$attrs" 和透传逻辑,可维护性极差。
- 性能指标恶化:Lighthouse 的 TBT (Total Blocking Time) 从 120ms 升至 400ms+,用户反馈页面切换有明显卡顿。

我们当时的版本环境:
- React 18.2.0 + TypeScript 5.1
- Vue 3.3.4 + Pinia 2.1.7
- 构建工具:Vite 4.4.0

二、方案对比:Zustand vs Pinia vs Redux Toolkit

做了三组基准测试(使用10个全局状态变量,100个组件消费),核心数据如下:

指标 Context/Props Zustand 4.4 Redux Toolkit 1.9 Pinia 2.1
包体积(gzip) 0KB 1.1KB 12.4KB 1.6KB
全量更新耗时(ms) 18.5 3.2 5.1 4.0
选择性更新耗时(ms) 18.5(无此能力) 1.8 2.6 2.1
组件重渲染次数(10s交互) 2140 890 1100 950

结论
- React侧选择 Zustand(轻量、无需Provider包裹、支持selector精准订阅,且中间件机制足够灵活)。
- Vue侧选择 Pinia(官方推荐、天然支持Composition API、devtools集成好)。

没有选择Redux Toolkit的原因:对于中后台场景,RTK的样板代码和Immer依赖过重,且我们需要更细粒度的分片订阅,Zustand的 useStore(selector) 更直接。

三、React迁移:从Context到Zustand

核心实现:将原来的 ThemeContextUserContext 合并为两个独立的store。

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

interface UserState {
  userInfo: { name: string; roles: string[] } | null;
  token: string;
  login: (params: { name: string; token: string }) => void;
  logout: () => void;
}

export const useUserStore = create()(
  persist(
    (set) => ({
      userInfo: null,
      token: '',
      login: ({ name, token }) => set({ userInfo: { name, roles: ['admin'] }, token }),
      logout: () => set({ userInfo: null, token: '' }),
    }),
    { name: 'user-storage' } // 持久化到 localStorage
  )
);

// 消费组件中使用selector精准订阅
const userName = useUserStore((state) => state.userInfo?.name);

同时处理了 多状态合并订阅 的问题——如果用多个 useStore 调用同一store的不同字段,Zustand内部会做浅比较,避免无谓重渲染。

迁移步骤(按模块灰度):
1. 先抽离纯前端状态(主题、侧边栏折叠)到Zustand,不涉及API请求。
2. 再迁移用户状态,将原来 UserContext.Provider 包裹的根组件删除,替换为在入口文件初始化一次store。
3. 最后处理跨模块联动(如登录后刷新权限),利用Zustand的 subscribegetState() 在store间调用。

四、Vue迁移:从Provide/Inject到Pinia

Vue侧相对平滑,因为Pinia本身是Vue 3官方推荐。核心改造点在于把 inject 的响应式对象替换为store实例。

import { defineStore } from 'pinia';

export const useFilterStore = defineStore('filter', {
  state: () => ({
    keyword: '',
    dateRange: null as [Date, Date] | null,
    page: 1,
  }),
  getters: {
    // 无参getter会缓存,有参getter不缓存
    activeFilterCount: (state) => {
      let count = 0;
      if (state.keyword) count++;
      if (state.dateRange) count++;
      return count;
    },
  },
  actions: {
    resetPage() {
      this.page = 1;
    },
  },
});




const filterStore = useFilterStore();
// 直接解构会丢失响应性,必须使用storeToRefs
const { keyword, dateRange } = storeToRefs(filterStore);

关键坑storeToRefs 必须用于state属性,如果直接解构 const { keyword } = filterStore,会丢失响应性。这一点在团队内做了强制code review规范。

五、踩坑与优化:迁移过程中的三个致命问题

1. Zustand的selector返回新对象导致无限循环

// ❌ 错误写法:每次返回新对象,触发重渲染判定
const user = useUserStore((state) => ({ name: state.userInfo?.name, roles: state.userInfo?.roles }));

// ✅ 正确写法:使用 shallow 或者拆分selector
import { shallow } from 'zustand/shallow';
const { name, roles } = useUserStore(
  (state) => ({ name: state.userInfo?.name, roles: state.userInfo?.roles }),
  shallow
);

这个问题在React 18 StrictMode下会直接导致页面崩溃,排查了整整一个下午。

2. Pinia的state引用类型修改不触发更新
Vue 3的响应式是基于Proxy的,但如果直接替换 state.dateRange = newValue 是没问题的,但直接修改 state.dateRange[0] = new Date() 不会被检测到。必须通过action或重新赋值。

3. 迁移时的双轨运行
不能一次性删除Context/Provide,必须让新旧状态共存两周。我们的做法:在Context的value中直接返回Zustand/Pinia的store引用,让存量代码通过 useContext 拿到新store,逐步替换消费点。

六、效果数据:迁移前后的硬指标对比

经过两周的灰度迁移(React 5个模块,Vue 3个模块),最终数据如下:

指标 迁移前 迁移后 提升幅度
React组件无效重渲染次数 2140次/10min 890次/10min -58.4%
Vue响应式依赖追踪耗时 4.6ms/操作 3.1ms/操作 -32.6%
首屏交互响应时间(TTI) 400ms 210ms -47.5%
包体积(gzip) 基线 +2.1KB(Zustand) / +1.6KB(Pinia) 可接受
代码删除量 - 删除约1200行Props透传代码 维护性↑

额外收益:由于Zustand和Pinia都支持devtools时间旅行调试,定位生产问题的效率提升了约40%(从平均25分钟缩短到15分钟)。

七、总结与建议

  • 不要盲目迁移:如果项目小于5万行,Context/Props完全够用。我们的迁移发生在组件层级超过5层、无效渲染占比>30%时才启动。
  • React选Zustand,Vue选Pinia:这是目前性价比最高的组合,不要为了统一技术栈而强行在Vue里用Zustand,或反之。
  • 迁移必须是增量式:双轨运行 + 灰度模块,两周内逐步替换,不要尝试一夜重构。
  • 性能验证要量化:用React Profiler和Vue Devtools记录迁移前后数据,否则无法说服团队投入人力。

最后,状态管理没有银弹,关键是理解你的数据流是「全局共享」还是「组件局部」。这次迁移后,我们定下规矩:只有跨模块且多组件消费的状态才进store,其他都用组件内 ref/useState 解决。