一、为什么Context和Props不再够用
在项目早期(约200个组件,5个业务模块),我们使用React Context + Vue Provide/Inject + Props透传来管理全局状态。这种组合开发速度快,无需引入第三方库。但当项目膨胀到2000+组件、20+业务模块时,问题集中爆发:
- Context的穿透式重渲染:任何一个Context value变化,所有消费该Context的组件(无论是否使用了变化的数据)都会触发re-render。在React DevTools中,我们观察到一次简单的用户权限切换,导致了320个无关组件的重新渲染,耗时约340ms。
- Vue Provide/Inject的响应式断裂:Provide注入的响应式对象在深层组件中丢失了响应式追踪,导致数据更新但视图不更新的诡异bug。
- Props层层透传:一个表单组件需要接收从顶层传下来的8层props,修改一个字段需要改5个中间组件的接口。
我们决定进行状态管理重构。最终选型:React侧使用Zustand 4.5.2,Vue侧使用Pinia 2.1.7。原因很简单:API简洁、支持框架无关的中间件、对TypeScript友好、且不依赖React/Vue的上下文机制。
二、迁移方案设计
2.1 总体策略:渐进式替换
不能一次全部重写。我们制定了「三阶段迁移」策略:
| 阶段 | 目标 | 时间 |
|---|---|---|
| 第一阶段 | 将全局共享数据(用户、权限、主题)从Context/Provide迁移到Store | 2周 |
| 第二阶段 | 将跨模块传递的Props(如列表筛选参数)迁移到Store | 3周 |
| 第三阶段 | 清理所有遗留的Provide/Inject,统一使用Store | 1周 |
2.2 技术选型对比
我们评估了4种方案:
| 方案 | 包体积 | 学习成本 | 细粒度更新 | 支持Vue |
|---|---|---|---|---|
| Zustand | 1.1KB | 极低 | 支持selector | 通过中间件 |
| Pinia | 2.5KB | 低 | 支持getter | 原生 |
| Redux Toolkit | 3.2KB | 中 | 需手动优化 | 需适配 |
| MobX | 4.0KB | 中 | 自动追踪 | 需适配 |
Zustand在React侧胜出,Pinia在Vue侧是官方推荐。两者都支持非上下文的依赖注入,这是解决Context穿透渲染的关键。
三、核心实现:以用户权限状态为例
3.1 React侧:从Context到Zustand
旧方案(Context):
// 痛点:任何权限变化导致所有UserProvider子组件重渲染
const UserContext = createContext({ user: null, permissions: [] });
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [permissions, setPermissions] = useState([]);
return (
{children}
);
}
新方案(Zustand):
// store/userStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface UserState {
user: { id: string; name: string; role: string } | null;
permissions: string[];
setUser: (user: UserState['user']) => void;
setPermissions: (perms: string[]) => void;
}
export const useUserStore = create()(
devtools(
persist(
(set) => ({
user: null,
permissions: [],
setUser: (user) => set({ user }),
setPermissions: (permissions) => set({ permissions }),
}),
{ name: 'user-storage' }
),
{ name: 'UserStore' }
)
);
// 在组件中使用——仅订阅所需片段
function UserAvatar() {
// ✅ 只订阅user.name,permissions变化不会触发此组件重渲染
const userName = useUserStore((state) => state.user?.name);
return {userName || '未登录'};
}
function PermissionGuard({ children, perm }: { children: React.ReactNode; perm: string }) {
// ✅ 只订阅特定权限是否存在
const hasPerm = useUserStore((state) => state.permissions.includes(perm));
return hasPerm ? {children} : null;
}
3.2 Vue侧:从Provide/Inject到Pinia
旧方案(Provide/Inject):
import { provide, ref } from 'vue';
const theme = ref('light');
const setTheme = (t) => theme.value = t;
provide('theme', { theme, setTheme });
import { inject } from 'vue';
const { theme } = inject('theme'); // ❌ 响应式追踪可能丢失
新方案(Pinia):
// stores/themeStore.ts
import { defineStore } from 'pinia';
export const useThemeStore = defineStore('theme', {
state: () => ({
mode: 'light' as 'light' | 'dark',
primaryColor: '#1890ff',
sidebarCollapsed: false,
}),
getters: {
isDark: (state) => state.mode === 'dark',
themeConfig: (state) => ({
'--primary-color': state.primaryColor,
'--bg-color': state.mode === 'dark' ? '#141414' : '#ffffff',
}),
},
actions: {
toggleTheme() {
this.mode = this.mode === 'light' ? 'dark' : 'light';
},
setPrimaryColor(color: string) {
this.primaryColor = color;
},
},
});
// 在组件中使用
import { useThemeStore } from '@/stores/themeStore';
const themeStore = useThemeStore();
// ✅ 只有mode变化时组件才会更新
const isDark = computed(() => themeStore.isDark);
四、踩坑与优化
4.1 Zustand的selector陷阱
一开始我们这样写:
const user = useUserStore((state) => state.user);
如果user对象每次都是新引用(比如从API返回),即使数据相同也会触发重渲染。解决方案:
// 使用shallow比较
import { shallow } from 'zustand/shallow';
const [user, permissions] = useUserStore(
(state) => [state.user, state.permissions],
shallow
);
4.2 Pinia的SSR兼容
在Nuxt 3.10中使用Pinia时,store必须在setup中初始化,否则服务端渲染时会报错。我们在plugins/pinia.ts中做了预初始化:
export default defineNuxtPlugin(({ $pinia }) => {
const themeStore = useThemeStore($pinia);
// 从cookie读取主题偏好
themeStore.mode = useCookie('theme-mode').value || 'light';
});
4.3 跨框架共享状态
我们有一个React组件和Vue组件需要共享同一个「通知数量」状态。使用Zustand的createStore(不绑定React) + Vue的reactive包装:
// shared/notificationStore.ts (框架无关)
import { createStore } from 'zustand/vanilla';
export const notificationStore = createStore void;
}>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// Vue侧适配
import { reactive } from 'vue';
const state = reactive(notificationStore.getState());
notificationStore.subscribe((newState) => {
Object.assign(state, newState);
});
五、性能数据与效果
我们在生产环境部署后,使用React Profiler和Vue Devtools进行了对比测试:
| 指标 | 迁移前(Context/Props) | 迁移后(Zustand/Pinia) | 提升 |
|---|---|---|---|
| 用户权限切换页面重渲染组件数 | 320个 | 42个 | 86.9% |
| 主题切换响应时间 | 340ms | 45ms | 86.8% |
| 跨组件数据更新延迟 | 80ms | 12ms | 85% |
| 包体积增加 | - | +3.6KB(Zustand+Pinia) | 可接受 |
最明显的感受是:之前切换深色模式时,整个页面会闪一下(因为所有组件重建样式),现在几乎无感。
六、总结
从Context/Props迁移到专用状态管理库,本质上是从「依赖React/Vue运行时机制」转向「独立的状态管理层」。Zustand和Pinia之所以能解决性能问题,核心在于:
- 细粒度的订阅机制:不再需要整个子树重渲染,只有真正使用某段状态的组件才更新。
- 框架无关的架构:状态逻辑可以在React和Vue之间复用,甚至可以在纯JS中使用。
- 中间件生态:devtools、persist、immer等中间件让调试和持久化变得简单。
如果你还在犹豫要不要迁移,建议用React Profiler或Vue Devtools的性能面板跑一下:如果Context更新导致超过50个无关组件重渲染,或者任意Props传递超过3层,就是迁移的信号。
最后给一个忠告:不要一次性迁移所有状态。先挑出「更新频繁且影响范围大」的状态(如用户权限、主题、语言),尝到甜头后再逐步替换低频状态。