一、问题背景:当Context成为性能瓶颈

去年接手的一个B端后台项目,最初用React 18 + Context API管理全局状态,后来由于团队技术栈调整,部分模块用Vue 3.5重写,出现了跨框架状态同步的噩梦。项目从MVP的12个页面膨胀到47个页面后,Context的value变化导致所有消费组件re-render的问题彻底爆发。

实际数据(Chrome Performance面板测量):
- 某个包含用户信息、权限、主题的Context,每次切换侧边栏菜单触发setState,导致43个子组件重渲染
- 首屏TTI(Time to Interactive)从1.2s恶化到3.8s
- 内存占用峰值达到89MB,在低端安卓设备上频繁触发GC

我们尝试过React.memo、useCallback、拆分Context,但很快发现Context本质上是“发布-订阅”模式,value一变,所有消费者都得执行。更致命的是跨框架通信——Vue模块和React模块共享一份登录态,需要手动在window上挂事件总线,代码丑且易错。

二、环境与版本:技术栈全景

React 18.2.0 + TypeScript 5.3.3 + Vite 5.0.0
Zustand 4.5.2(React侧)
Vue 3.5.1 + Pinia 2.1.7(Vue侧)
共用模块:@tanstack/react-query 5.x(服务端状态分离)

选型理由:
- Zustand:无Provider包裹,redux-devtools支持好,且支持在React组件外使用(对跨框架通信至关重要)
- Pinia:Vue 3官方推荐,配合Composition API比Vuex更直观
- 放弃Redux Toolkit:样板代码太多,且团队对Immer的mutate风格有争议

三、方案设计:双轨制状态层

核心思路是将“全局UI状态”与“服务端状态”彻底分离

┌─────────────────────────────────┐
│   Server State (React Query)    │ ← 接口缓存、轮询、乐观更新
├─────────────────────────────────┤
│   Client State (Zustand/Pinia)  │ ← 用户信息、权限、主题、UI开关
└─────────────────────────────────┘

关键设计点:
1. React侧Zustand store:用create函数定义,store内部持有setget,不依赖React上下文
2. Vue侧Pinia store:用defineStore定义,setup语法配合ref/computed
3. 跨框架桥接:写一个createBridge函数,用useSyncExternalStore(React 18)和watch(Vue 3)双向同步一个“最小状态切片”

四、核心实现:从Context到Zustand的迁移模板

4.1 React侧:Context → Zustand

原始Context代码(已简化为最小复现):

// 改造前:Context API
const AppContext = createContext({...});

export function AppProvider({children}) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = = useState('light');

  // 每次setUser,所有useContext(AppContext)的组件全部re-render
  return (

      {children}

  );
}

改造后Zustand store:

// store/authStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthState {
  user: User | null;
  permissions: string[];
  login: (user: User) => void;
  logout: () => void;
}

export const useAuthStore = create()(
  persist(
    (set) => ({
      user: null,
      permissions: [],
      login: (user) => set({ user, permissions: user.permissions }),
      logout: () => set({ user: null, permissions: [] }),
    }),
    { name: 'auth-storage' } // localStorage持久化
  )
);

// 组件中使用:只订阅user字段
const user = useAuthStore((state) => state.user);
// 不订阅user的组件不会re-render
const login = useAuthStore((state) => state.login);

迁移工具函数(用于自动化替换):

// scripts/migrate-context.ts (简化版)
import { parse, visit } from 'ts-ast-parser';

export function replaceContextWithStore(filePath: string) {
  const ast = parse(filePath);
  const replacements = new Map();

  visit(ast, {
    // 找到 useContext(AppContext) 调用
    CallExpression(path) {
      const callee = path.node.callee;
      if (callee.name === 'useContext' && path.node.arguments[0].name === 'AppContext') {
        // 从具体字段映射到useAuthStore((state) => state.xxx)
        const fieldName = path.parentPath.node.property.name;
        replacements.set(
          path.node.start, 
          `useAuthStore((state) => state.${fieldName})`
        );
      }
    }
  });

  // 输出替换后的代码
  return applyReplacements(filePath, replacements);
}

4.2 Vue侧:Props → Pinia

改造前Vue组件需要层层传递usertheme,改造后:

// stores/theme.ts
import { defineStore } from 'pinia';

export const useThemeStore = defineStore('theme', {
  state: () => ({
    theme: 'light' as 'light' | 'dark',
    sidebarCollapsed: false,
  }),
  actions: {
    toggleTheme() {
      this.theme = this.theme === 'light' ? 'dark' : 'light';
    },
  },
  getters: {
    isDark: (state) => state.theme === 'dark',
  },
});
import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/stores/theme';

const themeStore = useThemeStore();
// storeToRefs保证解构后仍是响应式
const { theme, sidebarCollapsed } = storeToRefs(themeStore);




    当前主题: {{ theme }} ({{ themeStore.isDark ? '暗色' : '亮色' }})

4.3 跨框架桥接(核心难点)

由于React和Vue的响应式原理不同,我们写了一个通用桥接层:

// shared/bridge.ts
import { useSyncExternalStore } from 'react';
import { watch, ref } from 'vue';

type StoreSnapshot = { version: number; data: T };

export function createCrossFrameworkBridge(initialData: T) {
  const sharedData = ref(initialData); // Vue响应式
  const listeners = new Set void>(); // React订阅
  let version = 0;

  // Vue侧更新 → 通知React
  watch(sharedData, (newVal) => {
    version++;
    listeners.forEach((listener) => listener());
  });

  return {
    // React 18的useSyncExternalStore
    useReactSnapshot(): T {
      return useSyncExternalStore(
        (callback) => {
          listeners.add(callback);
          return () => listeners.delete(callback);
        },
        () => ({ version, data: sharedData.value })
      ).data;
    },
    // Vue侧直接访问
    getVueRef() {
      return sharedData;
    },
    // 通用setter
    setData(newData: T) {
      sharedData.value = newData;
    }
  };
}

// 创建登录态桥接
export const authBridge = createCrossFrameworkBridge({
  user: null,
  token: '',
});

五、踩坑与优化:三个预想不到的坑

坑1:Zustand的shallow比较陷阱

一开始我们直接useAuthStore((state) => state)——这会导致store任何字段变化都触发re-render。后来改用useAuthStore(selector, shallow),但selector返回对象时shallow只做浅比较,内层对象引用变化仍会触发渲染。

解决:将selector拆细,或者用useShallowzustand/react/shallow导入。

坑2:Pinia的getter缓存问题

在Vue 3.5中,Pinia的getter会基于state自动缓存,但如果你在getter里返回一个新对象(如return this.items.filter(...)),每次访问都会重新计算。我们一个权限列表getter导致表格组件每帧都在重新过滤。

解决:在getter里用computed包一层,或者直接改为action在数据变化时手动更新。

坑3:React Compiler与Zustand的兼容性

试用React Compiler的react-compiler-runtime时,发现Zustand的useStore返回的函数会被Compiler错误地memorize,导致store更新不生效。最终放弃React Compiler,只保留babel-plugin-react-compilermemoize选项,并对关键组件手动React.memo

六、效果数据:迁移前后的量化对比

指标 迁移前 (Context/Props) 迁移后 (Zustand/Pinia) 变化
首屏TTI (中位数) 3.8s 2.2s ↓ 42%
菜单切换re-render数 43个组件 7个组件 ↓ 84%
内存峰值 (Chrome) 89MB 61MB ↓ 31%
代码行数 (全局状态相关) 2140行 1560行 ↓ 27%
跨框架同步延迟 事件总线+手动触发 桥接层自动同步 < 16ms

特别说明:TTI下降很大一部分来自React Query接管服务端状态,Context只占其中的50%左右。但re-render数的改善完全归功于Zustand的细粒度订阅。

七、总结与建议

  1. Context不是不能用:如果你的状态是低频更新(如主题、语言),且组件树不超过3层,Context完全够用。我们的问题是把高频状态(用户信息)和全局UI状态混在一起。
  2. 状态管理库不是银弹:Zustand的selector需要设计好,否则性能比Context还差。建议用useShallow + 细粒度selector。
  3. 跨框架通信:如果遇到React/Vue混合项目,别用事件总线,写一个基于useSyncExternalStore的桥接层,性能接近原生。
  4. 迁移要增量:我们花了2周时间,按“服务端状态 → 全局UI状态 → 跨模块共享状态”的顺序迁移,每次只改一个模块并跑完整测试。

最后,如果你也在做类似迁移,建议先用why-did-you-render库定位re-render热点,再决定是否值得迁移——别为了“用新技术”而重构。但如果你也面临40+页面、多技术栈、Context已经让开发效率下降,这篇文章的方法可以作为一个参考模板。

代码仓库已开源:github.com/your-project/state-migration-example(仅示例代码,不含业务逻辑)。有问题欢迎评论区交流。