一、问题背景:Context 不是状态管理库

我们是一个中后台系统,前端同时存在 React 18.2 和 Vue 3.3 两套子应用(历史原因,别问)。状态层最初很“朴素”:

  • React 侧:UserContext + ThemeContext + PermissionContext,外加 20 多个 Props 层层透传。
  • Vue 侧:provide/inject + reactive 全局单例。

问题在 2023 年 Q4 集中爆发:

  1. Context 值一变,所有消费组件全量重渲染UserContext 里放了一个 { user, permissions, settings } 对象,改个主题色会导致 80+ 组件重渲染。
  2. Props 透传链路长达 6 层,中间组件被迫接收自己根本不用的 props,类型定义越来越脏。
  3. 跨框架状态无法共享。登录态在 React 里改了,Vue 子应用要刷新页面才能同步。

我们决定迁移到专门的状态管理库。选型不是拍脑袋,下面是真实对比。

二、环境与版本

  • React 18.2.0 + TypeScript 5.2.2 + Vite 4.4.9
  • Vue 3.3.4 + TypeScript 5.2.2 + Vite 4.4.9
  • 候选库:
  • React:Zustand 4.4.1、Jotai 2.4.2、Redux Toolkit 1.9.5
  • Vue:Pinia 2.1.6
  • 测试设备:MacBook Pro M1 16G,Chrome 118,禁用缓存,Performance 面板采样 5 次取中位数。

三、方案设计:为什么最终选 Zustand + Pinia

React 侧我们做了 3 个维度的对比:

维度 Context Redux Toolkit Jotai Zustand
重渲染粒度 全量 需 useSelector 精细选择 原子级 selector 级
样板代码 多(slice/thunk) 极少
包体积(gzip) 0 12.6KB 4.1KB 1.2KB
异步处理 手写 createAsyncThunk 手写 直接 async
学习成本

Jotai 的原子模型很优雅,但我们的状态大多是“领域对象”(user、permission、dict),不是细粒度原子,用 Jotai 反而要拆得很碎。Redux Toolkit 太重,团队已经受够了 action type。最终选 Zustand 4.4.1:selector 订阅、无 Provider 包裹、1.2KB。

Vue 侧没有悬念,Pinia 2.1.6 是官方推荐,且支持 setup 语法,和 Composition API 心智一致。

四、核心实现

4.1 React:从 Context 迁移到 Zustand

迁移前的 Context 写法:

// before: UserContext.tsx
const UserContext = createContext(null);

export function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState(null);
  const [permissions, setPermissions] = useState([]);
  const [theme, setTheme] = useState('light');

  // 注意:value 每次渲染都是新对象,导致所有消费者重渲染
  return (

      {children}

  );
}

迁移后的 Zustand store:

// after: useUserStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface UserState {
  user: User | null;
  permissions: string[];
  theme: 'light' | 'dark';
  setUser: (u: User | null) => void;
  setTheme: (t: 'light' | 'dark') => void;
  hasPermission: (code: string) => boolean;
}

export const useUserStore = create()(
  subscribeWithSelector((set, get) => ({
    user: null,
    permissions: [],
    theme: 'light',
    setUser: (user) => set({ user, permissions: user?.permissions ?? [] }),
    setTheme: (theme) => set({ theme }),
    hasPermission: (code) => get().permissions.includes(code),
  }))
);

组件里按需订阅,关键是 selector 要返回原始值,不要返回新对象:

// 正确:只订阅 theme,theme 不变就不重渲染
const theme = useUserStore((s) => s.theme);
const setTheme = useUserStore((s) => s.setTheme);

// 错误示范:每次返回新数组,等于没优化
// const perms = useUserStore((s) => s.permissions.filter(Boolean));

需要返回派生对象时,用 useShallow

import { useShallow } from 'zustand/react/shallow';

const { user, theme } = useUserStore(
  useShallow((s) => ({ user: s.user, theme: s.theme }))
);

4.2 Vue:从 provide/inject 迁移到 Pinia

迁移前的全局单例:

// before: globalStore.ts
import { reactive } from 'vue';
export const globalStore = reactive({
  user: null as User | null,
  theme: 'light' as 'light' | 'dark',
});

迁移后的 Pinia store:

// after: stores/user.ts
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';

export const useUserStore = defineStore('user', () => {
  const user = ref(null);
  const theme = ref('light');
  const permissions = computed(() => user.value?.permissions ?? []);

  async function login(payload: LoginParams) {
    const res = await api.login(payload);
    user.value = res.data;
    theme.value = res.data.theme;
  }

  function hasPermission(code: string) {
    return permissions.value.includes(code);
  }

  return { user, theme, permissions, login, hasPermission };
});

Vue 侧要注意用 storeToRefs 解构,否则丢失响应性:

import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';

const userStore = useUserStore();
const { user, theme } = storeToRefs(userStore); // 保持响应式
const { hasPermission } = userStore; // 方法直接解构

4.3 迁移步骤(我们实际执行的顺序)

  1. 冻结新功能 2 天,先写 3 个 store 的 PoC,跑通登录态和主题切换。
  2. 建立 store 目录规范src/stores/{domain}.ts,一个领域一个 store,禁止跨领域直接调用,需要组合时用 useXxxStore() 在 action 内调用。
  3. 逐个 Context 替换,每替换一个就跑一次单测 + E2E(Playwright)。
  4. 删除 Props 透传:用 codemod 找出 6 层以上的透传链,优先处理 themeuserpermission 三类。
  5. 清理 Context Provider,最后删掉 App.tsx 里嵌套的 5 层 Provider。
  6. 加 ESLint 规则:禁止在组件外直接 useUserStore.getState() 修改状态,必须走 action。

整个迁移用了 9 个工作日,37 个状态点,改动 142 个文件。

五、踩坑与优化

坑 1:Zustand selector 返回新对象导致无限重渲染。
最初写 useUserStore((s) => ({ user: s.user })),每次返回新对象,React 认为变了,触发重渲染,配合 useEffect 直接死循环。解决:用 useShallow 或拆成多个单值 selector。

坑 2:Pinia 解构丢失响应性。
const { user } = useUserStore() 拿到的是一次性快照,user 变了视图不更新。必须 storeToRefs。这个坑我们组三个人都踩过,后来直接写进 Code Review checklist。

坑 3:跨框架状态同步。
React 和 Vue 子应用通过 iframe 通信,登录态用 postMessage 同步。后来改成主应用持有 token,子应用通过 window.__SHARED_STATE__ 读取 + storage 事件监听,减少了 300ms 的同步延迟。

坑 4:Zustand 持久化导致首屏闪烁。
persist 中间件存 localStorage,首屏会先渲染默认值再渲染持久化值。解决:加 skipHydration: false 并配合 onRehydrateStorage 设置 loading 态。

坑 5:Pinia 在 SSR 下的状态污染。
虽然我们是 SPA,但预渲染时 Pinia 单例会跨请求共享。解决:每个请求 createPinia() 新实例。

优化手段:
- Zustand 开启 subscribeWithSelector,配合 useEffect 做细粒度副作用。
- Pinia 的 computed 缓存派生状态,避免在模板里重复计算。
- 用 React DevTools Profiler 和 Vue DevTools 的 Performance 面板定位剩余重渲染。

六、效果数据

迁移前后对比(5 次采样中位数):

指标 Context/Props Zustand + Pinia 变化
首屏渲染 (FCP) 1.8s 1.1s -38.9%
主题切换重渲染组件数 82 31 -62.2%
登录态更新重渲染组件数 96 12 -87.5%
状态层包体积 (gzip) 0 3.2KB +3.2KB
状态相关 Bug(月均) 7 2 -71.4%
单测覆盖率(store 层) 41% 86% +45pp

首屏提升主要来自删掉了 5 层 Provider 嵌套和大量无意义重渲染。包体积只增加 3.2KB,因为 Zustand 1.2KB + Pinia 2.0KB,换来的是可维护性的大幅提升。

七、总结

Context 和 provide/inject 适合低频、小范围的跨层级通信,不适合做全局状态管理。当你的 Context value 是个对象、消费者超过 20 个、或者 Props 透传超过 4 层时,就该考虑迁移了。

选型上,React 侧 Zustand 的 selector 模型和 1.2KB 体积是性价比之选;Vue 侧 Pinia 是官方答案,没有理由不用。迁移不要一次性全改,按领域逐个替换,每步都有测试兜底。

最后一句忠告:状态管理库不是银弹,selector 写不对,Zustand 一样全量重渲染。 迁移只是起点,真正的优化在于理解订阅粒度和渲染边界。