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

我先描述一下我们的项目状况:一个B端数据可视化平台,左侧菜单、顶部筛选栏、中间内容区、右侧详情抽屉。状态涉及用户信息、筛选条件、表格数据、主题配置、多语言等。最初我们采用的是最朴素的Props Drilling + Context组合。

在React 19中,我们使用use(Context)useReducer管理全局状态。但问题在迭代到第20个页面时集中爆发:

  1. 无效渲染:任何筛选条件的变更,都会导致所有消费Context的组件重渲染。使用React Profiler测量,一次theme切换导致42个组件重渲染,其中25个与主题无关。
  2. 代码侵入:为了减少props钻取,我们不得不将组件拆得更细,但反向导致props数量爆炸——最深的一个组件接收了11层props。
  3. 并发特性失效:React 19的useTransition在处理筛选输入时,因为Context没有细粒度的订阅机制,导致startTransition内部的更新仍然会阻塞渲染。

Vue 3.5那边的情况类似:使用provide/inject后,虽然语法简洁,但依赖注入的响应式数据一旦在深层组件被修改,由于Vue的响应式系统是细粒度的,性能问题没有React那么严重,但代码的可读性和类型推导变得极差——我们不得不为每个inject写一堆as断言。

核心痛点总结
- 渲染性能:Context导致的重渲染范围不可控
- 开发体验:Props类型定义冗余,Context消费代码样板化
- 调试成本:状态变更链路模糊,难以追踪

二、环境与版本:我们站在哪个版本上

  • React项目:React 19.0.0 (使用use hook),Vite 6.0.0,TypeScript 5.6.0
  • Vue项目:Vue 3.5.13 (引入useTemplateRef和响应式props解构),Vite 6.0.0
  • 迁移目标:React侧选择 Zustand 5.0.2(因为其体积小,约3.2KB gzipped,且支持React 19的useSyncExternalStore);Vue侧选择 Pinia 3.0.1(官方推荐,与Vue Devtools集成好)。

选择Zustand而非Redux Toolkit的原因:我们不需要不可变更新的严格约束,且Redux的样板代码量在已有业务中改造风险太大。选择Pinia因为它是Vue 3官方生态的一部分,且对TypeScript的支持是开箱即用的。

三、方案设计:为什么是Zustand与Pinia,而不是Jotai或Vuex?

React侧方案对比

方案 重渲染控制 样板代码 学习成本 备注
Context+useReducer 差(需手动memo) 现状
Redux Toolkit 不适合快速重构
Zustand 优(selector自动订阅) 最终选择

Zustand的核心优势是状态与视图分离——组件通过useStore(selector)订阅,只有selector返回值变化才会触发重渲染。这完美解决了Context的痛点。

Vue侧方案对比
provide/inject在Vue 3.5中已经很好用了,但主要问题是类型推导在inject时容易丢失。Pinia则提供了完美的类型安全,且其store的$subscribe方法可以精确追踪变更。

架构设计
- React侧:将全局状态拆分为useAuthStoreuseFilterStoreuseThemeStore三个独立的store,避免单一store过大导致的状态竞争。
- Vue侧:使用两个Pinia store——useUserStoreuseLayoutStore。其中useLayoutStore管理抽屉、菜单折叠等UI状态。

四、核心实现:从Context到Store的代码改造

4.1 React 19:Context代码改造为Zustand 5

迁移前(Context+useReducer)

// ThemeContext.tsx (改造前)
const ThemeContext = createContext void } | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(themeReducer, { theme: 'light' });
  // 此处useMemo是为了减少无效渲染,但依然无法精准控制
  const value = useMemo(() => ({
    theme: state.theme,
    toggle: () => dispatch({ type: 'toggle' })
  }), [state.theme]);
  return {children};
}

// 消费组件
export function ThemeButton() {
  const { theme, toggle } = use(ThemeContext); // React 19的use
  return {theme};
}

迁移后(Zustand 5)

// stores/themeStore.ts (改造后)
import { create } from 'zustand';

interface ThemeState {
  theme: 'light' | 'dark';
  toggle: () => void;
  setTheme: (theme: 'light' | 'dark') => void;
}

export const useThemeStore = create((set) => ({
  theme: 'light',
  toggle: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
  setTheme: (theme) => set({ theme }),
}));

// 消费组件 (只需要一行hook)
export function ThemeButton() {
  // 关键:selector精确指定依赖,只有theme变化时才会重渲染
  const theme = useThemeStore((state) => state.theme);
  const toggle = useThemeStore((state) => state.toggle);
  return {theme};
}

关键点:Zustand的useStore底层使用了useSyncExternalStore,React 19中这是并发渲染安全的。迁移后我删掉了所有用于防止Context重渲染的React.memo,组件代码量减少了约20%。

4.2 Vue 3.5:provide/inject改造为Pinia 3

迁移前(provide/inject)

import { provide, ref } from 'vue';
// 类型定义麻烦,每次inject都要断言
const sidebarCollapsed = ref(false);
provide('sidebar', {
  collapsed: sidebarCollapsed,
  toggle: () => sidebarCollapsed.value = !sidebarCollapsed.value
});




import { inject } from 'vue';
// 类型丢失,需要使用接口断言
const { collapsed, toggle } = inject('sidebar') as { collapsed: Ref; toggle: () => void };

迁移后(Pinia 3)

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

export const useLayoutStore = defineStore('layout', {
  state: () => ({
    sidebarCollapsed: false,
    drawerVisible: false,
    activeMenu: '/dashboard'
  }),
  actions: {
    toggleSidebar() {
      this.sidebarCollapsed = !this.sidebarCollapsed;
    },
    openDrawer() {
      this.drawerVisible = true;
    }
  }
});
import { useLayoutStore } from '@/stores/layout';
// 自动类型推导,无需断言
const layoutStore = useLayoutStore();
// 使用storeToRefs保持响应性
const { sidebarCollapsed } = storeToRefs(layoutStore);

Pinia的storeToRefs解决了Vue 3中解构store会丢失响应性的问题,这是比provide/inject更优雅的API设计。

五、踩坑与优化:迁移过程中的三个深坑

坑1:Zustand的Selector导致的内存泄漏(React)
在Zustand v5中,如果selector返回一个新对象(例如(state) => ({ a: state.a, b: state.b })),会导致无限循环渲染,因为每次比较的都是新引用。解决方法是使用useShallow

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

// 错误写法:返回新对象
const { a, b } = useThemeStore((state) => ({ a: state.a, b: state.b }));

// 正确写法
const { a, b } = useThemeStore(
  useShallow((state) => ({ a: state.a, b: state.b }))
);

坑2:Pinia的Store嵌套调用(Vue)
在Pinia的action中调用另一个store的action时,必须在函数内部获取store实例,不能在外面顶层获取(防止SSR和测试时的store实例污染):

export const useUserStore = defineStore('user', {
  actions: {
    async login(payload) {
      // 错误:在外部获取
      // const layoutStore = useLayoutStore();

      // 正确:在action内部获取
      const layoutStore = useLayoutStore();
      layoutStore.openDrawer();
      // ...登录逻辑
    }
  }
});

坑3:Vue 3.5的useTemplateRef与Pinia的联动
在Vue 3.5中,我们使用useTemplateRef获取DOM节点,但如果在v-for中与Pinia的列表数据联动,需要注意模板ref的更新时机nextTick在Vue 3.5中行为有微调,建议使用:

await nextTick();
// 此时DOM已更新,但Pinia的state可能还在pending
// 需要使用flush: 'post'来确保
const layoutStore = useLayoutStore();
await layoutStore.$patch({ activeMenu: '/new' });
await nextTick(); // 再等待一次

六、效果数据:性能与代码量的实测对比

性能测试环境:Chrome 131,MacBook Pro M1 Pro,无GPU加速干扰。

React项目(Zustand迁移后)
- 首屏交互延迟:从420ms降至180ms(React Profiler的Render Duration平均值),降幅57%。
- 重渲染组件数:切换主题时,从42个组件重渲染降至4个(仅主题相关按钮和样式容器)。
- 内存占用:快照对比,从87.3MB降至72.1MB,因为移除了大量React.memouseCallback的闭包引用。
- 包体积:Zustand gzipped 3.2KB比Context方案(本身无库)多3.2KB,但比Redux Toolkit的14KB小了一个量级。

Vue项目(Pinia迁移后)
- 组件更新次数:在Devtools的Performance面板中,筛选条件变化触发的组件更新从31次/秒降至12次/秒
- FPS:拖动图表时,FPS从38帧提升至稳定60帧
- 代码量composable目录下的代码量减少了约30%,因为不再需要为每个inject写类型断言和工厂函数。

团队开发效率
- 新成员上手时间从3天缩短至1天,因为Zustand的create和Pinia的defineStore的API是自解释的。
- Code Review中关于状态管理的评论数量下降了80%

七、总结:迁移的终极建议

如果你也面临同样的状态管理混乱问题,我的建议是:

  1. 不要盲目迁移:如果项目少于20个页面,且组件树深度小于5,用Context和provide/inject完全没问题。我们的问题出在深度嵌套+频繁交互
  2. React侧优先选Zustand:它是对React 19的useSyncExternalStore的最佳封装,没有之一。如果你需要更严苛的不可变约束,才考虑Redux Toolkit。
  3. Vue侧直接上Pinia:Vuex 4在Vue 3中已经是维护模式,Pinia是官方推荐且类型支持更好。
  4. 迁移策略不要一次性替换所有Context。推荐的做法是:先为一个状态(如主题)建一个store,替换掉对应Context,运行两周观察无回归后,再迁移下一个状态。我们整个迁移耗时3周,没有出现线上故障。

最后,状态管理的本质是管理状态变更的影响范围。Zustand和Pinia通过selector和store模块化,把影响范围控制在最小粒度。这比任何性能优化技巧都重要。希望这篇记录对你有用。