一、问题背景:当状态流变成意大利面条

今年接手一个维护了3年的中后台项目,技术栈是“双轨制”:核心模块用React 19(搭配TypeScript 5.5),而遗留的报表模块仍跑在Vue 3.5上(通过micro-frontend架构共存)。随着业务迭代到230+组件,状态管理彻底失控:

  • Props钻透:最深的组件链达到12层,中间8个组件只做透传
  • Context泛滥:React侧创建了9个Context,但组件刷新时经常触发整棵子树重渲染
  • Vue的provide/inject:虽然解决了钻透,但响应式追踪失效,修改对象属性不触发更新

某次线上事故:用户切换筛选条件后,表格区域因Context值变化导致300+组件重渲染,主线程阻塞1.8秒。这成为迁移的导火索。

二、环境与版本:迁移前的技术基线

// 迁移前package.json关键依赖
{
  "react": "19.0.0",
  "react-dom": "19.0.0",
  "vue": "3.5.13",
  "pinia": "2.2.6",      // 已装但未启用
  "zustand": "5.0.2",    // 已装但未启用
  "typescript": "5.5.4",
  "vite": "6.0.5"
}

方案选型争议:团队内部对“统一用Redux Toolkit还是分而治之”吵了两周。最终决定:
- React侧:Zustand(理由:无Provider包裹、选择器粒度细、React 19的use()钩子兼容)
- Vue侧:Pinia(理由:官方推荐、DevTools集成好、TS支持完善)

三、方案设计:渐进式迁移与双框架兼容层

3.1 迁移策略:按业务域切分,禁止大爆炸

将状态按业务域拆成5个Store
- useAuthStore(认证):替代原AuthContext
- useFilterStore(筛选条件):替代原12层Props链
- useTableStore(表格状态):替代原TableContext
- useReportStore(报表配置):替代Vue侧的provide/inject
- useThemeStore(主题):全局常量,几乎不变

关键设计:建立一个兼容层StateBridge.ts,在迁移期间同时暴露旧Props和新Store,让组件逐批切换。

// StateBridge.ts - 兼容层核心逻辑
import { create } from 'zustand';
import { useContext } from 'react';
import { LegacyFilterContext } from './legacy/contexts';

// 新旧状态同步器
interface FilterState {
  keyword: string;
  status: 'active' | 'disabled';
  setKeyword: (val: string) => void;
}

export const useFilterStore = create((set) => ({
  keyword: '',
  status: 'active',
  setKeyword: (keyword) => set({ keyword }),
}));

// 迁移钩子:优先使用Store,若Store未初始化则降级到Context
export function useFilterBridge() {
  const storeKeyword = useFilterStore((s) => s.keyword);
  const legacyCtx = useContext(LegacyFilterContext);

  // 旧Context的值变化时同步到Store
  useEffect(() => {
    if (legacyCtx?.keyword !== storeKeyword) {
      useFilterStore.setState({ keyword: legacyCtx.keyword });
    }
  }, [legacyCtx?.keyword]);

  return {
    keyword: storeKeyword || legacyCtx?.keyword,
    setKeyword: useFilterStore.getState().setKeyword,
  };
}

3.2 React 19的use()钩子带来的新写法

React 19允许在组件顶层直接use(store),替代useSelector。但实测发现Zustand 5.0.2的useStore与React 19的use()有兼容问题——use()要求传入Promise或Context,不能直接传store。最终采用官方推荐的useShallow避免不必要的重渲染。

四、核心实现:Store重构与组件改造

4.1 React侧:从多Context合并到单一Store

改造前(Context地狱)

// 旧代码:嵌套Provider导致组件难以测试

改造后(Zustand Store)

// 新代码:直接在组件内引用
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore, useFilterStore, useTableStore } from '../stores';

function FilterBar() {
  // 使用useShallow确保数组/对象引用变化时才重渲染
  const { keyword, status, setKeyword } = useFilterStore(
    useShallow((s) => ({ keyword: s.keyword, status: s.status, setKeyword: s.setKeyword }))
  );

  return (
     setKeyword(e.target.value)}
      placeholder="筛选关键词,输入防抖500ms"
    />
  );
}

// 表格组件:只订阅自己关心的切片
function DataTable() {
  const rows = useTableStore((s) => s.filteredRows);
  const total = useTableStore((s) => s.totalCount);
  // 不再透传props,直接渲染
  return ;
}

关键优化:将原FilterContext中的防抖逻辑移入Store的setKeyword方法中,避免每个组件重复实现——这直接消除了80%的无效渲染。

4.2 Vue侧:从provide/inject到Pinia

改造前问题:provide一个包含reactive对象,但子组件解构后失去响应性。

import { provide, reactive } from 'vue';
const state = reactive({ filter: { keyword: '', page: 1 } });
provide('reportConfig', state);




import { inject } from 'vue';
const config = inject('reportConfig');
// 直接修改config.filter不会触发任何组件更新!

改造后(Pinia Store)

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

export const useReportStore = defineStore('report', {
  state: () => ({
    filters: { keyword: '', dateRange: [] as [Date, Date] | [] },
    currentPage: 1,
    pageSize: 20,
  }),
  getters: {
    totalPages: (state) => Math.ceil(state.totalCount / state.pageSize),
  },
  actions: {
    async fetchReportData() {
      // 直接访问this,无需担心响应性丢失
      const res = await api.get('/report', { params: this.filters });
      this.totalCount = res.data.total;
      this.rows = res.data.list;
    },
  },
});
import { useReportStore } from '@/stores/report';

const reportStore = useReportStore();
const { filters, currentPage } = storeToRefs(reportStore); // 保持解构后的响应性

五、踩坑与优化:迁移中的意外之喜与雷区

5.1 雷区1:Zustand的create与React 19 StrictMode双调用

现象:开启React StrictMode后,Store的初始化action执行了两次,导致API重复请求。

解决:在Store创建时使用skipHydration标志,并在根组件用useEffect手动调用一次初始化:

export const useAuthStore = create()(
  persist(
    (set) => ({
      user: null,
      // 初始化逻辑移到外部
    }),
    { name: 'auth-storage', skipHydration: true }
  )
);

// App.tsx
useEffect(() => {
  useAuthStore.persist.rehydrate(); // 手动触发一次
}, []);

5.2 雷区2:Pinia在微前端(qiankun)下的实例隔离

由于双框架通过micro-frontend集成,Vue子应用的Pinia实例会被主应用重复注册。最终方案:在子应用启动时用createPinia()创建独立实例,并通过app.use(pinia)注入,避免全局污染。

5.3 意外优化:移除了memo和computed的疯狂使用

迁移前,为了缓解Props钻透,团队用React.memo包裹了60+组件。迁移后,由于Zustand的细粒度订阅,这些memo大多变得冗余。移除后代码量减少约2400行,且通过React DevTools验证了渲染次数确实下降。

六、性能变化:数据不会说谎

迁移耗时3周(2人并行),最终性能对比如下:

指标 Context/Props模式(迁移前) Zustand/Pinia模式(迁移后) 变化
筛选操作导致的组件重渲染数 平均312个 平均47个 ↓85%
最坏情况下TS的setState耗时 1.8秒 约420ms ↓77%
首屏加载包体积 2.4MB (gzip) 2.408MB (gzip) 仅+8KB
新增依赖体积 - zustand+pinia ≈+12KB gzip
代码删除量 - 2400行(Props透传+Context) 净减
团队上手时间 - 1天(内部培训) -

更有趣的是:由于删除了大量Context Provider嵌套,React组件的重新挂载(remount)次数也下降了,这对包含复杂表单的页面流畅度提升显著。

七、总结:如果你的项目也痛,请立刻行动

迁移后最大的感受是:状态管理库不是银弹,但比“伪状态管理”强百倍。Context和Props适合极小型应用,一旦组件树深度超过5层、Context数量超过3个,就必须考虑引入专业方案。

给后来者的建议
1. 别想着一步到位:写兼容层(Bridge模式)逐步替换,让代码审查更安全。
2. 关注选择器粒度:在Zustand中永远用useShallow包裹对象选择器,否则会触发无限渲染。
3. 双框架迁移要统一Store命名风格:如React用useXxxStore,Vue用useXxxStore,但内部实现完全隔离。

现在这个项目已经稳定运行2个月,新功能开发效率提升约30%——因为新同事只需看Store的TS类型定义就能搞清楚数据流,而不用翻遍10层组件找props来源。如果你正被同样的状态混乱折磨,希望这份实测数据能坚定你迁移的决心。