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

先交代项目背景:一个中后台管理系统,React和Vue两个版本并行维护。React端用的是React 19.0.0 + TypeScript 5.6,Vue端是Vue 3.5.12 + script setup语法。业务上有一个共同痛点:全局筛选条件(日期范围、关键词、部门树)需要下发给至少5个层级、20+个业务组件

最初架构很“正统”:React用createContext+useReducer,Vue用provide/inject+reactive。但随着业务膨胀,问题逐渐显现:

  1. React端:任何一个筛选条件的变更,都会导致所有useContext的消费组件重渲染。用React DevTools Profiler录制,一次条件变更触发27个组件重渲染,耗时120ms。交互卡顿感明显。
  2. Vue端:虽然provide/inject配合computed有一定优化,但组件层级深时,依赖追踪仍然混乱。且inject的响应式对象在跨模块共享时,类型推导(TS)体验极差。

二、环境与版本:选型前的硬性约束

  • React端:React 19.0.0、Zustand 5.0.2、React DevTools 5.x
  • Vue端:Vue 3.5.12、Pinia 3.0.1、Vue DevTools 7.x
  • 构建工具:Vite 6.0(React插件版本4.3,Vue插件版本5.2)
  • 性能基准设备:MacBook Pro M1 Pro,Chrome 131,CPU 4x降速模拟

选型理由:Zustand 5和Pinia 3均支持细粒度的状态订阅(React端利用useSyncExternalStore,Vue端利用effectScope),且体积都控制在3KB左右(gzip后)。

三、方案设计:两套框架的迁移对照

3.1 React:Context → Zustand

设计原则:将状态拆分为“基础筛选条件”与“派生数据”两部分。基础条件存Zustand,派生数据用useMemoselectors计算,避免重复存储。

// store/filterStore.ts - Zustand 5 完整实现
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface FilterState {
  dateRange: [Date, Date] | null;
  keyword: string;
  deptId: string;
  setDateRange: (range: [Date, Date]) => void;
  setKeyword: (kw: string) => void;
  setDeptId: (id: string) => void;
  reset: () => void;
}

export const useFilterStore = create()(
  subscribeWithSelector((set) => ({
    dateRange: null,
    keyword: '',
    deptId: '',
    setDateRange: (dateRange) => set({ dateRange }),
    setKeyword: (keyword) => set({ keyword }),
    setDeptId: (deptId) => set({ deptId }),
    reset: () => set({ dateRange: null, keyword: '', deptId: '' }),
  }))
);

// 组件中细粒度订阅:仅当keyword变化时重渲染
const keyword = useFilterStore((state) => state.keyword);
// 复杂派生数据用selector + shallow比较
const filters = useFilterStore(
  (state) => ({ dateRange: state.dateRange, deptId: state.deptId }),
  (prev, next) => prev.dateRange === next.dateRange && prev.deptId === next.deptId
);

关键点:subscribeWithSelector中间件让我们可以在selector里做浅比较,避免对象解构导致的无限重渲染。

3.2 Vue:provide/inject → Pinia

设计原则:利用Pinia的setup store语法,将响应式状态与业务逻辑聚合。

// stores/filter.ts - Pinia 3 完整实现
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useFilterStore = defineStore('filter', () => {
  // 基础状态
  const dateRange = ref(null);
  const keyword = ref('');
  const deptId = ref('');

  // 派生状态:去重、排序后的部门列表(模拟异步获取)
  const filteredDepts = computed(() => {
    // 实际业务中这里是基于deptId的树遍历
    return [];
  });

  // 业务动作
  function setDateRange(range: [Date, Date]) {
    dateRange.value = range;
  }
  function setKeyword(kw: string) {
    keyword.value = kw;
  }
  function reset() {
    dateRange.value = null;
    keyword.value = '';
    deptId.value = '';
  }

  return { dateRange, keyword, deptId, filteredDepts, setDateRange, setKeyword, reset };
});

Vue组件内使用:

import { storeToRefs } from 'pinia';
const filterStore = useFilterStore();
// 关键:storeToRefs保持响应性,但避免解构丢失响应式
const { keyword, dateRange } = storeToRefs(filterStore);

四、核心实现:迁移中的关键改动点

4.1 替换Provider嵌套地狱

React端:删除了App.tsx中包裹的三层Provider(AuthProvider、ThemeProvider、FilterProvider),替换为:

// App.tsx 改动前后对比
// 之前:
// 之后:直接渲染,状态在模块顶层初始化

Vue端:在main.ts中移除provide('filter', filterState),改为:

// main.ts
import { createPinia } from 'pinia';
const pinia = createPinia();
app.use(pinia);

4.2 跨组件通信改造

之前React用useContext的组件需要包裹memo+手动比较props,现在只需:

// 之前:const { state, dispatch } = useContext(FilterContext);
// 每次dispatch都会导致所有消费者重渲染
// 现在:selector机制自动跳过无关更新
const deptId = useFilterStore((state) => state.deptId);

4.3 异步状态处理的差异

一个关键坑:Context方案中,我们习惯在useEffect里dispatch一个FETCH_SUCCESS action。迁移到Zustand后,直接调用set即可,但要注意selector的稳定性

// 错误示范:selector返回新对象,导致无限循环
const { deptId } = useFilterStore((state) => ({ deptId: state.deptId }));

// 正确示范:返回基础类型,或使用useShallow
import { useShallow } from 'zustand/react/shallow';
const { deptId, keyword } = useFilterStore(
  useShallow((state) => ({ deptId: state.deptId, keyword: state.keyword }))
);

五、踩坑与优化:那些文档没写的细节

  1. React 19的useHook与Zustand的兼容性:React 19的use()可以读取Context,但Zustand的useStore在并发特性下需要确保useSyncExternalStoregetSnapshot是缓存稳定的。我们用createJSONStorage持久化时,发现快照函数每次返回新引用,导致水合告警。解决方案:手动缓存序列化结果。

  2. Vue的storeToRefs与解构陷阱storeToRefs只能解构顶层属性,嵌套对象需要继续.value访问。我们有一个filters对象存储多个筛选条件,解构后修改filters.value.keyword不会触发更新。踩坑后:将嵌套对象拆分为独立ref

  3. 性能监控的对比方法:我们使用Chrome Performance面板录制相同操作(切换部门树节点+输入关键词)。迁移前React Profile显示红色警告,迁移后全部绿色。Vue端用@vue/devtools的组件渲染时间,从平均18ms降至6ms。

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

指标 React Context React Zustand Vue provide/inject Vue Pinia
一次筛选变更触发重渲染组件数 27 4 15 3
交互响应时间(ms) 87 23 64 18
首次内容绘制FCP(ms) 2140 1870 2050 1790
包体积增量(gzip) - +2.8KB - +3.1KB
TypeScript类型推导 手动声明 自动推导+类型安全 any泛滥 完美推导

额外收益:Zustand的devtools中间件和Pinia的persist插件让调试和持久化变得开箱即用。特别是Pinia的$subscribe可以批量监听状态变化,替代了之前手动在多个组件里写watch

七、总结:什么情况下值得迁移

如果你的项目满足以下任一条件,我强烈建议迁移:
- 状态被5个以上组件消费,且更新频率高(如实时筛选、拖拽、表单联动)
- 出现了“Context地狱”(超过2层嵌套)
- 团队中TS使用率高,需要完整的类型提示

反之,如果只有1-2层传递且更新不频繁,Context/provide完全够用,不必为了“先进”而增加依赖。状态管理库不是银弹,它解决的是“细粒度订阅”问题,而不是“架构设计”问题

最后提醒:React 19的useHook虽然能读Context,但依然无法解决重渲染问题;Vue 3.5的useTemplateRef+defineModel组合在局部状态时很香,但全局状态库的生态仍是Pinia。选型时建议先看看团队熟悉度,再考虑技术指标。