1. 问题背景:当「提桶」变成「搬山」
事情始于我们维护的一个后台管理系统(React 18.2.0 + TypeScript 4.9)。这个项目有超过120个组件,用户登录信息、权限路由、主题配置、以及一个实时更新的“未读消息数”需要被超过30个组件共享。
最初的架构是典型的 Props + Context 方案:
// 典型的三层Context嵌套
问题在第三个月爆发:
- 性能瓶颈:NotificationProvider 的 value 对象中包含了 unreadCount 和 setUnreadCount。当 unreadCount 每秒更新一次时,所有消费 useContext(NotificationContext) 的组件(包括那些只需要 setUnreadCount 的按钮组件)全部重渲染。
- 维护地狱:新增一个页面需要先看这个页面属于哪个Provider层级,然后决定是传Props还是再包一层Context。代码审查时经常看到 props 里挂着5层以上的透传属性。
- 调试困难:React DevTools 里看组件树,全是 `` 包裹,无法直观看到状态来源。
与此同时,我们另一个Vue 3.3项目(Vite 5 + Pinia)虽然用 provide/inject 解决了部分问题,但同样存在 非响应式陷阱:一旦 inject 一个普通对象,修改它不会触发视图更新,必须引入 reactive 包裹,这导致代码心智负担极重。
2. 环境与版本:我们选择的武器
经过两周调研和Demo验证,我们确定了最终方案:
React项目:
- 框架:React 18.2.0 + TypeScript 4.9.5
- 状态库:Zustand v4.4.7(选择理由:无Provider包裹,支持 create 函数直接创建hook,且默认浅比较,避免不必要的重渲染)
- 构建工具:Vite 5.0(非CRA,因为我们需要自定义Chunk拆分)
Vue项目:
- 框架:Vue 3.3.4 + `` + TypeScript 5.2
- 状态库:Pinia v2.1.7(选择理由:官方推荐,DevTools插件完美,且store间可以互相调用)
- 构建工具:Vite 5.0
关键配置:在 vite.config.ts 中开启 optimizeDeps.include: ['zustand', 'pinia'] 预构建,减少首次加载解析时间。
3. 方案设计:统一的状态分层与原子化拆分
我们没有简单的一刀切替换,而是制定了 “三层状态架构”:
第一层:服务端状态(用户信息、权限) → 直接使用 Zustand/Pinia store,但通过 `createJSONStorage` 持久化到 localStorage。
第二层:全局UI状态(主题、侧边栏折叠、通知数) → 拆分为独立的原子store。
第三层:临时共享状态(跨组件表单步骤) → 仍然使用 Props 或 provide/inject,但限定在局部作用域(如一个路由页面内)。
核心设计原则:
- React 中,一个store只负责一个领域,例如 useAuthStore、useNotificationStore。严禁创建 useGlobalStore 大杂烩。
- Vue 中,Pinia 的 store 使用 setup 语法(类似组合式API),确保 TypeScript 推断完整。
4. 核心实现:迁移步骤与代码对比
4.1 React:从 Context 到 Zustand 的迁移(6步走)
步骤1:安装与创建store
// src/stores/notificationStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface NotificationState {
unreadCount: number;
lastUpdated: number;
increase: (by: number) => void;
reset: () => void;
}
// 使用 subscribeWithSelector 中间件,支持精确订阅
export const useNotificationStore = create()(
subscribeWithSelector((set) => ({
unreadCount: 0,
lastUpdated: Date.now(),
increase: (by) => set((state) => ({
unreadCount: state.unreadCount + by,
lastUpdated: Date.now()
})),
reset: () => set({ unreadCount: 0, lastUpdated: Date.now() }),
}))
);
步骤2:替换消费方(核心踩坑点)
原Context写法:
const { unreadCount } = useContext(NotificationContext);
Zustand写法(必须使用选择器,否则依然会重渲染):
// 正确写法:只订阅 unreadCount 字段
const unreadCount = useNotificationStore((state) => state.unreadCount);
// 错误写法:直接解构整个store,会导致任何state变化都触发组件更新
// const { unreadCount, increase } = useNotificationStore();
步骤3:处理异步Action——Zustand 的 set 是同步的,异步逻辑写在store外面或者使用 async 函数内调用 set:
// 在组件中触发异步更新
const increaseAsync = async () => {
await fetch('/api/notifications');
useNotificationStore.getState().increase(1);
};
4.2 Vue:从 provide/inject 到 Pinia 的迁移
步骤1:定义store(使用setup语法,这是Vue 3.3推荐写法)
// src/stores/notification.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useNotificationStore = defineStore('notification', () => {
const unreadCount = ref(0);
const lastUpdated = ref(Date.now());
const unreadCountPow = computed(() => unreadCount.value * 2);
function increase(by: number) {
unreadCount.value += by;
lastUpdated.value = Date.now();
}
function reset() {
unreadCount.value = 0;
lastUpdated.value = Date.now();
}
return { unreadCount, lastUpdated, unreadCountPow, increase, reset };
});
步骤2:组件中替代 provide/inject
关键点:**Pinia store 在 ` 中解构会丢失响应性**,必须使用storeToRefs`:
import { storeToRefs } from 'pinia';
import { useNotificationStore } from '@/stores/notification';
const store = useNotificationStore();
// 必须使用 storeToRefs 解构,直接解构 store 会变成普通字符串
const { unreadCount, lastUpdated } = storeToRefs(store);
// 方法可以直接解构,因为方法不涉及响应式代理
const { increase } = store;
{{ unreadCount }} 条未读,最后更新:{{ lastUpdated }}
+
5. 踩坑与优化:那些文档里没写的事
坑1:Zustand v4 的 create 泛型需要双重调用。
如果直接写 create()(fn) 会报 TS 错误,必须显式传入类型参数。这是 TypeScript 的柯里化限制,官方文档有说明,但很容易忽略。
坑2:Pinia 的 store 中引入 setTimeout 或 setInterval 必须清理。
我们在一个 store 中用了 setInterval 轮询,但组件销毁时没有 clearInterval,导致内存泄漏。正确做法是在 onUnmounted 中清理,或者使用 $onAction 钩子。
坑3:性能优化——避免过度订阅。
Zustand 默认是浅比较,但如果组件中使用了 useNotificationStore() 不带选择器,依然会订阅整个store。我们用了一个自定义 useShallow 工具来避免:
import { useShallow } from 'zustand/react/shallow';
const { unreadCount, lastUpdated } = useNotificationStore(
useShallow((state) => ({ unreadCount: state.unreadCount, lastUpdated: state.lastUpdated }))
);
优化:DevTools 时间线分析。
迁移后我们使用 React DevTools Profiler 和 Vue Devtools 进行对比。重点关注 渲染耗时 和 组件更新次数。
6. 效果数据:用数字说话
迁移完成后,我们对两个项目进行了压测(Chrome 120,CPU 4x slowdown模拟低端机):
React 项目(核心页面,包含实时通知列表)
- 首屏渲染时间:从 2.1s(Context版本) 降至 1.2s(Zustand版本),降低 42.8%。
- 每秒钟状态更新(模拟每秒1次通知)时的组件重渲染次数:从 45 次/秒 降至 15 次/秒,降低 66.7%。
- 内存占用:从 87.3MB 降至 68.1MB(因为不再需要多层Provider的嵌套对象)。
Vue 项目(管理后台,复杂表格+筛选联动)
- 交互响应延迟(输入筛选条件到表格更新):从 320ms 降至 180ms。
- 状态追踪性能:Vue Devtools 中记录一次状态变更的耗时从平均 12ms 降至 7ms。
代码量对比:React 项目中,删除 Context 相关文件 9 个,新增 store 文件 6 个,Props 透传属性减少了 70%。
7. 总结:工具是药,对症才有效
这次迁移最大的感悟是:状态管理库不是银弹。如果你的状态是纯局部且树状传递,Context/Props 依然是最简单直接的方案。但一旦出现 跨层级共享且频繁更新 的状态(比如实时通知、WebSocket数据),就必须引入具备 选择器订阅 能力的库。
技术选型建议:
- 团队熟悉 React 且追求极简 API:选 Zustand(学习成本极低,且不需要 Provider 包裹,适合微前端)。
- 项目基于 Vue 3 且需要严格的状态历史追踪:选 Pinia(官方支持,DevTools 体验最好,且天然拥抱响应式)。
- 如果你的项目有严重的性能瓶颈,但不想引入库,可以考虑 React 18 的 useSyncExternalStore 或 Vue 3 的 shallowRef 做局部优化,但边际成本较高,不如一步到位。
最终,两个项目稳定运行了 3 个月,未出现回归问题。迁移过程中最耗时的不是写代码,而是梳理业务中哪些状态是真正全局的。建议你在动手前,先花两天时间梳理状态依赖图——这比任何技术方案都重要。
附录:核心代码仓库结构参考
src/
├── stores/
│ ├── authStore.ts # React Zustand
│ ├── notificationStore.ts # React Zustand
│ └── notification.ts # Vue Pinia
└── utils/
└── performanceMonitor.ts # 性能打点工具