一、问题背景:当Context成为性能瓶颈与维护噩梦

我们产品是一个低代码报表搭建平台,由React 19负责画布编辑器,Vue 3.4负责右侧属性面板,两者通过iframe通信。随着业务复杂度上升,React侧出现了典型的“Props钻取地狱”——一个主题色配置需要从App组件逐层穿透7层到ChartItem组件,中间4个组件其实完全不需要这个props。

Vue侧情况稍好,有Provide/Inject,但问题在于:当我们修改主题色时,所有中间层组件因为inject声明而全部触发重渲染。用Vue DevTools统计,一次颜色修改导致81个组件重新渲染,其中63个与状态无关。

这就是我们必须迁移到状态管理库的导火索:状态共享范围已经远超组件树局部,且更新频率高、影响面广,必须引入可预测的、细粒度的状态分发机制。

二、环境与版本选型:为什么是Zustand 5 + Pinia 2

当前环境:
- React 19.0.0(使用useSyncExternalStore特性)
- Vue 3.4.21(Composition API + `) - Vite 5.2.0,构建目标es2020`

选型并非拍脑袋。我们评估了Redux Toolkit 2.2(太重,且我们必须保留现有的React 19并发特性)、Jotai(原子化粒度不适合我们现有的action模式)。最终确定:

  • React侧:Zustand 5.0.3。核心原因是它基于useSyncExternalStore,不会与React 19的并发渲染冲突,且支持selector返回非引用类型时自动跳过渲染。
  • Vue侧:Pinia 2.2.8。它天然利用Vue 3的effectScope,且去除Vuex的Mutation概念,减少样板代码。

我们明确拒绝使用Context作为最终方案,因为React官方文档也承认:Context不适合高频状态更新,且无法阻止中间组件重渲染

三、方案设计:迁移分层与状态隔离策略

不搞一刀切。我们设计了三层迁移策略:

  1. 全局UI主题状态(高频更新):立即迁移到状态库,这是性能重灾区。
  2. 用户会话/权限状态(低频但全局读取):保留Context/Provide,但包裹在状态库之外,避免状态库被低频数据污染。
  3. 局部UI状态(如弹窗开关):原封不动保留在组件内,禁止迁移。

具体架构:
- React侧:创建useThemeStore,存放themeConfigupdateColorapplyPreset
- Vue侧:创建useThemeStore(Pinia),结构与React侧对齐,方便iframe通信时映射字段。

关键设计:两个store的state结构完全一致,这样通过PostMessage同步时,只需JSON.parse后直接store.setState,无需字段映射。

四、核心实现:两套代码的迁移实战

4.1 React侧:从Context到Zustand 5

迁移前的Context代码:

// 迁移前:Context + useReducer
const ThemeContext = createContext }>(null!);

// 组件中消费(导致中间层全部重渲染)
function ChartItem() {
  const { theme } = useContext(ThemeContext); // 即使只用theme.color,任何context变化都重渲染
}

迁移后的Zustand代码:

// store/themeStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

export interface ThemeState {
  color: string;
  bgColor: string;
  fontSize: number;
  updateColor: (color: string) => void;
}

// 关键:使用 subscribeWithSelector 中间件,实现细粒度订阅
export const useThemeStore = create()(
  subscribeWithSelector((set) => ({
    color: '#4F46E5',
    bgColor: '#F9FAFB',
    fontSize: 14,
    updateColor: (color) => set({ color }),
  }))
);

// 组件中消费:仅当color变化时重渲染
const color = useThemeStore((s) => s.color); // 核心API
const updateColor = useThemeStore((s) => s.updateColor);

注意:Zustand 5要求selector返回原始值(string/number)才会触发Object.is比较,返回对象会直接跳过渲染。我们所有的selector均返回基础类型。

4.2 Vue侧:从Provide/Inject到Pinia 2

迁移前的Provide/Inject代码:

provide('theme', { color: themeColor, bgColor });




const theme = inject('theme'); // 只要provide的响应式对象变化,此组件必被触发

迁移后的Pinia代码:

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

export const useThemeStore = defineStore('theme', {
  state: () => ({
    color: '#4F46E5',
    bgColor: '#F9FAFB',
    fontSize: 14,
  }),
  actions: {
    updateColor(color: string) {
      this.color = color; // Pinia直接修改,无需 mutations
    },
  },
  // 关键:可以配置 getter 返回原始值来减少依赖
  getters: {
    displayColor: (state) => state.color,
  },
});

组件中使用:

import { storeToRefs } from 'pinia';
const themeStore = useThemeStore();
// 核心:storeToRefs 解构后,只有color变化才会触发此组件的effect
const { color } = storeToRefs(themeStore);

性能关键:Vue中不用storeToRefs直接解构store会丢失响应性(注意reactive解构丢失问题)。我们统一使用storeToRefs并配合watch来实现细粒度监听。

五、踩坑记录:两个必须绕开的深坑

5.1 React 19的useSyncExternalStore与Zustand 5的兼容

:在React 19并发特性下,Zustand 5的create方法如果直接返回对象,在并发渲染下可能出现“tearing”(撕裂)。
解决:必须使用create + subscribeWithSelector中间件,且确保所有state更新都是不可变更新。我们同时开启Zustand的shallow比较:

import { shallow } from 'zustand/shallow';

// 多字段选择时用shallow避免对象引用导致的重渲染
const { color, bgColor } = useThemeStore(
  (s) => ({ color: s.color, bgColor: s.bgColor }),
  shallow
);

5.2 Vue 3.4的storeToRefseffectScope冲突

:在`组件中,storeToRefs解构出的ref在组件缓存期间不会主动更新。 **解决**:在onActivated`钩子中手动重新获取:

onActivated(() => {
  const { color } = storeToRefs(themeStore);
  // 手动强制更新视图
});

这个坑导致我们线上出现过一次主题色切换后,被缓存的组件显示旧色。最终在onActivated中强制刷新解决。

六、性能量化对比:数据说话

我们使用React Profiler与Vue DevTools Performance Tab采集了以下数据(均为生产构建,CPU 6x降速模拟):

指标 迁移前 迁移后 提升
React侧组件重渲染次数(一次主题色修改) 81个 14个 -82.7%
Vue侧组件更新次数(一次主题色修改) 63个 9个 -85.7%
平均交互延迟(从点击到视觉反馈) 8.3ms 2.1ms -74.7%
首屏JS bundle体积 React:214KB 196KB -8.4%(因为移除了大量Props类型定义)

额外收益:代码删除约3800行(主要是中间层的Props透传、Context Provider嵌套层)。git log显示,迁移过程中我们删除了12个只负责透传Props的“中间组件”。

七、总结与建议

如果你正在React 19或Vue 3.4项目中面临Props钻取问题,先测量。用Profiler确认重渲染次数是否真的因状态传递而爆炸。如果只是低频状态(如用户信息),Context完全够用。但我们这个场景——高频UI主题配置,且跨组件层级深——状态管理库是唯一解。

Zustand 5和Pinia 2都非常轻量(gzip后不到1KB和1.5KB),迁移成本远低于Redux。最后的建议:迁移时不要重写业务逻辑,只替换状态读取和更新API,风险最小。我们的迁移实际耗时2周,纯coding约3天,其余时间都在处理边界case。