一、为什么我受够了Props钻探:一个真实的重渲染事故

先交代背景:我们是一个数据可视化团队,前端仓库里同时存在React(主应用,版本19.0.0)和Vue(微前端子应用,版本3.5.13)。两套代码共享一个全局的筛选条件对象(时间范围、地区、指标维度),这个对象会从顶层的DashboardLayout传给图表组件、表格组件、甚至导出按钮。

事故发生在一次上线后——用户切换时间范围时,页面卡顿长达1.2秒。打开React DevTools Profiler,发现一次setState触发了214个组件的重渲染,其中132个组件根本没用到这个筛选条件。原因很直白:props drilling导致中间层组件(比如一个只负责布局的SectionWrapper)被迫接收并透传filters对象,而Context的value一旦变化,所有消费该Context的组件都会重渲染——无论他们是否真的需要那个变化的部分。

Vue侧稍好,因为Vue 3.5的useSlotsprovide/inject有响应式追踪,但问题出在深层嵌套组件获取inject的依赖链过长,导致组件初始化时间增加了约35ms/个。

二、环境与版本:双技术栈下的技术选型定案

先说结论:React侧选择Zustand(5.0.3),Vue侧选择Pinia(3.0.2)。没有选择Redux Toolkit或MobX,原因后面对比时展开。

具体环境:
- React 19.0.0(使用use Hook与Server Components,但我们的业务组件均为Client Components)
- Vue 3.5.13(使用`语法) - 构建工具:Vite 6.0.7(React插件版本4.3.4,Vue插件版本5.2.1) - 状态库:Zustand 5.0.3(基于useSyncExternalStore),Pinia 3.0.2(基于Vue 3.5的reactive`)

为什么放弃Context?在React 19中,Context依然存在“provider重新渲染时,所有consumer强制重渲染”的机制。即便你用useMemo包裹value,只要组件在provider树中且消费了context,渲染就无法跳过。而Zustand利用了useSyncExternalStore的细粒度订阅,每个store的selector变化时才触发重渲染。

Vue侧为什么不用reactive + provide?因为Pinia的devtools集成和模块热更新(HMR)支持完善,且其基于reactive的实现天然具备依赖追踪——相比手写provide/inject省去大量模板代码,且不会有意外解包的问题。

三、方案设计:迁移顺序与store拆分策略

迁移不能一刀切。我设计了三步走方案,耗时4个迭代完成:

  1. 先建store骨架,再逐模块替换:先把全局筛选条件提取为useFilterStore(React)和useFilterStore(Pinia),但保留现有Props透传。新写的组件直接消费store,老组件后续再改。
  2. 从最深层消费者开始迁移:优先处理那些处于props链最底端的组件(比如表头的筛选下拉框),然后逐层向上删除中间的透传props。
  3. 最后处理副作用逻辑:原本在顶层组件里的useEffect监听筛选条件变化去拉数,迁移到store的actions中,通过store.subscribe触发数据请求。

Store拆分遵循单一职责:filterStore(筛选条件)、uiStore(侧边栏折叠、主题色)、userStore(登录信息)。全局只有一个useAuthStore是跨React与Vue共享的——通过一个定制的bridge(基于window.dispatchEvent),但这不是本文重点,后续专文讨论。

四、核心实现:React 19 + Zustand 5与Vue 3.5 + Pinia 3的代码对照

React侧核心实现(使用Zustand 5.0.3):

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

interface FilterState {
  timeRange: [Date, Date];
  regions: string[];
  metric: string;
  setTimeRange: (range: [Date, Date]) => void;
  setRegions: (regions: string[]) => void;
  setMetric: (metric: string) => void;
  reset: () => void;
}

export const useFilterStore = create()(
  subscribeWithSelector((set) => ({
    timeRange: [new Date('2024-01-01'), new Date('2024-01-31')],
    regions: ['华东'],
    metric: 'PV',
    setTimeRange: (timeRange) => set({ timeRange }),
    setRegions: (regions) => set({ regions }),
    setMetric: (metric) => set({ metric }),
    reset: () => set({
      timeRange: [new Date('2024-01-01'), new Date('2024-01-31')],
      regions: ['华东'],
      metric: 'PV',
    }),
  }))
);

// 使用:组件只订阅需要的字段,避免无谓渲染
function MetricSelector() {
  // 关键:selector返回原始值,Zustand内部用Object.is比较
  const metric = useFilterStore((state) => state.metric);
  const setMetric = useFilterStore((state) => state.setMetric);

  return (
     setMetric(e.target.value)}>
      PV
      UV
      Click

  );
}

Vue侧核心实现(使用Pinia 3.0.2):

// stores/filter.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useFilterStore = defineStore('filter', () => {
  // 注意:使用ref/reactive定义state,Composition API风格
  const timeRange = ref([new Date('2024-01-01'), new Date('2024-01-31')]);
  const regions = ref(['华东']);
  const metric = ref('PV');

  function setTimeRange(range: [Date, Date]) {
    timeRange.value = range;
  }
  function setRegions(newRegions: string[]) {
    regions.value = newRegions;
  }
  function setMetric(newMetric: 'PV' | 'UV' | 'Click') {
    metric.value = newMetric;
  }
  function reset() {
    timeRange.value = [new Date('2024-01-01'), new Date('2024-01-31')];
    regions.value = ['华东'];
    metric.value = 'PV';
  }

  return { timeRange, regions, metric, setTimeRange, setRegions, setMetric, reset };
});

// 组件中使用:storeToRefs确保解构后保持响应性

import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filter';

const filterStore = useFilterStore();
// 关键:用storeToRefs包裹解构,避免失去响应性
const { metric, timeRange } = storeToRefs(filterStore);
const { setMetric } = filterStore;

五、踩坑与优化:隐藏的getter闭包陷阱与订阅泄漏

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

初期我写的是useFilterStore((state) => ({ metric: state.metric, regions: state.regions }))——这会导致每次store变更都生成新对象,useSyncExternalStore判断值变化为true,组件无限重渲染。解决方法是使用useShallow钩子:

import { useShallow } from 'zustand/react/shallow';

const { metric, regions } = useFilterStore(
  useShallow((state) => ({ metric: state.metric, regions: state.regions }))
);

坑2:Vue的Pinia getter在组合式store中写法不同。

我一开始在组合式store里尝试用getter属性,但发现它不在storeToRefs的返回中。查阅源码发现,组合式store中使用computed来替代getter:

// 错误写法(选项式store的getter在组合式中不支持)
// getters: { doubleCount: (state) => state.count * 2 }

// 正确写法(组合式store中用computed)
const displayLabel = computed(() => `${metric.value} 趋势`);

坑3:React 19的use Hook与Zustand的兼容性。

在React 19中,我尝试在一个Server Component里消费Zustand store——直接报错。原因:Server Component无法使用useSyncExternalStore。解决方案:将消费store的组件标记为'use client',或者用`包裹一个异步client子组件。如果必须在服务端读取初始状态,可以在store初始化时传入defaultState`,但避免在服务端订阅变更。

优化:批量更新与节流控制筛选请求。

由于筛选条件变化会触发多个图表的数据请求,我在store的setTimeRange等action中增加了请求调度逻辑(基于requestIdleCallback + 300ms防抖)。这避免了一次操作导致4-5个API并发请求的峰值问题。

六、效果数据:迁移前后的性能量化对比

数据采集方式:React使用Profiler API(`)记录每次commit的渲染耗时;Vue使用onRenderTrackedonRenderTriggered`监听组件更新。样本量为20次页面加载 + 50次交互操作的平均值。

指标 迁移前 (Context/Props) 迁移后 (Zustand/Pinia) 提升幅度
单次筛选操作触发的组件重渲染数 214个 71个 -67%
组件渲染耗时(平均,React) 18.6ms 3.2ms -83%
Vue组件更新耗时(平均) 22.4ms 4.8ms -79%
交互响应时间 P95 312ms 98ms -69%
首屏JS包体积(gzip) 287KB 301KB +4.9%(store代码开销)

值得说明的是,包体积增加4.9%来自Zustand + Pinia的库代码(约14KB gzip),但这换来了运行时性能的大幅提升。另外,代码量从原来透传props的38处修改点减少为12处,后续新增组件只需要直接调用store,无需关心层级。

七、总结与建议:什么情况不该迁移?(但大多数情况该)

如果你的应用满足以下条件,可以继续用Context/Props:
- 组件树深度小于3层,状态变化频率低于每秒1次
- 状态只在一个子树内部共享,且消费者少于5个
- 项目没有后续迭代需求(但这不可能)

我的建议是:任何超过10个组件共享同一个状态,且该状态更新频率高于用户点击频率的场景,都值得引入轻量级状态库。Zustand和Pinia的学习成本很低,对TypeScript支持极佳,它们带来的性能收益和代码维护性提升是立竿见影的。

最后留一个问题供讨论:当React和Vue共存时,是否有更好的跨框架状态同步方案?目前我们用window事件桥接,但感觉维护成本高,欢迎在评论区交流。