一、问题背景:当Props钻透成为技术债
我们的项目是一个中后台OMS系统,核心模块是订单管理。早期为了快速上线,状态管理直接用了React Context + useReducer,Vue端则是provide/inject。随着业务迭代,问题逐渐失控:
- Props钻透(Prop Drilling):订单详情页需要传递
userInfo、permissionMap、orderFilters等数据,最深层组件距离顶层竟有8层嵌套。每次加需求都要在中间组件补...props,代码阅读性极差。 - Context重渲染风暴:Context value一旦变化,所有Consumer组件都会重新渲染。我们的
GlobalConfigContext包含了主题、时区、货币符号,结果切换主题时,整个订单表格(300+行)卡顿1.2秒。 - Vue端provide/inject的响应式丢失:当我们在
provide中直接传递一个reactive对象时,如果子组件解构赋值,就会失去响应式链接。这个问题在排查时极其隐蔽。
二、环境与版本:技术选型的现实约束
迁移前基线:
- React 18.2 + TypeScript 5.3 + Ant Design 5.12
- Vue 3.4 + Pinia 2.1(旧项目)+ Element Plus 2.6
- 构建工具:Vite 5.1(React端)、Webpack 5.9(Vue端,历史包袱)
迁移目标版本:
- React端:Zustand v5.0.2(选择它而不是Redux Toolkit,因为Zustand v5彻底拥抱了React 19的use Hook,且无Provider嵌套)
- Vue端:Pinia v3.0.1(Vue 3.5对reactive的Proxy优化,让Pinia的性能更上一层楼)
关键决策:React端坚决不引入Redux,因为团队没人愿意写样板代码;Vue端保留Pinia,因为Vue官方推荐且我们已有2.x的使用经验。
三、方案设计:从“隐式依赖”到“显式消费”
1. Zustand v5的Store设计
核心思路:将原来的OrderContext(包含订单列表、筛选条件、分页信息)拆分为3个独立的Store,利用Zustand的create和createSelectors避免多余渲染。
// stores/orderStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface OrderState {
orders: Order[];
filters: FilterParams;
pageInfo: { page: number; pageSize: number };
setFilters: (filters: Partial) => void;
fetchOrders: () => Promise;
}
export const useOrderStore = create()(
devtools(
(set) => ({
orders: [],
filters: { status: 'all', keyword: '' },
pageInfo: { page: 1, pageSize: 20 },
setFilters: (filters) => set((state) => ({ filters: { ...state.filters, ...filters } })),
fetchOrders: async () => {
const res = await api.getOrders();
set({ orders: res.data });
},
}),
{ name: 'OrderStore' } // Redux DevTools调试关键
)
);
2. Pinia v3的Store设计
Vue端将原来的useGlobalConfig提供者替换为组合式Store,解决响应式丢失问题。
// stores/globalConfig.ts (Vue端)
import { defineStore } from 'pinia';
export const useGlobalConfigStore = defineStore('globalConfig', {
state: () => ({
theme: 'light',
currency: 'CNY',
timezone: 'Asia/Shanghai',
}),
getters: {
themeClass: (state) => `theme-${state.theme}`,
},
actions: {
updateTheme(theme: 'light' | 'dark') {
this.theme = theme;
// 这里可以配合持久化插件自动同步localStorage
},
},
});
四、核心实现:迁移步骤与代码级别的破坏性变更
迁移五步法:
1. 依赖注入替换:全局搜索useContext(OrderContext),替换为useOrderStore()。在Vue端,搜索inject('globalConfig'),替换为useGlobalConfigStore()。
2. Provider移除:删除React入口文件的`包裹层,减少一层DOM嵌套。
3. **选择器优化**:在React组件中使用useOrderStore((state) => state.orders),避免对象解构导致的重渲染(Zustand v5会自动进行浅比较)。
4. **Actions重构**:将原来写在组件里的dispatch({ type: 'SET_FILTER', payload })逻辑收敛到Store的action中。
5. **持久化迁移**:Vue端利用Pinia的persist插件替代原来的watch+localStorage`手动同步。
核心代码对比(React端):
// 迁移前 - 繁琐的Context消费
const { state, dispatch } = useContext(OrderContext);
const handleFilterChange = (filter: FilterParams) => {
dispatch({ type: 'UPDATE_FILTER', payload: filter });
};
// 迁移后 - 直接调用Store方法
const filters = useOrderStore((state) => state.filters);
const setFilters = useOrderStore((state) => state.setFilters);
const handleFilterChange = (filter: FilterParams) => {
setFilters(filter); // 类型安全,无字符串魔数
};
五、踩坑与优化:那些文档没写清楚的细节
坑1:Zustand v5的create泛型必须显式传入
如果你用create()(...),一旦忘记传泛型,TypeScript会推断出unknown类型。这个问题在迁移时折磨了我们一个下午,最终发现是v5对TS严格模式的加强。
坑2:Pinia Store的解构响应式
在组件中使用const { theme, updateTheme } = useGlobalConfigStore()时,theme会失去响应式。必须在解构时用storeToRefs包裹:
import { storeToRefs } from 'pinia';
const store = useGlobalConfigStore();
const { theme } = storeToRefs(store); // 保持响应式
优化策略:
- React端使用useShallow来比较复杂对象(如filters),避免深度比较的开销。
- Vue端在Pinia Store的getter中避免返回新对象,否则会触发无限渲染循环。
- 引入zustand/middleware/immer中间件,让不可变更新写起来像可变更新,代码量减少20%。
六、效果数据与性能对比
我们使用React Profiler和Vue Devtools的Performance Tab,在生产构建下(关闭HMR)跑了20次订单列表页的交互测试。
| 指标 | Context/Props模式 | Zustand/Pinia模式 | 提升 |
|---|---|---|---|
| 首屏渲染时间(React) | 850ms | 620ms | 27%提升 |
| 筛选条件变更重渲染(React) | 1.2s(全表渲染) | 340ms(仅列表更新) | 71.6%提升 |
| 主题切换耗时(Vue) | 480ms | 120ms | 75%提升 |
| 代码量(订单模块) | 3,200行 | 2,080行 | 35%减少 |
性能变化原因分析:
1. 精准订阅:Zustand的selector让每个组件只订阅它需要的slice,而Context是广播式通知。
2. 内存优化:Pinia v3基于Vue 3.5的proxyRefs优化,store实例的内存占用减少约15%。
3. DevTools友好:Zustand的devtools中间件和Pinia的vue-devtools集成,调试时间缩短一半。
七、总结与建议
如果项目还在早期,直接用Zustand/Pinia;如果像我们一样已经积累了Context债务,不要犹豫,立刻迁移——技术债的利息会随着组件数量指数增长。
最后三个忠告:
1. 迁移时一次性完成,不要混合使用Context和Store,否则调试会精神分裂。
2. 务必启用ESLint的react-hooks/exhaustive-deps,在移除Context后漏掉依赖是重渲染的头号杀手。
3. 对比React和Vue的状态管理思路:Zustand更像“外部库”,Pinia则是“框架原生”,选择时要考虑团队的技术倾向。
迁移不是炫技,而是为了给业务迭代留出呼吸感。当你的同事不再为一个状态传递找三层组件时,你会觉得这周的加班值了。