一、问题背景:Context 和 Props 不是万能药

我们做的是一套同时包含 React 18 和 Vue 3 子应用的后台系统。React 侧最早用 Context + useReducer 管理全局状态,Vue 侧用 provide/inject + props。小规模时没问题,但随着权限、主题、用户信息、表单草稿这些状态越来越多,问题集中爆发:

  1. React Context 的“全量广播”:只要 Context value 变了,所有 Consumer 都会重新渲染。我们的 AppContext 里同时放了用户信息、主题、侧边栏折叠状态,改一个主题色,整个页面 47 个组件重渲染。
  2. Props drilling 太深:一个 Table 组件要把 onRowSelect 从页面传到第 5 层子组件,中间组件根本不关心这个 prop,但每次父级更新都要跟着渲染。
  3. Vue 侧 provide/inject 响应式丢失:用 reactive 提供对象后,子组件解构后失去响应性,排查了半天。
  4. 性能数据:Lighthouse 测首屏 TTI 2.8s,React DevTools Profiler 显示一次主题切换 commit 耗时 340ms。

这时候我决定迁移到专门的状态管理库:React 用 Zustand 4.5.2,Vue 用 Pinia 2.1.7。

二、环境与版本

  • React 18.2.0 + TypeScript 5.3 + Vite 5.0
  • Vue 3.4.21 + TypeScript 5.3 + Vite 5.0
  • 原状态方案:React Context + useReducer,Vue provide/inject + props
  • 目标方案:Zustand 4.5.2(React),Pinia 2.1.7(Vue)
  • 测试工具:React DevTools Profiler、Vue DevTools、Lighthouse 11.0

选 Zustand 而不是 Redux Toolkit,是因为我们不需要时间旅行调试和 middleware 生态,Zustand 包体积 1.2KB gzip,API 更接近 hooks。Pinia 则是 Vue 官方推荐,和 Vue 3 组合式 API 契合。

三、方案设计:按状态类型拆分 Store

迁移不是把所有 Context 都搬进 Store,而是先分类:

  • 服务端状态:用户列表、订单数据 → 继续用 React Query / Vue Query,不放入全局 Store。
  • 全局客户端状态:用户信息、主题、权限、侧边栏 → 放入 Store。
  • 局部状态:表单输入、弹窗开关 → 留在组件内 useState/ref。

Store 拆分原则:按业务域拆,不按组件拆。React 侧建了 useUserStoreuseThemeStoreusePermissionStore;Vue 侧对应 userStorethemeStorepermissionStore

关键设计:选择器粒度要细。Zustand 的 useStore(state => state.xxx) 只在选中值变化时触发重渲染,这是性能提升的核心。

四、核心实现:代码与迁移步骤

4.1 React 侧:从 Context 到 Zustand

原来的 Context:

// 旧代码:AppContext.tsx
const AppContext = createContext(null);

export function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [collapsed, setCollapsed] = useState(false);
  // value 每次都是新对象,所有 Consumer 重渲染
  return (

      {children}

  );
}

迁移到 Zustand:

// 新代码:stores/useAppStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface AppState {
  user: User | null;
  theme: 'light' | 'dark';
  collapsed: boolean;
  setUser: (u: User | null) => void;
  setTheme: (t: 'light' | 'dark') => void;
  toggleCollapsed: () => void;
}

export const useAppStore = create()(
  subscribeWithSelector((set) => ({
    user: null,
    theme: 'light',
    collapsed: false,
    setUser: (user) => set({ user }),
    setTheme: (theme) => set({ theme }),
    toggleCollapsed: () => set((s) => ({ collapsed: !s.collapsed })),
  }))
);

组件里按需选择:

// 只订阅 theme,user 变化不会触发这个组件重渲染
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);

迁移步骤:

  1. 新建 stores 目录,按域建文件。
  2. 把 Context 里的 state 和 action 平移到 Store。
  3. 全局搜索 useContext(AppContext),替换为对应的 useAppStore(selector)
  4. 删除 Provider 包裹,减少组件树层级。
  5. subscribeWithSelector 处理需要在 Store 外监听变化的场景,比如主题变化时更新 document.documentElement.dataset.theme

4.2 Vue 侧:从 provide/inject 到 Pinia

原来的 provide/inject:

// 旧代码:App.vue
const appState = reactive({ user: null, theme: 'light' });
provide('appState', appState);
// 子组件解构后失去响应性
const { theme } = inject('appState');

迁移到 Pinia:

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

export const useUserStore = defineStore('user', () => {
  const user = ref(null);
  const isAdmin = computed(() => user.value?.role === 'admin');

  async function fetchUser() {
    const res = await api.getUser();
    user.value = res.data;
  }

  return { user, isAdmin, fetchUser };
});

组件中使用:

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

const userStore = useUserStore();
// 用 storeToRefs 保持响应性,直接解构会丢失
const { user, isAdmin } = storeToRefs(userStore);

迁移步骤:

  1. 安装 Pinia,在 main.tsapp.use(createPinia())
  2. 按域建 store,用 setup 语法写。
  3. 替换 injectuseXxxStore()
  4. 注意:需要响应式的 state 用 storeToRefs,action 直接解构没问题。
  5. 删除 provide 调用。

五、踩坑与优化

坑 1:Zustand 选择器返回新对象导致无限重渲染。

// 错误:每次返回新对象,引用变化触发重渲染
const { user, theme } = useAppStore((s) => ({ user: s.user, theme: s.theme }));

// 正确:用 useShallow 或分开选择
import { useShallow } from 'zustand/react/shallow';
const { user, theme } = useAppStore(useShallow((s) => ({ user: s.user, theme: s.theme })));

坑 2:Pinia 解构丢失响应性。 必须用 storeToRefs,这个和 Vue 的 toRefs 一个道理。

坑 3:Store 里放服务端数据导致缓存逻辑复杂。 我们一开始把订单列表放进 Pinia,结果要自己写 loading、error、缓存失效。后来全部挪回 Vue Query,Store 只留客户端状态。

优化:持久化只持久化必要字段。 用户信息和主题用 persist 中间件存 localStorage,权限列表不存,避免权限变更后本地脏数据。

export const useThemeStore = create(
  persist(
    (set) => ({ theme: 'light', setTheme: (theme) => set({ theme }) }),
    { name: 'theme-storage', partialize: (s) => ({ theme: s.theme }) }
  )
);

六、效果数据

迁移前后用同一套测试用例(10 个页面,含表格、表单、图表):

指标 迁移前 迁移后
主题切换重渲染组件数 47 6
主题切换 commit 耗时 340ms 28ms
首屏 TTI 2.8s 1.3s
首屏 JS 体积(gzip) 186KB 189KB
代码行数(状态相关) 约 1200 行 约 780 行

JS 体积略增 3KB,因为引入了 Zustand 和 Pinia,但换来的渲染性能提升明显。代码行数减少是因为删掉了大量 Provider 嵌套和 props 透传。

七、总结

Context 和 provide/inject 适合低频、小范围的全局状态,比如主题、语言。一旦状态更新频繁、消费者众多,就该考虑 Zustand 或 Pinia。迁移的关键不是换 API,而是先分类状态:服务端状态交给 React Query / Vue Query,客户端全局状态才进 Store,局部状态留在组件。选择器粒度决定性能上限,Zustand 的 useShallow 和 Pinia 的 storeToRefs 是必须掌握的两个细节。如果你的项目也出现了“改一个值全页面重渲染”,不妨按这个思路试一次,收益比想象中大。