一、问题背景:当Props钻取变成“回调地狱”

我们团队同时维护两个核心项目:一个是基于React 18.3.1的管理后台,另一个是采用Vue 3.4.21的移动端H5。随着业务迭代,两个项目都出现了同样的症状——状态层层传递,改一个字段需要顺藤摸瓜翻6-7个组件文件。

React端最典型的问题是为了避免Context频繁更新,我们不得不使用React.memouseCallback进行手动优化。但即便这样,当用户切换一个下拉框时,通过React DevTools Profiler能看到有23个组件发生重渲染,其中11个是无关组件。

Vue端的情况好一些,因为Vue的依赖收集机制避免了大部分无用渲染,但Props钻取带来的维护成本依然很高。特别是当我们需要在深层子组件触发一个修改根组件状态的事件时,emit链的长度经常让人崩溃。

性能数据基线(基于Lighthouse 10.0模拟Moto G Power):
- React端:首次内容绘制(FCP) 2.8s,交互响应时间(Input Delay) 320ms
- Vue端:组件树节点数426个,单次状态变更触发的组件更新数平均为37个

二、环境与版本:迁移前的技术栈快照

在动手前,我们先锁定了现有依赖版本,避免升级过程中发生不可控的连锁反应。

# React项目 (package.json 关键字段)
"react": "18.3.1",
"react-dom": "18.3.1",
"zustand": "^4.5.5"  // 目标版本 5.0.3
"@reduxjs/toolkit": "^2.2.7"  // 备选方案,最终弃用

# Vue项目
"vue": "^3.4.21",
"pinia": "^2.1.7"  // 目标版本 3.0.2
"vuex": "^4.1.0"   // 旧状态管理,考虑迁移

选择Zustand而非Redux Toolkit的原因很现实:Redux的样板代码太多,对于一个已经成熟的项目来说,重写所有action和reducer的成本太高。而Zustand的create方法几乎零成本接入,且不需要Provider包裹。

对于Vue端,Pinia 3.x版本已经全面支持Vue 3.5的useStore响应式API,且天然支持Composition API,这对我们现有的setup语法是降维打击。

三、方案设计:渐进式替换,绝不推倒重来

我们制定了三步走策略,确保每步都能独立回滚:

第一步:识别“热点状态”。通过代码扫描(使用react-codemodvue-ast工具),找出被超过5层组件传递的props。最终锁定React端有47个,Vue端有31个。

第二步:建立隔离层。不为整个项目引入状态库,而是先为一个业务模块(比如“用户权限管理”)创建独立的store。React用Zustand的create,Vue用Pinia的defineStore。原有Props继续保留,但新代码优先消费store。

第三步:性能对比验证。每迁移一个模块,就对比一次FCP和组件重渲染次数。只有确认收益大于成本,才继续下一个模块。

核心设计原则是store只放跨组件状态,组件内部状态继续用useStateref。这避免了把所有状态都塞进store导致的新问题。

四、核心实现:Zustand与Pinia的迁移代码实录

4.1 React端:从Context+useReducer到Zustand

迁移前,我们使用的是这样一个Context:

// 迁移前:React Context + useReducer
const AppContext = createContext(null!);

export function AppProvider({children}: {children: React.ReactNode}) {
  const [state, dispatch] = useReducer(appReducer, initialState);
  const value = useMemo(() => ({state, dispatch}), [state]);
  return {children};
}

// 使用:useContext(AppContext) 拿到state和dispatch
// 问题:任何state变化,所有消费该Context的组件都会重渲染

迁移后,我们为“用户权限”模块单独建一个store:

// 迁移后:Zustand 5.0.3
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface PermissionState {
  userRoles: string[];
  isAdmin: boolean;
  setRoles: (roles: string[]) => void;
  toggleAdmin: () => void;
}

export const usePermissionStore = create()(
  persist(
    (set) => ({
      userRoles: [],
      isAdmin: false,
      setRoles: (roles) => set({ userRoles: roles, isAdmin: roles.includes('admin') }),
      toggleAdmin: () => set((s) => ({ isAdmin: !s.isAdmin })),
    }),
    {
      name: 'permission-storage', // 持久化到localStorage
      partialize: (state) => ({ userRoles: state.userRoles }), // 只持久化必要字段
    }
  )
);

// 在组件中使用,可以精准订阅,避免多余渲染
const isAdmin = usePermissionStore((s) => s.isAdmin);
const setRoles = usePermissionStore((s) => s.setRoles);

关键点是usePermissionStore的selector订阅。当isAdmin变化时,只有订阅了isAdmin的组件会重渲染,而订阅setRoles的组件不会。这比Context的全量更新高效得多。

4.2 Vue端:从Props+emit到Pinia

迁移前的Vue组件,我们经常写这样的代码:

const props = defineProps();
const emit = defineEmits();
// 经过三层传递,这里才能修改根组件的数据

使用Pinia后,我们直接在组件内操作store:

// 迁移后:Pinia 3.0.2
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useUserStore = defineStore('user', () => {
  // 使用setup语法,更贴合Composition API
  const userInfo = ref({ name: '', id: 0 });
  const isLoggedIn = computed(() => userInfo.value.id !== 0);

  function updateUserInfo(newInfo: Partial) {
    userInfo.value = { ...userInfo.value, ...newInfo };
  }

  async function fetchUserInfo(userId: number) {
    const response = await fetch(`/api/user/${userId}`);
    userInfo.value = await response.json();
  }

  return { userInfo, isLoggedIn, updateUserInfo, fetchUserInfo };
});

// 在任意子组件中
const userStore = useUserStore();
userStore.updateUserInfo({ name: '新名字' }); // 直接修改,无需emit

Pinia 3.x的setup store与Vue 3.5的reactive深度集成,store的$subscribe可以监听状态变化,配合patch函数可以批量更新,这在处理复杂表单时非常好用。

五、踩坑与优化:我们替你们趟过的雷

坑1:Zustand的selector返回值引用不稳定导致死循环
我们曾写const user = usePermissionStore((s) => s.user),但user对象每次都会新建引用,导致组件无限重渲染。解决办法是使用useShallow

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

const { userRoles, isAdmin } = usePermissionStore(
  useShallow((s) => ({ userRoles: s.userRoles, isAdmin: s.isAdmin }))
);

坑2:Pinia的store在组件外使用需要提前注册
在路由守卫或工具函数中使用store时,必须确保Pinia实例已激活。我们最初在router.beforeEach中直接调用useUserStore()报错,解决方法是:

// 在main.ts中导出pinia实例
export const pinia = createPinia();

// 在路由守卫中使用
import { pinia } from '@/main';
import { useUserStore } from '@/stores/user';

router.beforeEach(() => {
  const userStore = useUserStore(pinia); // 显式传入pinia实例
});

优化1:React端配合useTransition处理非紧急更新
对于权限切换这种不涉及UI阻塞的操作,我们用useTransition包裹,避免store更新阻塞输入框输入。

优化2:Vue端使用shallowRef存储大对象
对于用户列表这种大数组,使用shallowRef避免深度响应式带来的性能开销:

const userList = shallowRef([]);
// 更新时整体替换
userList.value = newUserList;

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

经过6周的逐步迁移(React端4周,Vue端2周),我们收集了基于同一测试设备(MacBook Pro M1 + Chrome 126)的对比数据:

指标 React迁移前 React迁移后 Vue迁移前 Vue迁移后
首屏FCP (模拟4G) 2.8s 1.9s 1.6s 1.3s
交互响应时间 (Input Delay) 320ms 105ms 180ms 90ms
单次状态变更重渲染组件数 23个 3个 37个 9个
包体积 (gzip) 312KB 328KB (+16KB) 198KB 210KB (+12KB)

包体积增加是引入新库的必然代价,但换来的是交互响应时间降低67%,这个交换完全值得。另外,代码删除量也说明问题:React端删除了约1200行Context相关代码,Vue端删除了约800行emit链代码。

七、总结:迁移不是技术秀,而是成本收益比

如果你问我现在是否后悔做了这次迁移?答案明显是否定的。但我要强调,不是所有项目都需要状态管理库。如果你的组件树深度不超过3层,Context和Props完全够用。当你发现以下情况时,才值得动手:

  1. 重渲染次数失控:DevTools显示单次交互触发超过10个无关组件更新。
  2. 状态逻辑分散:同一个业务状态被分散在多个组件中,难以追踪。
  3. 团队协作混乱:新成员需要花一周时间才能搞懂状态流向。

最佳实践建议
- React项目优先考虑Zustand(轻量)或Jotai(原子化),Redux Toolkit适合超大型团队但学习曲线陡峭。
- Vue项目无脑选择Pinia,它已经成为Vue 3的官方推荐。
- 迁移时一定要用增量式,先挑一个业务模块试点,用数据说服团队,而不是一次性推倒重写。

最后,状态管理永远是工具,不是目的。你的核心目标应该是让用户感受到“快”,让团队感受到“清晰”。希望这篇文章能帮到你,如果你在迁移中也遇到了奇葩问题,欢迎在评论区交流。