1. 问题背景:Context 和 Provide 不是万能药
项目是一个运营后台,React 18.2 负责数据看板,Vue 3.3 负责配置中心。两个子应用通过 qiankun 2.10 微前端集成。状态管理最初的选择很自然:
- React 侧:
createContext+useContext,配合useReducer管理全局用户信息、权限、主题、面包屑、字典缓存。 - Vue 侧:
provide/inject,在根组件注入globalStore,子组件按需 inject。
问题在第 8 个迭代后集中爆发:
- React Context 渲染失控:任何 context value 变化,所有 consumer 全部重渲染。我们有一个
AppContext包含 user、theme、permissions、dict、breadcrumb 五个字段,改一个面包屑导致整个看板 200+ 组件重渲染。React DevTools Profiler 显示一次 breadcrumb 更新 commit 耗时 48ms。 - Vue inject 命名污染:不同模块都 inject
store,但期望的结构不同。有人 inject 到store.user,有人 inject 到store.userInfo,运行时才报 undefined。 - 热更新状态丢失:Vite HMR 时 Context Provider 重新挂载,所有状态归零,调试一个表单要重新填 10 个字段。
- 测试困难:Context 需要包裹多层 Provider 才能渲染组件,单测文件里
wrapper写了 30 行。
我们决定迁移到专门的状态管理库:React 用 Zustand 4.4.1,Vue 用 Pinia 2.1.7。选型理由后面说。
2. 环境与版本
{
"react": "18.2.0",
"react-dom": "18.2.0",
"zustand": "4.4.1",
"vue": "3.3.4",
"pinia": "2.1.7",
"vite": "4.4.5",
"typescript": "5.2.2",
"qiankun": "2.10.16"
}
Node 18.17.0,pnpm 8.7.0。两个子应用独立构建,共享一个 @shared/state 包存放类型定义。
3. 方案设计:为什么选 Zustand 和 Pinia
对比了 4 个候选:
| 方案 | React 侧 | Vue 侧 | 包体积 | 学习成本 | 迁移成本 |
|---|---|---|---|---|---|
| 继续 Context/Provide | - | - | 0 | 低 | 0 |
| Redux Toolkit + Vuex | RTK 1.9 | Vuex 4 | 13KB+ | 高 | 高 |
| Zustand + Pinia | 4.4.1 | 2.1.7 | 1.2KB+2KB | 低 | 中 |
| Jotai + Valtio | 2.4 | - | 3KB | 中 | 高 |
选 Zustand + Pinia 的核心原因:
- Zustand 没有 Provider:store 是外部对象,组件用
useStore(selector)订阅。只有 selector 返回值变化才重渲染,天然解决 Context 全量渲染问题。 - Pinia 是 Vue 官方推荐:
defineStore支持 setup 语法,类型推导比 Vuex 好太多,且storeToRefs解决解构丢失响应式问题。 - 迁移可以渐进:Zustand 允许在 Context 内部先用 store,再逐步删 Context。Pinia 可以按模块建 store,旧 inject 保留。
- 体积可接受:gzip 后 Zustand 1.2KB,Pinia 2KB,对后台项目无感。
4. 核心实现
4.1 React 侧:从 Context 到 Zustand
旧代码(简化):
// 旧:AppContext.tsx
const AppContext = createContext(null);
export function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [permissions, setPermissions] = useState([]);
const [breadcrumb, setBreadcrumb] = useState([]);
const [dict, setDict] = useState({});
const value = { user, setUser, theme, setTheme, permissions, setPermissions, breadcrumb, setBreadcrumb, dict, setDict };
return {children};
}
// 组件里
const { user, breadcrumb, setBreadcrumb } = useContext(AppContext);
问题:value 每次渲染都是新对象,所有 consumer 重渲染。
新代码:
// store/appStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface AppState {
user: User | null;
theme: 'light' | 'dark';
permissions: string[];
breadcrumb: string[];
dict: Record;
setUser: (u: User | null) => void;
setTheme: (t: 'light' | 'dark') => void;
setBreadcrumb: (b: string[]) => void;
}
export const useAppStore = create()(
subscribeWithSelector((set) => ({
user: null,
theme: 'light',
permissions: [],
breadcrumb: [],
dict: {},
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
setBreadcrumb: (breadcrumb) => set({ breadcrumb }),
}))
);
组件使用:
// 只订阅 breadcrumb
const breadcrumb = useAppStore((s) => s.breadcrumb);
const setBreadcrumb = useAppStore((s) => s.setBreadcrumb);
关键点:useAppStore 接受 selector,Zustand 内部用 Object.is 比较 selector 返回值。breadcrumb 变化时,只有订阅 breadcrumb 的组件重渲染。user 变化的组件不受影响。
迁移步骤:
- 新建
store/appStore.ts,把 Context 的 state 和 setter 全部搬进去。 - 保留
AppProvider但内部不再提供 state,只做初始化(比如登录后useAppStore.setState({ user }))。 - 逐个组件替换
useContext(AppContext)为useAppStore(selector)。用正则搜索useContext(AppContext)找到 47 处。 - 删除
AppContext和AppProvider,全局搜索确保无残留。
4.2 Vue 侧:从 Provide/Inject 到 Pinia
旧代码:
const store = reactive({
user: null,
theme: 'light',
permissions: [],
breadcrumb: [],
});
provide('globalStore', store);
const store = inject('globalStore');
// 有人写 store.user,有人写 store.userInfo,运行时才报错
新代码:
// stores/app.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAppStore = defineStore('app', () => {
const user = ref(null);
const theme = ref('light');
const permissions = ref([]);
const breadcrumb = ref([]);
const isAdmin = computed(() => permissions.value.includes('admin'));
function setUser(u: User | null) {
user.value = u;
}
function setBreadcrumb(b: string[]) {
breadcrumb.value = b;
}
return { user, theme, permissions, breadcrumb, isAdmin, setUser, setBreadcrumb };
});
组件使用:
import { storeToRefs } from 'pinia';
import { useAppStore } from '@/stores/app';
const appStore = useAppStore();
// 解构保持响应式
const { user, breadcrumb } = storeToRefs(appStore);
// 方法直接解构
const { setBreadcrumb } = appStore;
迁移步骤:
pnpm add pinia,在main.ts中app.use(createPinia())。- 按模块建 store:
stores/app.ts、stores/dict.ts、stores/permission.ts。 - 全局搜索
inject('globalStore'),替换为useAppStore()。共 32 处。 - 删除根组件的
provide调用。 - 对于
inject后解构的代码,统一加storeToRefs,否则丢失响应式。
5. 踩坑与优化
坑 1:Zustand selector 返回新对象导致无限重渲染
// 错误写法
const { user, theme } = useAppStore((s) => ({ user: s.user, theme: s.theme }));
每次返回新对象,Object.is 比较失败,组件无限重渲染。解决:用 useShallow 或分开写两个 selector。
import { useShallow } from 'zustand/react/shallow';
const { user, theme } = useAppStore(useShallow((s) => ({ user: s.user, theme: s.theme })));
坑 2:Pinia 解构丢失响应式
// 错误
const { user } = useAppStore();
// user 是普通值,后续变化不更新
必须用 storeToRefs。这个坑在迁移时出现 5 次,后来加了 ESLint 规则 pinia/no-destructure-store 禁止直接解构。
坑 3:qiankun 微前端下 store 隔离
两个子应用各自有 Zustand 和 Pinia 实例,主应用也有。用户信息在主应用登录后,子应用需要同步。方案:主应用通过 props 传递 setUser 方法,子应用在 mount 生命周期里调用自己的 useAppStore.setState。不要试图共享 store 实例,qiankun 的沙箱会代理 window,但 store 是模块级变量,共享会出诡异问题。
坑 4:Vite HMR 状态保留
Zustand 默认 HMR 会重置 store。加 import.meta.hot 处理:
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
useAppStore.setState(newModule.useAppStore.getState());
});
}
Pinia 在 Vite 下需要 pinia-plugin-persistedstate 或手动 acceptHMRUpdate:
import { acceptHMRUpdate } from 'pinia';
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAppStore, import.meta.hot));
}
优化:selector 粒度
最初 React 侧用 useAppStore((s) => s) 拿整个 store,等于没优化。后来改成按字段订阅,配合 useShallow。Vue 侧用 computed 派生 isAdmin,避免在模板里写 permissions.includes('admin')。
6. 效果数据
迁移前后用 React DevTools Profiler 和 Vue DevTools 对比,测试场景:切换面包屑 + 更新用户主题。
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| React 看板 commit 耗时 | 48ms | 18ms | -62% |
| React 重渲染组件数 | 203 | 12 | -94% |
| Vue 配置中心更新耗时 | 22ms | 9ms | -59% |
| 首屏 JS 体积(gzip) | 186KB | 189.2KB | +3.2KB |
| 单测 wrapper 行数 | 30 行 | 3 行 | -90% |
| HMR 状态保留 | 丢失 | 保留 | - |
包体积增加 3.2KB(Zustand 1.2KB + Pinia 2KB),但换来的渲染性能提升和开发体验完全值得。首屏时间从 1.8s 降到 1.75s,因为减少了 Provider 嵌套层级。
7. 总结
Context 和 Provide/Inject 适合低频、小范围的状态共享,比如主题、语言。一旦状态字段超过 3 个、消费组件超过 20 个、更新频率高于每秒 1 次,就应该考虑 Zustand 或 Pinia。
迁移的核心不是 API 替换,而是订阅粒度的重新设计。Context 是「推送模式」,Provider 变了所有 consumer 都收到;Zustand 和 Pinia 是「拉取模式」,组件自己声明需要什么。这个思维转变比写代码更重要。
如果重来一次,我会在项目初期就用 Zustand + Pinia,而不是等到 Context 嵌套 7 层才动手。迁移成本大约 3 人日,其中 1 天在改 selector 和 storeToRefs,1 天在测试,1 天在处理微前端和 HMR 的边界情况。建议在业务低峰期做,因为迁移过程中会短暂出现两套状态并存的混乱期。