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

我们的主项目是一个基于React 19 + Vite 7的管理系统,另一个子应用是Vue 3.5 + Webpack 5。早期为了快速上线,状态管理全部依赖Props回调+Context。当业务模块增加到30+后,问题集中爆发:

  • 地狱透传:用户权限状态从顶层传到第5层子组件,中间4层组件不得不声明并转发该props,纯属“搬砖工”。
  • 无效渲染:Context的value一旦变化,所有消费该Context的组件无论是否用到具体字段都会重渲染。我们用React DevTools Profiler统计,一次权限变更触发217个组件重渲染,其中约60%的组件与权限无关。
  • 可维护性差:Vue子应用中,隔代组件通信靠provide/inject,但注入的属性无法被Vue的响应式系统高效追踪,导致排查问题时需要像侦探一样顺着组件树找源头。

业务场景:一个复杂的“订单工作台”页面,包含用户信息、权限按钮、筛选条件、列表数据、详情抽屉五个联动模块。任意一个模块的状态变化,都会引发连锁渲染。

二、环境与版本:明确技术基线

在动手前,先锁定依赖版本,避免迁移过程中出现兼容性惊喜:

{
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "zustand": "^5.0.2",
    "vue": "^3.5.13",
    "pinia": "^3.0.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.4",
    "vite": "^7.0.0",
    "typescript": "5.6.3"
  }
}

决策点:为什么React不用Redux Toolkit?因为我们的项目状态是“局部共享”而非“全局单一store”,Redux的样板代码和不可变更新逻辑在这种场景下性价比低。Zustand的create函数和selector机制恰好能精准控制订阅粒度。Vue侧则直接无脑选Pinia,因为它天然适配Vue 3的Composition API。

三、方案设计:从“血缘关系”到“数据中心”

核心设计原则是按业务模块拆分store,而非全局一个store。我们设计了三个核心store:

  1. useAuthStore:用户信息、权限点、登录状态。
  2. useOrderStore:订单列表、筛选条件、分页信息。
  3. useUiStore:抽屉开关、全局Loading状态。

React侧改造策略

  • 替换Context:删除AuthContext.ProvideruseAuthContext,改用useAuthStore
  • 替换Props钻取:列表页的筛选条件不再通过`传递,而是组件内部直接const filters = useOrderStore(s => s.filters)`。

Vue侧改造策略

  • provide/inject替换为Pinia的defineStore
  • 组件内通过storeToRefs解构,确保响应性不丢失。

关键设计:Zustand的Store必须使用createset函数更新状态,并且禁止在组件外部直接修改store.state,否则会绕过React的批处理机制。

四、核心实现:代码级迁移对比

4.1 React:从Context到Zustand

迁移前(Context实现)

// AuthContext.tsx
const AuthContext = createContext boolean }>(null!);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const hasPerm = useCallback((perm: string) => user?.perms?.includes(perm), [user]);
  // ...省略login逻辑
  return {children};
};

// 深层次组件使用
const { hasPerm } = useContext(AuthContext);
if (!hasPerm('order:delete')) return 删除;

迁移后(Zustand实现)

// stores/authStore.ts
import { create } from 'zustand';

interface AuthState {
  user: User | null;
  hasPerm: (perm: string) => boolean;
  setUser: (user: User | null) => void;
}

export const useAuthStore = create((set, get) => ({
  user: null,
  hasPerm: (perm) => get().user?.perms?.includes(perm) ?? false,
  setUser: (user) => set({ user }),
}));

// 深层组件中使用,只订阅特定字段
const user = useAuthStore(s => s.user);
const hasPerm = useAuthStore(s => s.hasPerm);
if (!hasPerm('order:delete')) return 删除;

注意点useAuthStore(s => s.hasPerm) 返回的是函数引用,只要store不重建,该引用是稳定的,不会导致组件无限重渲染。这是Context无法做到的——Context的value每次都是新对象引用。

4.2 Vue:从Provide到Pinia

迁移前

import { provide, ref } from 'vue';
const orderFilters = ref({ status: 'pending' });
provide('orderFilters', orderFilters);




import { inject } from 'vue';
const filters = inject('orderFilters');

迁移后

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

export const useOrderStore = defineStore('order', {
  state: () => ({
    filters: { status: 'pending', keyword: '' },
    list: [] as OrderItem[],
  }),
  actions: {
    async fetchOrders() {
      // 模拟接口调用
      const res = await api.getOrders(this.filters);
      this.list = res.data;
    },
  },
});
import { storeToRefs } from 'pinia';
import { useOrderStore } from '@/stores/order';

const orderStore = useOrderStore();
const { filters } = storeToRefs(orderStore);

关键踩坑:在Pinia中,如果直接解构const { filters } = useOrderStore()会丢失响应性。必须用storeToRefs。而Zustand中则没有这个问题,因为Zustand本身就是基于不可变更新的。

五、踩坑与优化:两个框架的“性格”差异

React(Zustand)

  • 坑1:Selector返回新对象。如果写useOrderStore(s => ({ filters: s.filters, list: s.list })),每次都会返回新对象,导致组件无限循环。解决:使用useShallow或拆分成两个selector。
  • 坑2:异步action中的状态覆盖。在fetchOrders中,如果连续触发两次请求,后返回的响应可能覆盖先返回的响应。解决:引入requestId标记,在action中判断当前请求是否最新。
  • 优化:结合React.memo,对于列表项组件,确保传入的props是稳定的,此时Zustand的精确订阅优势才能完全发挥。

Vue(Pinia)

  • 坑1:$patch与直接赋值的区别。在actions中直接修改this.filters.status = 'done'是可行的,但若需要同时修改多个属性,建议用this.$patch({ filters: { ...this.filters, status: 'done' } }),以保持原子性。
  • 坑2:Store的模块循环引用。如果authStore引用了orderStore,而orderStore又引用了authStore,在初始化时会死循环。解决:在setup函数中延迟引用,或者在actions内部动态获取另一个store。

性能调优参数

  • React端:开启unstable_batchedUpdates的自动批处理(React 18+默认开启),无需额外配置。
  • Vue端:在Vite中配置optimizeDeps.include: ['pinia'],加快依赖预构建。

六、效果数据:用数字说话

迁移完成后的性能对比(基于Chrome Performance面板,100次采样取平均值):

指标 迁移前(Context/Props) 迁移后(Zustand/Pinia) 提升幅度
首屏交互响应时间 780ms 120ms 84.6%
权限变更触发重渲染组件数 217个 38个 82.5%
内存占用(DevTools Heap) 180MB 97MB 46.1%
代码中props转发层数(最深) 5层 0层 -
Vue子应用组件重渲染次数 42次/秒 16次/秒 61.9%

额外收益:由于去除了中间层的props转发,我们删除了约2300行无效的“搬运”代码。Git提交记录显示,重构后的包体积减少了约35KB(gzip前)。

七、总结与建议

  1. 不要盲目迁移。如果你的组件树深度小于3层,或者状态变更频率极低,Context/Props完全够用。我们是在性能瓶颈和代码维护成本达到“痛感阈值”后才动手的。
  2. 方案选型要“看菜下饭”。React项目优先考虑Zustand,它的API设计更符合React的思维模式;Vue项目无脑选Pinia,它是官方推荐且与Devtools集成极好。
  3. 迁移要“渐进式”。不要试图一夜之间删除所有Context。我们采用了“新代码用store,旧代码逐步改”的策略,每个迭代迁移一个模块,持续了三个版本才完全清理完。
  4. 性能监控要常态化。迁移完成后,我们在CI流程中加入了web-vitals监控,防止回归。

最后,状态管理没有银弹。Zustand和Pinia只是工具,真正重要的是你对组件职责边界的划分能力。希望这篇博客能给你在技术选型和迁移执行上提供一些参考。

(完)