1. 问题背景:Context 和 Provide 不是万能药

项目是一个运营后台,React 18.2 负责数据看板,Vue 3.3 负责配置中心。两个子应用通过 qiankun 2.10 微前端集成。状态管理最初的选择很自然:

  • React 侧:createContext + useContext,配合 useReducer 管理全局用户信息、权限、主题、面包屑、字典缓存。
  • Vue 侧:provide / inject,在根组件注入 globalStore,子组件按需 inject。

问题在第 8 个迭代后集中爆发:

  1. React Context 渲染失控:任何 context value 变化,所有 consumer 全部重渲染。我们有一个 AppContext 包含 user、theme、permissions、dict、breadcrumb 五个字段,改一个面包屑导致整个看板 200+ 组件重渲染。React DevTools Profiler 显示一次 breadcrumb 更新 commit 耗时 48ms。
  2. Vue inject 命名污染:不同模块都 inject store,但期望的结构不同。有人 inject 到 store.user,有人 inject 到 store.userInfo,运行时才报 undefined。
  3. 热更新状态丢失:Vite HMR 时 Context Provider 重新挂载,所有状态归零,调试一个表单要重新填 10 个字段。
  4. 测试困难: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 变化的组件不受影响。

迁移步骤:

  1. 新建 store/appStore.ts,把 Context 的 state 和 setter 全部搬进去。
  2. 保留 AppProvider 但内部不再提供 state,只做初始化(比如登录后 useAppStore.setState({ user }))。
  3. 逐个组件替换 useContext(AppContext)useAppStore(selector)。用正则搜索 useContext(AppContext) 找到 47 处。
  4. 删除 AppContextAppProvider,全局搜索确保无残留。

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;

迁移步骤:

  1. pnpm add pinia,在 main.tsapp.use(createPinia())
  2. 按模块建 store:stores/app.tsstores/dict.tsstores/permission.ts
  3. 全局搜索 inject('globalStore'),替换为 useAppStore()。共 32 处。
  4. 删除根组件的 provide 调用。
  5. 对于 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 的边界情况。建议在业务低峰期做,因为迁移过程中会短暂出现两套状态并存的混乱期。