一、问题背景:当Props钻透成为技术债

我们的项目是一个中后台OMS系统,核心模块是订单管理。早期为了快速上线,状态管理直接用了React Context + useReducer,Vue端则是provide/inject。随着业务迭代,问题逐渐失控:

  1. Props钻透(Prop Drilling):订单详情页需要传递userInfopermissionMaporderFilters等数据,最深层组件距离顶层竟有8层嵌套。每次加需求都要在中间组件补...props,代码阅读性极差。
  2. Context重渲染风暴:Context value一旦变化,所有Consumer组件都会重新渲染。我们的GlobalConfigContext包含了主题、时区、货币符号,结果切换主题时,整个订单表格(300+行)卡顿1.2秒。
  3. Vue端provide/inject的响应式丢失:当我们在provide中直接传递一个reactive对象时,如果子组件解构赋值,就会失去响应式链接。这个问题在排查时极其隐蔽。

二、环境与版本:技术选型的现实约束

迁移前基线:
- React 18.2 + TypeScript 5.3 + Ant Design 5.12
- Vue 3.4 + Pinia 2.1(旧项目)+ Element Plus 2.6
- 构建工具:Vite 5.1(React端)、Webpack 5.9(Vue端,历史包袱)

迁移目标版本:
- React端:Zustand v5.0.2(选择它而不是Redux Toolkit,因为Zustand v5彻底拥抱了React 19的use Hook,且无Provider嵌套)
- Vue端:Pinia v3.0.1(Vue 3.5对reactive的Proxy优化,让Pinia的性能更上一层楼)

关键决策:React端坚决不引入Redux,因为团队没人愿意写样板代码;Vue端保留Pinia,因为Vue官方推荐且我们已有2.x的使用经验。

三、方案设计:从“隐式依赖”到“显式消费”

1. Zustand v5的Store设计
核心思路:将原来的OrderContext(包含订单列表、筛选条件、分页信息)拆分为3个独立的Store,利用Zustand的createcreateSelectors避免多余渲染。

// stores/orderStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface OrderState {
  orders: Order[];
  filters: FilterParams;
  pageInfo: { page: number; pageSize: number };
  setFilters: (filters: Partial) => void;
  fetchOrders: () => Promise;
}

export const useOrderStore = create()(
  devtools(
    (set) => ({
      orders: [],
      filters: { status: 'all', keyword: '' },
      pageInfo: { page: 1, pageSize: 20 },
      setFilters: (filters) => set((state) => ({ filters: { ...state.filters, ...filters } })),
      fetchOrders: async () => {
        const res = await api.getOrders();
        set({ orders: res.data });
      },
    }),
    { name: 'OrderStore' } // Redux DevTools调试关键
  )
);

2. Pinia v3的Store设计
Vue端将原来的useGlobalConfig提供者替换为组合式Store,解决响应式丢失问题。

// stores/globalConfig.ts (Vue端)
import { defineStore } from 'pinia';

export const useGlobalConfigStore = defineStore('globalConfig', {
  state: () => ({
    theme: 'light',
    currency: 'CNY',
    timezone: 'Asia/Shanghai',
  }),
  getters: {
    themeClass: (state) => `theme-${state.theme}`,
  },
  actions: {
    updateTheme(theme: 'light' | 'dark') {
      this.theme = theme;
      // 这里可以配合持久化插件自动同步localStorage
    },
  },
});

四、核心实现:迁移步骤与代码级别的破坏性变更

迁移五步法
1. 依赖注入替换:全局搜索useContext(OrderContext),替换为useOrderStore()。在Vue端,搜索inject('globalConfig'),替换为useGlobalConfigStore()
2. Provider移除:删除React入口文件的`包裹层,减少一层DOM嵌套。 3. **选择器优化**:在React组件中使用useOrderStore((state) => state.orders),避免对象解构导致的重渲染(Zustand v5会自动进行浅比较)。 4. **Actions重构**:将原来写在组件里的dispatch({ type: 'SET_FILTER', payload })逻辑收敛到Store的action中。 5. **持久化迁移**:Vue端利用Pinia的persist插件替代原来的watch+localStorage`手动同步。

核心代码对比(React端)

// 迁移前 - 繁琐的Context消费
const { state, dispatch } = useContext(OrderContext);
const handleFilterChange = (filter: FilterParams) => {
  dispatch({ type: 'UPDATE_FILTER', payload: filter });
};

// 迁移后 - 直接调用Store方法
const filters = useOrderStore((state) => state.filters);
const setFilters = useOrderStore((state) => state.setFilters);
const handleFilterChange = (filter: FilterParams) => {
  setFilters(filter); // 类型安全,无字符串魔数
};

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

坑1:Zustand v5的create泛型必须显式传入
如果你用create()(...),一旦忘记传泛型,TypeScript会推断出unknown类型。这个问题在迁移时折磨了我们一个下午,最终发现是v5对TS严格模式的加强。

坑2:Pinia Store的解构响应式
在组件中使用const { theme, updateTheme } = useGlobalConfigStore()时,theme会失去响应式。必须在解构时用storeToRefs包裹:

import { storeToRefs } from 'pinia';
const store = useGlobalConfigStore();
const { theme } = storeToRefs(store); // 保持响应式

优化策略
- React端使用useShallow来比较复杂对象(如filters),避免深度比较的开销。
- Vue端在Pinia Store的getter中避免返回新对象,否则会触发无限渲染循环。
- 引入zustand/middleware/immer中间件,让不可变更新写起来像可变更新,代码量减少20%。

六、效果数据与性能对比

我们使用React Profiler和Vue Devtools的Performance Tab,在生产构建下(关闭HMR)跑了20次订单列表页的交互测试。

指标 Context/Props模式 Zustand/Pinia模式 提升
首屏渲染时间(React) 850ms 620ms 27%提升
筛选条件变更重渲染(React) 1.2s(全表渲染) 340ms(仅列表更新) 71.6%提升
主题切换耗时(Vue) 480ms 120ms 75%提升
代码量(订单模块) 3,200行 2,080行 35%减少

性能变化原因分析
1. 精准订阅:Zustand的selector让每个组件只订阅它需要的slice,而Context是广播式通知。
2. 内存优化:Pinia v3基于Vue 3.5的proxyRefs优化,store实例的内存占用减少约15%。
3. DevTools友好:Zustand的devtools中间件和Pinia的vue-devtools集成,调试时间缩短一半。

七、总结与建议

如果项目还在早期,直接用Zustand/Pinia;如果像我们一样已经积累了Context债务,不要犹豫,立刻迁移——技术债的利息会随着组件数量指数增长。

最后三个忠告
1. 迁移时一次性完成,不要混合使用Context和Store,否则调试会精神分裂。
2. 务必启用ESLint的react-hooks/exhaustive-deps,在移除Context后漏掉依赖是重渲染的头号杀手。
3. 对比React和Vue的状态管理思路:Zustand更像“外部库”,Pinia则是“框架原生”,选择时要考虑团队的技术倾向。

迁移不是炫技,而是为了给业务迭代留出呼吸感。当你的同事不再为一个状态传递找三层组件时,你会觉得这周的加班值了。