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

我们的后台管理系统(React 18.2 + TypeScript 5.3)运行两年后,用户模块的组件树深度达到了9层。为了把“当前用户信息”从顶层传到第八层的头像组件,我们经历了以下折磨:

// 第七层的组件,需要接收5个与用户相关的props
interface UserAvatarProps {
  user: User;
  permissions: Permission[];
  lastLoginAt: string;
  onUpdateAvatar: (url: string) => void;
  onRefreshUser: () => void;
  // ...还有3个
}

每次产品加一个“显示用户VIP等级”的需求,我就要沿着组件树改7个中间组件的props类型定义。项目里 useContext 用了6个,嵌套的Provider像千层饼。更致命的是,Context一旦值变化,所有消费该Context的组件都会re-render——不管它们是否真的用到了变化的那部分数据。

环境版本
- React 18.2.0 + TypeScript 5.3.3
- Vue 3.4.21 + Pinia 2.1.7
- Zustand 4.4.7
- 构建工具:Vite 5.1.0

二、方案对比:为什么不是Redux,而是Zustand和Pinia

我们内部做了POC(概念验证),测了Redux Toolkit 2.2、Zustand、Jotai 2.6、Pinia。核心指标如下(1000次状态更新,React DevTools Profiler统计):

方案 包体(gzip) 平均渲染时间(ms) 无效渲染次数 学习成本
Redux Toolkit 13.2KB 8.4 320
Zustand 2.8KB 3.1 89 极低
Jotai 3.1KB 3.8 120
Pinia(仅Vue) 2.1KB 2.9 45 极低

最终决定:React项目用Zustand,Vue项目用Pinia。原因很实际:Zustand不需要包裹Provider,可以直接在组件外部读取状态,配合 useShallow 能精准控制订阅粒度;Pinia天然集成Vue 3的响应式系统,且DevTools支持比自研方案好太多。Redux被否是因为我们团队没人喜欢写样板代码。

三、迁移步骤:四个阶段,两周完成

阶段1:新建Store目录,复制状态逻辑(第1-2天)

先不删旧代码,平行创建store。以用户模块为例:

// store/userStore.ts (Zustand)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UserState {
  user: User | null;
  permissions: string[];
  fetchUser: (id: string) => Promise;
  updateAvatar: (url: string) => void;
}

export const useUserStore = create()(
  persist(
    (set) => ({
      user: null,
      permissions: [],
      fetchUser: async (id) => {
        const res = await api.get(`/users/${id}`);
        set({ user: res.data, permissions: res.data.permissions });
      },
      updateAvatar: (url) => 
        set((state) => ({ user: state.user ? { ...state.user, avatar: url } : state.user })),
    }),
    { name: 'user-storage' } // 持久化到localStorage
  )
);

Vue侧同理,用Pinia的 defineStore。注意:这个阶段保持旧代码运行,新store只用于新功能。

阶段2:组件改造——从顶层开始剥洋葱(第3-7天)

策略:先改最底层的消费组件,再往上删props。比如第七层的 UserAvatar 组件:

// 改造前:接收7个props
// 改造后:直接订阅store
import { useUserStore } from '@/store/userStore';
import { useShallow } from 'zustand/react/shallow';

export const UserAvatar = () => {
  // 使用useShallow避免因对象引用变化导致的无效渲染
  const { user, updateAvatar } = useUserStore(
    useShallow((state) => ({ user: state.user, updateAvatar: state.updateAvatar }))
  );

  if (!user) return ;
  return (
     updateAvatar(prompt('新头像URL')!)}
    />
  );
};

关键点:用 useShallow 做浅比较,这样只有 userupdateAvatar 的引用变化时组件才重渲染。实测:这个改动让组件渲染次数从每次点击12次降到1-2次。

阶段3:删除中间层的Props传递(第8-12天)

当所有叶子组件都改为直接消费store后,中间层的props就像死代码一样可以清除了。我们写了一个codemod脚本来自动检测未使用的props,但强烈建议人工审查——脚本漏掉了一个 onRefreshUser 的回调,导致线上出现了一个“刷新后数据不回显”的bug,花了半小时排查。

阶段4:移除Context Provider(第13-14天)

最后一步是把App.tsx里的6个Context.Provider全部移除。这一步收益最大:根组件的首次渲染时间从210ms降到了142ms,因为React不再需要处理嵌套的上下文传递。

四、踩坑与优化:三个真实教训

坑1:Zustand的持久化中间件与异步初始化冲突
persist 默认是同步读取localStorage,如果store初始化时有异步请求,会出现“先用null渲染,再闪一下数据”的问题。解决方案是配置 skipHydration: true,然后在路由守卫里手动 await useUserStore.persist.rehydrate()

坑2:Pinia的storeToRefs解构丢失响应性
这是Vue新手容易踩的:

// 错误!这样解构后count不再是响应式的
const { count, doubleCount } = useCounterStore();
// 正确
import { storeToRefs } from 'pinia';
const { count, doubleCount } = storeToRefs(useCounterStore());

坑3:过度使用单一store导致性能回退
一开始我们把所有全局状态塞进一个Zustand store,结果任何状态更新都导致所有订阅组件重渲染。后来按业务域拆成了 userStoreuiStorenotifStore 三个store,无效渲染直接降了70%。

五、效果数据:重构后的量化对比

迁移完成两周后,我们基于React Profiler和Vue Devtools做了对比(同一台MacBook Pro M1,生产构建):

  • 组件渲染时间:平均值从6.8ms降至4.3ms(↓37%)
  • 无效re-render次数:从每操作12.4次降至4.7次(↓62%)
  • 首屏JS包体:从312KB降至294KB(↓18KB),因为删除了Context Provider的嵌套代码和大量中间层props类型定义
  • 开发效率:新增一个“用户积分”字段,原来需要改动11个文件,现在只需要改store定义 + 2个直接消费组件(↓82%)
  • 崩溃率:没有明显变化(说明迁移过程没有引入严重bug)

六、总结与建议

这次迁移的收益远超预期,尤其是开发效率的提升。但我要泼一盆冷水:如果你的项目组件树小于4层,或者状态只在2-3个兄弟组件间共享,不要用状态管理库,Context + useReducer 完全够用。状态管理库不是银弹,它解决的是“跨层级高频共享状态”的问题,代价是引入额外的依赖和心智负担。

另外,迁移时一定要保持“新旧共存”的过渡期,每改一个组件就立刻跑一遍相关测试用例。我们用了 @playwright/test 1.40.0 做E2E回归,确保用户核心链路(登录→首页→用户中心)没有断。

最后,别忘了把store的ts类型导出给后端同学看——他们经常因为接口字段命名不一致来找我们,现在有了类型定义,沟通成本降低了一半。