1. 问题背景:当「提桶」变成「搬山」

事情始于我们维护的一个后台管理系统(React 18.2.0 + TypeScript 4.9)。这个项目有超过120个组件,用户登录信息、权限路由、主题配置、以及一个实时更新的“未读消息数”需要被超过30个组件共享。

最初的架构是典型的 Props + Context 方案:

// 典型的三层Context嵌套

问题在第三个月爆发:
- 性能瓶颈NotificationProvidervalue 对象中包含了 unreadCountsetUnreadCount。当 unreadCount 每秒更新一次时,所有消费 useContext(NotificationContext) 的组件(包括那些只需要 setUnreadCount 的按钮组件)全部重渲染。
- 维护地狱:新增一个页面需要先看这个页面属于哪个Provider层级,然后决定是传Props还是再包一层Context。代码审查时经常看到 props 里挂着5层以上的透传属性。
- 调试困难:React DevTools 里看组件树,全是 `` 包裹,无法直观看到状态来源。

与此同时,我们另一个Vue 3.3项目(Vite 5 + Pinia)虽然用 provide/inject 解决了部分问题,但同样存在 非响应式陷阱:一旦 inject 一个普通对象,修改它不会触发视图更新,必须引入 reactive 包裹,这导致代码心智负担极重。

2. 环境与版本:我们选择的武器

经过两周调研和Demo验证,我们确定了最终方案:

React项目:
- 框架:React 18.2.0 + TypeScript 4.9.5
- 状态库:Zustand v4.4.7(选择理由:无Provider包裹,支持 create 函数直接创建hook,且默认浅比较,避免不必要的重渲染)
- 构建工具:Vite 5.0(非CRA,因为我们需要自定义Chunk拆分)

Vue项目:
- 框架:Vue 3.3.4 + `` + TypeScript 5.2
- 状态库:Pinia v2.1.7(选择理由:官方推荐,DevTools插件完美,且store间可以互相调用)
- 构建工具:Vite 5.0

关键配置:在 vite.config.ts 中开启 optimizeDeps.include: ['zustand', 'pinia'] 预构建,减少首次加载解析时间。

3. 方案设计:统一的状态分层与原子化拆分

我们没有简单的一刀切替换,而是制定了 “三层状态架构”

第一层:服务端状态(用户信息、权限) → 直接使用 Zustand/Pinia store,但通过 `createJSONStorage` 持久化到 localStorage。
第二层:全局UI状态(主题、侧边栏折叠、通知数) → 拆分为独立的原子store。
第三层:临时共享状态(跨组件表单步骤) → 仍然使用 Props 或 provide/inject,但限定在局部作用域(如一个路由页面内)。

核心设计原则:
- React 中,一个store只负责一个领域,例如 useAuthStoreuseNotificationStore。严禁创建 useGlobalStore 大杂烩。
- Vue 中,Pinia 的 store 使用 setup 语法(类似组合式API),确保 TypeScript 推断完整。

4. 核心实现:迁移步骤与代码对比

4.1 React:从 Context 到 Zustand 的迁移(6步走)

步骤1:安装与创建store

// src/stores/notificationStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface NotificationState {
  unreadCount: number;
  lastUpdated: number;
  increase: (by: number) => void;
  reset: () => void;
}

// 使用 subscribeWithSelector 中间件,支持精确订阅
export const useNotificationStore = create()(
  subscribeWithSelector((set) => ({
    unreadCount: 0,
    lastUpdated: Date.now(),
    increase: (by) => set((state) => ({ 
      unreadCount: state.unreadCount + by,
      lastUpdated: Date.now() 
    })),
    reset: () => set({ unreadCount: 0, lastUpdated: Date.now() }),
  }))
);

步骤2:替换消费方(核心踩坑点)

原Context写法:

const { unreadCount } = useContext(NotificationContext);

Zustand写法(必须使用选择器,否则依然会重渲染):

// 正确写法:只订阅 unreadCount 字段
const unreadCount = useNotificationStore((state) => state.unreadCount);

// 错误写法:直接解构整个store,会导致任何state变化都触发组件更新
// const { unreadCount, increase } = useNotificationStore();

步骤3:处理异步Action——Zustand 的 set 是同步的,异步逻辑写在store外面或者使用 async 函数内调用 set

// 在组件中触发异步更新
const increaseAsync = async () => {
  await fetch('/api/notifications');
  useNotificationStore.getState().increase(1);
};

4.2 Vue:从 provide/inject 到 Pinia 的迁移

步骤1:定义store(使用setup语法,这是Vue 3.3推荐写法)

// src/stores/notification.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useNotificationStore = defineStore('notification', () => {
  const unreadCount = ref(0);
  const lastUpdated = ref(Date.now());
  const unreadCountPow = computed(() => unreadCount.value * 2);

  function increase(by: number) {
    unreadCount.value += by;
    lastUpdated.value = Date.now();
  }
  function reset() {
    unreadCount.value = 0;
    lastUpdated.value = Date.now();
  }

  return { unreadCount, lastUpdated, unreadCountPow, increase, reset };
});

步骤2:组件中替代 provide/inject

关键点:**Pinia store 在 ` 中解构会丢失响应性**,必须使用storeToRefs`:

import { storeToRefs } from 'pinia';
import { useNotificationStore } from '@/stores/notification';

const store = useNotificationStore();
// 必须使用 storeToRefs 解构,直接解构 store 会变成普通字符串
const { unreadCount, lastUpdated } = storeToRefs(store);
// 方法可以直接解构,因为方法不涉及响应式代理
const { increase } = store;



  {{ unreadCount }} 条未读,最后更新:{{ lastUpdated }}
  +

5. 踩坑与优化:那些文档里没写的事

坑1:Zustand v4 的 create 泛型需要双重调用
如果直接写 create()(fn) 会报 TS 错误,必须显式传入类型参数。这是 TypeScript 的柯里化限制,官方文档有说明,但很容易忽略。

坑2:Pinia 的 store 中引入 setTimeoutsetInterval 必须清理
我们在一个 store 中用了 setInterval 轮询,但组件销毁时没有 clearInterval,导致内存泄漏。正确做法是在 onUnmounted 中清理,或者使用 $onAction 钩子。

坑3:性能优化——避免过度订阅
Zustand 默认是浅比较,但如果组件中使用了 useNotificationStore() 不带选择器,依然会订阅整个store。我们用了一个自定义 useShallow 工具来避免:

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

const { unreadCount, lastUpdated } = useNotificationStore(
  useShallow((state) => ({ unreadCount: state.unreadCount, lastUpdated: state.lastUpdated }))
);

优化:DevTools 时间线分析
迁移后我们使用 React DevTools Profiler 和 Vue Devtools 进行对比。重点关注 渲染耗时组件更新次数

6. 效果数据:用数字说话

迁移完成后,我们对两个项目进行了压测(Chrome 120,CPU 4x slowdown模拟低端机):

React 项目(核心页面,包含实时通知列表)
- 首屏渲染时间:从 2.1s(Context版本) 降至 1.2s(Zustand版本),降低 42.8%。
- 每秒钟状态更新(模拟每秒1次通知)时的组件重渲染次数:从 45 次/秒 降至 15 次/秒,降低 66.7%。
- 内存占用:从 87.3MB 降至 68.1MB(因为不再需要多层Provider的嵌套对象)。

Vue 项目(管理后台,复杂表格+筛选联动)
- 交互响应延迟(输入筛选条件到表格更新):从 320ms 降至 180ms。
- 状态追踪性能:Vue Devtools 中记录一次状态变更的耗时从平均 12ms 降至 7ms。

代码量对比:React 项目中,删除 Context 相关文件 9 个,新增 store 文件 6 个,Props 透传属性减少了 70%。

7. 总结:工具是药,对症才有效

这次迁移最大的感悟是:状态管理库不是银弹。如果你的状态是纯局部且树状传递,Context/Props 依然是最简单直接的方案。但一旦出现 跨层级共享且频繁更新 的状态(比如实时通知、WebSocket数据),就必须引入具备 选择器订阅 能力的库。

技术选型建议
- 团队熟悉 React 且追求极简 API:选 Zustand(学习成本极低,且不需要 Provider 包裹,适合微前端)。
- 项目基于 Vue 3 且需要严格的状态历史追踪:选 Pinia(官方支持,DevTools 体验最好,且天然拥抱响应式)。
- 如果你的项目有严重的性能瓶颈,但不想引入库,可以考虑 React 18 的 useSyncExternalStoreVue 3 的 shallowRef 做局部优化,但边际成本较高,不如一步到位。

最终,两个项目稳定运行了 3 个月,未出现回归问题。迁移过程中最耗时的不是写代码,而是梳理业务中哪些状态是真正全局的。建议你在动手前,先花两天时间梳理状态依赖图——这比任何技术方案都重要。


附录:核心代码仓库结构参考

src/
├── stores/
│   ├── authStore.ts          # React Zustand
│   ├── notificationStore.ts  # React Zustand
│   └── notification.ts       # Vue Pinia
└── utils/
    └── performanceMonitor.ts # 性能打点工具