一、问题背景:Context/Props钻取的本质瓶颈
去年接手了一个维护两年的中后台系统,React 16.8 + TypeScript 4.5,42个页面,187个组件。最让人头疼的不是业务逻辑,而是状态传递。
用户登录信息、权限列表、当前选中的项目ID、主题配置……这些全局状态通过Context + useReducer管理。但问题在于:
- Context值变化时,所有消费该Context的组件都会重渲染,哪怕它们只用了其中一部分数据。我们的
AppContext里挂了12个字段,每次权限更新,整个页面树都要跑一遍diff。 - 深层组件无法直接修改状态,必须通过props层层传递回调函数。一个简单的
updateUserProfile,从页面顶部传到第7层子组件,中间5层组件都要转发这个函数。 - 性能监控数据显示:在无任何交互的静态页面上,React DevTools Profiler显示平均提交时间为87ms,其中63%的时间浪费在无关组件的重渲染上。
Vue那边的项目是3.2 + Pinia 1.x,情况稍好,但Props钻取同样存在。一个订单详情页,从OrderDetail.vue到OrderItem.vue到OrderItemAddress.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中需要手动引入pinia的patch插件。所以如果你还在用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引用orderStore,orderStore又引用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无法追踪普通变量的变化。改成ref或reactive即可。
坑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是核心,这两个用好了,性能问题基本解决。
以上是这次迁移的完整记录,希望对正在做类似改造的开发者有帮助。有问题欢迎在评论区交流。