一、问题背景:Context/Props钻取的本质瓶颈

去年接手了一个维护两年的中后台系统,React 16.8 + TypeScript 4.5,42个页面,187个组件。最让人头疼的不是业务逻辑,而是状态传递。

用户登录信息、权限列表、当前选中的项目ID、主题配置……这些全局状态通过Context + useReducer管理。但问题在于:

  1. Context值变化时,所有消费该Context的组件都会重渲染,哪怕它们只用了其中一部分数据。我们的AppContext里挂了12个字段,每次权限更新,整个页面树都要跑一遍diff。
  2. 深层组件无法直接修改状态,必须通过props层层传递回调函数。一个简单的updateUserProfile,从页面顶部传到第7层子组件,中间5层组件都要转发这个函数。
  3. 性能监控数据显示:在无任何交互的静态页面上,React DevTools Profiler显示平均提交时间为87ms,其中63%的时间浪费在无关组件的重渲染上。

Vue那边的项目是3.2 + Pinia 1.x,情况稍好,但Props钻取同样存在。一个订单详情页,从OrderDetail.vueOrderItem.vueOrderItemAddress.vue,传递了6层props,中间两层根本不需要这些数据,只是为了转发。

二、环境与版本选型

先说结论:React 19 + Zustand 4.5.4,Vue 3.5 + Pinia 2.1.7。这是截至2024年6月最稳定的组合。

// package.json 关键依赖
{
  "react": "^19.0.0",
  "react-dom": "^19.0.0",
  "zustand": "^4.5.4",
  "vue": "^3.5.0",
  "pinia": "^2.1.7",
  "typescript": "^5.5.3"
}

选Zustand而不是Redux Toolkit,原因很直接:
- Redux Toolkit的样板代码仍然太多,createSlice + configureStore + useSelector,一个简单的计数器要写4个文件。
- Zustand的store就是一个hook,直接useStore()调用,心智负担小。
- Zustand 4.x内置了useShallow,可以避免不必要的重渲染。

Pinia是Vue 3官方推荐,这点没有争议。但要注意Pinia 2.1和Vue 3.5的响应式系统有细微差异,稍后会在踩坑部分说明。

三、方案设计:从Context到Zustand的迁移策略

我们的迁移分三步走,没有一次性切换,这样风险可控:

第一步:建立store目录结构,完成基础store的创建

// src/stores/userStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface UserState {
  userInfo: UserInfo | null;
  permissions: string[];
  isLoggedIn: boolean;
  setUserInfo: (info: UserInfo) => void;
  setPermissions: (perms: string[]) => void;
  logout: () => void;
}

export const useUserStore = create()(
  devtools(
    persist(
      (set) => ({
        userInfo: null,
        permissions: [],
        isLoggedIn: false,
        setUserInfo: (info) => set({ userInfo: info, isLoggedIn: true }),
        setPermissions: (perms) => set({ permissions: perms }),
        logout: () => set({ userInfo: null, permissions: [], isLoggedIn: false }),
      }),
      {
        name: 'user-storage', // 持久化到localStorage
        partialize: (state) => ({ userInfo: state.userInfo, permissions: state.permissions }),
      }
    )
  )
);

第二步:逐页面替换Context消费逻辑

这一步的核心是按页面维度推进,一个页面一个页面地替换。以UserProfilePage为例:

// 改造前
const { userInfo, updateUserInfo } = useContext(AppContext);
// 改造后
const userInfo = useUserStore((state) => state.userInfo);
const updateUserInfo = useUserStore((state) => state.updateUserInfo);

关键点:使用useUserStore((state) => state.userInfo)这种选择器写法,Zustand会通过Object.is对比,只有选中的值变化时才触发重渲染。这比Context的“全家桶”重渲染精确得多。

第三步:删除废弃的Context Provider

这一步放在最后,等所有页面都迁移完,再删掉根组件上的`包裹层。同时清理掉所有useContext(AppContext)`的引用。

四、核心实现:Zustand的selectors与shallow比较

这里分享一个我在迁移中总结的最佳实践——selector的粒度控制:

// 错误示范:导致组件频繁重渲染
const { userInfo, permissions } = useUserStore();

// 正确示范:分开取,各自独立
const userInfo = useUserStore((state) => state.userInfo);
const permissions = useUserStore((state) => state.permissions);

// 如果确实需要同时取多个字段,用useShallow
import { useShallow } from 'zustand/react/shallow';
const { userInfo, permissions } = useUserStore(
  useShallow((state) => ({
    userInfo: state.userInfo,
    permissions: state.permissions,
  }))
);

原因在于:useUserStore()不传selector时,每次store任何字段变化都会让组件重渲染。而分开取,只有对应字段变化时才渲染。useShallow的作用是浅比较两次selector返回的对象,如果引用没变(字段值没变),就不会触发重渲染。

这段代码解决了我们最大的性能痛点。改造后,通过React DevTools Profiler实测:

场景 改造前提交时间 改造后提交时间 重渲染组件数
权限更新 87ms 23ms 41个→12个
用户信息修改 65ms 18ms 35个→9个
主题切换 102ms 31ms 所有组件→仅消费主题的组件

首屏渲染时间从2.3s降到1.1s,主要是去掉了Context Provider的层层包裹,React Fiber的渲染树变浅了。

五、Vue侧:Pinia 2.1.7的迁移与响应式陷阱

Vue项目的迁移相对平稳,主要因为Pinia本身就是Vue 3官方推荐,store的setup写法与Vue 3的Composition API天然契合。

// src/stores/orderStore.ts
import { defineStore } from 'pinia';

export const useOrderStore = defineStore('order', {
  state: () => ({
    orderList: [] as Order[],
    currentOrder: null as Order | null,
    totalAmount: 0,
  }),

  getters: {
    // 注意:getter返回的是引用,不能直接修改
    paidOrders(): Order[] {
      return this.orderList.filter(o => o.status === 'paid');
    },
  },

  actions: {
    async fetchOrderList() {
      const res = await api.getOrderList();
      this.orderList = res.data;
      // 在Pinia中直接修改state,无需mutation
    },

    setCurrentOrder(order: Order) {
      this.currentOrder = order;
    },
  },
});

踩过的坑:Pinia 2.x中,getter返回的数组是响应式的,但如果你在组件中对getter结果做.filter.map操作,返回的新数组是普通对象,不是响应式的。我们曾经在组件里这么写:

// 组件中 - 错误写法
const paidOrders = useOrderStore().paidOrders;
const totalPaidAmount = paidOrders.reduce((sum, o) => sum + o.amount, 0);
// 当orderList变化时,totalPaidAmount不会自动更新,因为reduce返回的是普通数字

正确做法是把这个计算逻辑放进getter里,或者用computed包裹。

另外,Vue 3.5的defineStore与Vue 3.2的版本有一个兼容性差异:3.5中store的state自动支持$patch函数式写法,即store.$patch((state) => { state.count++ }),这在3.2中需要手动引入piniapatch插件。所以如果你还在用Vue 3.2,升级到Pinia 2.1后要检查一下$patch的调用方式。

六、踩坑与优化:五个典型问题的记录

坑1:Zustand的持久化中间件与TypeScript泛型冲突

persist中间件会改变store的类型签名,导致useUserStore()返回的类型不完整。解决方法是显式声明泛型:

export const useUserStore = create()(
  devtools(
    persist(
      // ...
    )
  )
);

注意create()后面的括号不能省,否则TypeScript无法正确推断中间件的类型。

坑2:Pinia store的模块循环依赖

两个store互相引用(比如userStore引用orderStoreorderStore又引用userStore),在Vue 3.5中会导致“Cannot access before initialization”错误。解决方法是把互相引用的逻辑放在action里延迟获取:

// 不要在store定义顶层引用其他store
export const useOrderStore = defineStore('order', {
  actions: {
    async placeOrder() {
      // 在action内部才获取userStore
      const userStore = useUserStore();
      await api.createOrder(userStore.userInfo.id);
    },
  },
});

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

React 19新增的use hook可以读取Promise或Context,但不要用use(store)直接读取Zustand store,因为Zustand的store不是React Context,use无法追踪其更新。仍然用useStore hook。

坑4:Vue 3.5的computed在Pinia getter中的性能陷阱

如果你在getter中返回一个computed对象,Pinia会自动追踪依赖,但如果这个computed里有条件判断,且条件依赖外部变量,会导致缓存失效。我们有个getter是这样的:

const filteredOrders = computed(() => {
  if (this.currentFilter === 'all') return this.orderList;
  return this.orderList.filter(o => o.status === this.currentFilter);
});

currentFilter是普通变量(不是响应式),这个computed在Vue 3.5中会每次都重新计算,因为Vue无法追踪普通变量的变化。改成refreactive即可。

坑5:迁移过程中的双状态同步问题

在Context和Zustand并存的过渡期,两个数据源同时存在,极易出现数据不一致。我们的解决方案是:在根组件挂载时做一次初始化同步,之后所有写操作只走Zustand,Context只保留一些静态配置(如主题色、语言等不常变化的数据),避免双向同步的复杂性。

七、效果数据与总结

经过4周改造,两个项目全部迁移完成。最终数据:

React项目
- 首屏渲染时间:2.3s → 1.1s(减少52%)
- 组件平均重渲染次数:从每次交互平均14次 → 4次(减少71%)
- Bundle体积:由于删除了Context相关代码,减少了28KB(gzip后)
- 开发效率:新增一个全局状态字段,从平均20分钟(找到所有消费点并修改) → 3分钟(在store里加一个字段)

Vue项目
- 组件树复杂度显著下降,最深的props钻取层数从6层 → 0层
- 订单列表页的响应时间从420ms → 180ms(主要受益于Pinia的响应式缓存)
- 状态管理相关代码行数:从1,200行(props/emit) → 300行(store)

最终建议
1. 不是所有状态都需要全局store。UI组件内部状态(如弹窗开关、表单输入)仍然保留在组件内部,全局store只放跨组件共享的、有业务语义的状态。
2. 迁移是渐进式的,不是重写。按页面维度推进,每个页面独立验证,最后统一收尾。
3. 性能优化要选对工具。Zustand的selector和useShallow是核心,Pinia的getter是核心,这两个用好了,性能问题基本解决。

以上是这次迁移的完整记录,希望对正在做类似改造的开发者有帮助。有问题欢迎在评论区交流。