一、背景:当Context成为性能瓶颈,当Props开始“传家宝”
我们的项目是一个中后台管理系统,技术栈为React 19 + TypeScript 5.6(核心模块)与Vue 3.5(插件市场模块),代码量已突破10万行。早期为了快速迭代,全局状态(用户信息、权限、主题配置)均使用React Context或Vue的Provide/Inject,而业务数据则通过Props层层透传,最深可达8层。
痛点爆发于一次性能专项审查。使用React Profiler测得:当用户点击某个功能按钮触发Context value更新时,整个应用组件树(约1200个组件)全部重渲染,单次交互耗时从12ms飙升至210ms。而在Vue侧,虽然响应式追踪避免了全量渲染,但Props透传导致组件复用性极差——修改一个页面权限字段需要改动7个中间组件的Props定义。
此时我们意识到:状态管理不是“要不要用”的问题,而是“如何优雅迁移”的问题。本文以这次真实重构为蓝本,完整复盘方案对比、迁移步骤与性能收益。
二、方案选型:Zustand 5.0 vs Pinia 3.0 vs Jotai——我们不只要打败Context
环境版本锁定:
- React 19.0.0 (稳定版) + TypeScript 5.6.3
- Vue 3.5.12 + Vite 6.0.5
- 状态库:Zustand 5.0.2(React)、Pinia 3.0.1(Vue)
我们内部进行了三轮评审,核心维度如下表:
| 维度 | Zustand 5.0 | Pinia 3.0 | 说明 |
|---|---|---|---|
| 响应式原理 | 基于useSyncExternalStore |
基于Vue Composition API | React侧无额外依赖 |
| 选择器优化 | 需手动使用useShallow |
天然组件级订阅 | Pinia对Vue更友好 |
| 代码侵入性 | 极低(无Provider) | 需安装pinia并createPinia() |
迁移成本差距不大 |
| 包体积(gzip) | +1.8KB | +3.2KB | 均可接受 |
最终结论:React侧选Zustand,Vue侧选Pinia。理由是Zustand的useSyncExternalStore与React 19并发特性(如useTransition)配合更好,且无需修改根组件结构;Pinia则原生集成Vue Devtools,保留了Vue的响应式心智模型。
三、迁移架构设计:模块隔离,灰度切换
我们确定“分模块渐进式迁移”策略,而非“Big Bang”重写。核心设计如下:
- 新建
stores/目录,每个业务域一个store文件(如userStore.ts、permissionStore.ts)。 - 保留旧Context作为“读兼容层”:迁移期间,Context仅提供默认值,并监听store变化触发自身更新(bridge模式),确保未迁移组件不会白屏。
- 数据流改造原则:凡是被超过3层透传的Props,或超过5个组件共享的状态,必须入store。
React侧核心store设计(Zustand v5):
// stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { subscribeWithSelector } from 'zustand/middleware';
interface UserState {
userInfo: { id: string; name: string; roles: string[] } | null;
permissions: Set;
fetchUser: () => Promise;
toggleRole: (role: string) => void;
}
export const useUserStore = create()(
subscribeWithSelector(
persist(
(set, get) => ({
userInfo: null,
permissions: new Set(),
fetchUser: async () => {
// 模拟请求
const res = await fetch('/api/user/me');
const data = await res.json();
set({ userInfo: data.user, permissions: new Set(data.permissions) });
},
toggleRole: (role) => {
const { userInfo } = get();
if (!userInfo) return;
const hasRole = userInfo.roles.includes(role);
set({
userInfo: {
...userInfo,
roles: hasRole
? userInfo.roles.filter(r => r !== role)
: [...userInfo.roles, role]
}
});
},
}),
{ name: 'user-storage', partialize: (state) => ({ userInfo: state.userInfo }) } // 持久化部分字段
)
)
);
Vue侧核心store设计(Pinia 3.0):
// stores/permissionStore.ts
import { defineStore } from 'pinia';
export const usePermissionStore = defineStore('permission', {
state: () => ({
// 使用ref保持响应式
permMap: {} as Record,
pageLoading: false,
}),
getters: {
canAccess: (state) => (perm: string) => !!state.permMap[perm],
},
actions: {
async loadPermissions(userId: string) {
this.pageLoading = true;
try {
const res = await fetch(`/api/perms/${userId}`);
const data = await res.json();
// 直接赋值,Pinia内部保证响应式
this.permMap = data.permMap;
} finally {
this.pageLoading = false;
}
},
// 批量更新权限(性能优化关键)
batchUpdatePermissions(newPerms: Record) {
// 使用$patch且不触发无关watcher
this.$patch((state) => {
Object.assign(state.permMap, newPerms);
});
}
},
});
四、核心实现迁移步骤与代码细节
第一步:接入Provider(Vue侧)与Bridge(React侧)
在Vue入口处安装Pinia:
// main.ts (Vue)
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia()); // 全局注入,所有组件可访问store
app.mount('#app');
React侧无需Provider,但需处理旧Context与新store协同。我们写了一个桥接组件:
// providers/StoreBridge.tsx (React)
import { useUserStore } from '@/stores/userStore';
import { LegacyUserContext } from '@/legacy/contexts/UserContext';
import { useEffect, ReactNode } from 'react';
export function StoreBridge({ children }: { children: ReactNode }) {
// 订阅store,当store变化时同步更新Context value
const userInfo = useUserStore((state) => state.userInfo);
const permissions = useUserStore((state) => state.permissions);
// 仅当store值变化时,Context value才变化
const legacyValue = { userInfo, permissions };
return (
{children}
);
}
第二步:组件消费替换
替换前(Context):
const { userInfo } = useContext(LegacyUserContext);
替换后(Zustand + 精确selector):
// 只订阅userInfo,不订阅permissions,减少渲染次数
const userInfo = useUserStore((state) => state.userInfo);
// 若需对比旧值,可使用useShallow防止引用不稳定
const roles = useUserStore((state) => state.userInfo?.roles, shallow);
第三步:处理Props透传
我们使用codemod脚本扫描源码,找出所有超过3层的透传变量,生成迁移清单。以Vue侧为例,改造前:
{{ permMap['edit'] }}
// 需要defineProps接收从父级传下来的permMap
defineProps({ permMap: Object });
改造后(直接引入store):
import { usePermissionStore } from '@/stores/permissionStore';
// 直接使用store,无需defineProps
const permStore = usePermissionStore();
const canEdit = permStore.canAccess('edit');
五、踩坑与优化:6个典型问题及解法(重点)
坑1:React 19 + Zustand 的StrictMode双调用
- 现象:fetchUser在开发模式下被调用两次。
- 原因:React 19 StrictMode会故意double-invoke reducer和初始化函数。
- 解法:在store外使用let fetchPromise: Promise | null = null,若已有pending请求则复用,或使用useEffect的空依赖+ref锁。
坑2:Pinia的响应式丢失(Vue 3.5新特性)
- 现象:将permMap整体赋值后,组件不更新。
- 原因:未使用$patch导致Vue无法追踪新对象。
- 解法:一律通过$patch或action内部赋值,禁止直接store.permMap = xxx(除非定义为ref)。
坑3:Zustand selector返回新对象导致死循环
- 问题代码:const user = useUserStore((state) => ({ ...state.userInfo })); 每次render都返回新对象,触发无限渲染。
- 解法:使用useShallow,或拆分selector只取基本类型字段。
坑4:迁移期间Context与Store数据不同步
- 场景:页面A使用Context,页面B使用Store,用户信息更新后两处不一致。
- 解法:采用第二节的Bridge模式,Context只读,Store负责写。若直接修改Context value,会触发全树更新,违背迁移初衷。
坑5:Vue Devtools无法调试Pinia状态
- 原因:未安装@pinia/plugin-devtools或未在createPinia()后挂载。
- 解法:安装并注册:pinia.use(DevtoolsPlugin()),且需Vue Devtools版本≥6.6。
坑6:持久化与SSR/Hydration冲突
- 场景:Zustand persist在客户端初始化时从localStorage读取,但服务端渲染(Next.js)时无window。
- 解法:使用skipHydration: true并在客户端useEffect中手动调用useUserStore.persist.rehydrate()。
性能优化关键点:为Zustand store添加subscribeWithSelector中间件,使我们能在组件外精确订阅某字段:
// 在非组件文件中监听permissions变化
useUserStore.subscribe(
(state) => state.permissions.size,
(size, prevSize) => {
console.log(`权限数从${prevSize}变为${size}`);
}
);
六、效果数据与总结
迁移历时3周,分4个批次灰度上线。最终数据如下:
| 指标 | 迁移前 | 迁移后 | 提升幅度 |
|---|---|---|---|
| React侧交互卡顿耗时(P95) | 210ms | 58ms | 72.4%↓ |
| Vue侧组件渲染次数(单次操作) | 平均43次 | 平均11次 | 74.4%↓ |
| 首屏可交互时间(TTI) | 2.8s | 1.9s | 32%↓ |
| 代码中Props定义行数 | 1,230行 | 410行 | 66.7%↓ |
最后的态度:不要为了用库而用库。如果你的Context只是全局且低频更新(如主题色),完全没必要迁移。但若你面临和我一样的场景——高频状态更新、Props透传深渊、性能告警,那么Zustand/Pinia是值得投入的。迁移不是重写,而是把“隐式依赖”变为“显式订阅”。过程中最大的阻力不是技术,而是团队成员“我就用Props传一下怎么了”的惯性思维——代码评审时,我们强制要求:超过3层透传必须入store。现在回看,这个规则的执行是收益最大的部分。
参考版本:React 19.0.0、Zustand 5.0.2、Vue 3.5.12、Pinia 3.0.1、Vite 6.0.5、TypeScript 5.6.3。