一、为什么Context和Props不再够用

在项目早期(约200个组件,5个业务模块),我们使用React Context + Vue Provide/Inject + Props透传来管理全局状态。这种组合开发速度快,无需引入第三方库。但当项目膨胀到2000+组件、20+业务模块时,问题集中爆发:

  1. Context的穿透式重渲染:任何一个Context value变化,所有消费该Context的组件(无论是否使用了变化的数据)都会触发re-render。在React DevTools中,我们观察到一次简单的用户权限切换,导致了320个无关组件的重新渲染,耗时约340ms。
  2. Vue Provide/Inject的响应式断裂:Provide注入的响应式对象在深层组件中丢失了响应式追踪,导致数据更新但视图不更新的诡异bug。
  3. 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之所以能解决性能问题,核心在于:

  1. 细粒度的订阅机制:不再需要整个子树重渲染,只有真正使用某段状态的组件才更新。
  2. 框架无关的架构:状态逻辑可以在React和Vue之间复用,甚至可以在纯JS中使用。
  3. 中间件生态:devtools、persist、immer等中间件让调试和持久化变得简单。

如果你还在犹豫要不要迁移,建议用React Profiler或Vue Devtools的性能面板跑一下:如果Context更新导致超过50个无关组件重渲染,或者任意Props传递超过3层,就是迁移的信号

最后给一个忠告:不要一次性迁移所有状态。先挑出「更新频繁且影响范围大」的状态(如用户权限、主题、语言),尝到甜头后再逐步替换低频状态。