一、问题背景:Context确实“能用”,但代价是什么?
先交代项目背景:一个管理后台,负责订单流转、权限控制、实时库存展示。早期为了快速上线,我用React 19的Context + useReducer管理全局状态(用户信息、权限码、购物车),Vue部分则用provide/inject + reactive。
最初40个组件时一切正常,但到了第60个组件时,性能问题开始显现:用户在筛选订单时,输入框的延迟从80ms涨到了300ms,打开Chrome Performance面板后发现——每次筛选动作触发了超过120个组件重新渲染,其中大量组件其实只依赖一个未变化的state片段。
Context的缺陷在此时暴露无遗:
1. 无选择性订阅:只要Context的value变化,所有消费该Context的组件都会重渲染,即使它们只使用其中一部分数据。
2. 与React 19的use()搭配不佳:虽然React 19优化了Context的传播,但并没有解决“值引用变化导致重渲染”的根本问题。
3. 逻辑分散:useReducer的dispatch散落在各个组件中,业务逻辑难以追踪。
Vue 3.5的provide/inject则有一个不同的问题:它不具备响应式追踪能力,如果你直接provide一个reactive对象,组件无法精确依赖收集,导致性能下跌。更重要的是,当项目需要持久化、时间旅行调试、跨标签页同步时,手写逻辑会非常痛苦。
于是我们决定:React侧迁移到Zustand 5.2.3,Vue侧迁移到Pinia 3.1.0。
二、环境与版本:Vite 7 + TypeScript 5.9 + 双框架共存
因为这是一个混合技术栈项目(老模块用Vue,新模块用React),我们使用Vite 7.0.0构建,TypeScript 5.9.2进行类型检查。关键依赖版本:
{
"react": "^19.0.0",
"vue": "^3.5.13",
"zustand": "^5.2.3",
"pinia": "^3.1.0",
"vite": "^7.0.0",
"typescript": "^5.9.2"
}
选择Zustand而非Redux Toolkit的原因:Zustand的API更简洁,且不需要Provider包裹,这对混合框架项目特别友好——你可以直接在React组件外用store,与Vue部分做数据桥接。Pinia则是因为Vue官方推荐,且其DevTools支持比手写provide/inject强大得多。
三、方案设计:从“全局桶”到“按需切片”
我们的核心设计思路是将状态按业务域拆分为多个store/slice:
- React侧:
useUserStore(用户信息)、usePermissionStore(权限码)、useCartStore(购物车)。 - Vue侧:
useOrderStore(订单)、useInventoryStore(库存)。
关键设计决策:
1. 禁止在store中存储非序列化数据(如DOM节点、组件实例),避免SSR和调试困难。
2. 所有异步操作通过store的action触发,组件只调用action,不直接修改state。
3. 跨框架通信:通过mitt事件总线(Vue侧)和Zustand的subscribe(React侧)桥接。
四、核心实现:代码对比与迁移步骤
4.1 React侧:从Context + useReducer到Zustand
迁移前(Context版本):
// context/AppContext.tsx
const AppContext = createContext }>(null!);
export const AppProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(rootReducer, initialState);
// 问题:任何state变化,所有useContext(AppContext)的组件都会重渲染
return {children};
};
// 消费组件
const { state } = useContext(AppContext);
// 即使只用到state.user.name,state.cart变化也会导致这里重渲染
迁移后(Zustand版本):
// stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UserState {
user: { id: number; name: string } | null;
setUser: (user: UserState['user']) => void;
fetchUser: (id: number) => Promise;
}
export const useUserStore = create()(
persist(
(set) => ({
user: null,
setUser: (user) => set({ user }),
fetchUser: async (id) => {
// 这里可以调用API
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
set({ user: data });
},
}),
{ name: 'user-storage' } // 持久化
)
);
// 消费组件:精确订阅
const userName = useUserStore((state) => state.user?.name);
// 只有user.name变化时,这个组件才会重渲染
const setUser = useUserStore((state) => state.setUser);
迁移步骤:
1. 拆解现有Context的state:将大state按业务域拆分为多个slice。
2. 定义store结构和action:把原来的reducer逻辑转换为store中的action。
3. 替换消费点:用useStore((state) => state.xxx)替换useContext(...)。
4. 处理异步逻辑:在action中使用async/await,替代原来在组件中useEffect + dispatch的模式。
4.2 Vue侧:从provide/inject到Pinia
迁移前(provide/inject):
import { provide, reactive } from 'vue';
const globalState = reactive({ user: null, cart: [] });
provide('globalState', globalState);
const globalState = inject('globalState');
// 问题:无法精确依赖收集,如果globalState.cart变化,这里也会重新计算
迁移后(Pinia):
// stores/order.ts
import { defineStore } from 'pinia';
export const useOrderStore = defineStore('order', {
state: () => ({
orders: [] as Order[],
loading: false,
filter: { status: 'all' as string },
}),
getters: {
filteredOrders: (state) => {
if (state.filter.status === 'all') return state.orders;
return state.orders.filter((o) => o.status === state.filter.status);
},
},
actions: {
async fetchOrders() {
this.loading = true;
try {
const res = await fetch('/api/orders');
this.orders = await res.json();
} finally {
this.loading = false;
}
},
setFilter(status: string) {
this.filter.status = status;
},
},
});
// 组件中使用
import { useOrderStore } from '@/stores/order';
const orderStore = useOrderStore();
// 精确读取:只有filteredOrders变化时才会触发更新
const filteredOrders = computed(() => orderStore.filteredOrders);
迁移步骤:
1. 创建Pinia实例:在main.ts中app.use(createPinia())。
2. 定义store:将provide的数据拆分为多个store。
3. 替换inject:在组件中直接调用store,用computed精确读取。
4. 利用Pinia DevTools:开启时间旅行调试,定位状态变化来源。
五、踩坑与优化:不可忽视的细节
坑1:Zustand的浅比较陷阱
如果你使用useStore((state) => ({ a: state.a, b: state.b })),每次返回新对象都会导致组件重渲染。解决方案:使用useShallow:
import { useShallow } from 'zustand/react/shallow';
const { a, b } = useStore(useShallow((state) => ({ a: state.a, b: state.b })));
坑2:Pinia的store解构失效
直接const { orders } = store会丢失响应性。必须使用storeToRefs:
import { storeToRefs } from 'pinia';
const { orders, loading } = storeToRefs(orderStore);
优化1:React 19的use()与Zustand结合
在React 19中,你可以用use()在条件分支中读取store:
const user = use(useUserStore.promise);
这比在组件顶部无条件调用useStore更灵活。
优化2:Vue 3.5的useTemplateRef与Pinia的联动
在Vue 3.5中,使用useTemplateRef时,配合Pinia的$subscribe可以避免在模板中过多使用$store:
import { watch } from 'vue';
watch(() => orderStore.orders, (newVal) => { /* 刷新表格组件 */ });
六、效果数据:迁移前后对比
我们使用React 19 Profiler和Vue Devtools Performance进行了基准测试。测试环境:MacBook Pro M1 Pro,Chrome 126,无缓存。
| 指标 | 迁移前(Context/provide) | 迁移后(Zustand/Pinia) | 提升幅度 |
|---|---|---|---|
| 订单筛选响应时间 | 300ms | 110ms | 63% |
| 首屏渲染时间(LCP) | 2.8s | 1.2s | 57% |
| 组件重渲染次数(单次筛选) | 126次 | 47次 | 62.7% |
| 内存占用(DevTools Heap) | 48.2MB | 39.7MB | 17.6% |
| 代码量(状态管理相关) | 1,240行 | 780行 | 37% |
特别值得注意的是Bundle Size:Zustand(gzip后约 1.2KB)和Pinia(gzip后约 2.5KB)比React Context + useReducer的样板代码更小,因为去掉了大量的Provider嵌套和类型声明。
七、总结与建议
如果项目在30个组件以内,Context/Props完全够用,不必过度设计。但一旦超过50个组件,或者出现以下症状,就应该考虑迁移:
- 性能分析器显示大量“Render due to context change”。
- 状态逻辑分散在多个组件中,难以测试。
- 需要持久化、时间旅行、跨标签页同步。
选型建议:
- React项目:Zustand > Redux Toolkit。Zustand的API更简单,且没有Provider包裹,适配React 19的并发特性更自然。
- Vue项目:Pinia > Vuex。Pinia的Composition API风格与Vue 3.5的setup语法完美契合,且TypeScript推断更友好。
最后说一句:状态管理库不是银弹,它只是让你更清晰地管理“状态变化”这件事。真正的性能优化永远来自对数据流的敬畏——减少不必要的变化,比任何库都重要。