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

我维护的项目是一个电商后台,React部分负责订单管理模块,Vue部分负责用户权限面板。最初为了快速迭代,我们使用了最基础的React Context + useReducer,以及Vue的provide/inject + reactive。

但随着业务增长,问题迅速暴露:

  • React侧:订单列表页有`等12个子组件,它们共享筛选条件、排序字段、当前页数。初期用OrderContext包裹整个页面,任何筛选条件变化都会导致所有消费useContext的组件重新渲染。即使使用React.memo,由于context value`每次都是新对象,memo直接失效。
  • Vue侧:权限面板的role状态被30多个组件注入,修改角色时,DevTools显示依赖追踪的trigger数量超过200个,导致Vue响应式系统的依赖收集和触发开销显著。

核心痛点:状态共享逻辑与UI渲染逻辑耦合,无法精确控制更新粒度。

二、环境与版本:明确技术栈基线

在动手前,先确认项目环境(这决定了选型范围):

React: 18.3.1(后续升级至19.0.0测试)
Next.js: 14.2.5
Vue: 3.5.13
Pinia: 2.3.1
Zustand: 5.0.3
TypeScript: 5.6.3
构建工具: Vite 5.4.2

为什么选Zustand和Pinia?
- Zustand:体积仅2.9KB(gzip),无Provider包裹,利用useSyncExternalStore(React 18+)精确订阅,避免过度渲染。
- Pinia:Vue 3官方推荐,基于reactive但通过storeToRefsmapStores实现细粒度响应,且天然支持DevTools时间旅行。

对比过Redux Toolkit(RTK)和Vuex后,我认为RTK的样板代码量对本项目过于冗余,Vuex的Mutation在TS下类型推导不够顺畅。

三、方案设计:分层迁移,避免一锅端

我没有选择“删掉旧代码一次性重写”,而是采用渐进式替换策略,分三个阶段:

  1. 阶段A(React侧):将OrderContext拆分为三个独立的Zustand store(筛选store、列表数据store、UI状态store),组件按需订阅。
  2. 阶段B(Vue侧):将provide/inject替换为Pinia,但保留旧的注入逻辑作为兼容层(通过computed包装store状态)。
  3. 阶段C:移除所有Context/Props drilling的残留代码,清理废弃的createContext调用。

关键设计原则:store按业务领域垂直拆分,避免一个全局store变成新的“上帝对象”

四、核心实现:Zustand与Pinia的代码对比

4.1 React侧:Zustand store拆分与选择器

// stores/orderFilter.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface FilterState {
  keyword: string;
  status: 'pending' | 'shipped' | 'completed';
  page: number;
  pageSize: 10 | 20 | 50;
  setKeyword: (kw: string) => void;
  setStatus: (s: FilterState['status']) => void;
  setPage: (p: number) => void;
}

export const useOrderFilterStore = create()(
  devtools(
    (set) => ({
      keyword: '',
      status: 'pending',
      page: 1,
      pageSize: 20,
      // 使用set替换整个状态,但通过shallow比较避免无关更新
      setKeyword: (kw) => set((state) => ({ ...state, keyword: kw, page: 1 })),
      setStatus: (s) => set((state) => ({ ...state, status: s, page: 1 })),
      setPage: (p) => set((state) => ({ ...state, page: p })),
    }),
    { name: 'order-filter' }
  )
);

// 组件中精确订阅:
const keyword = useOrderFilterStore((s) => s.keyword); // 只订阅keyword
const setKeyword = useOrderFilterStore((s) => s.setKeyword); // 方法引用稳定

关键优化:在OrderTable中,我们不订阅整个store,而是通过useShallow选择所需的多值:

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

const { status, page, pageSize } = useOrderFilterStore(
  useShallow((s) => ({ status: s.status, page: s.page, pageSize: s.pageSize }))
);

如果没有useShallow{ status, page, pageSize }每次返回新对象会导致无限渲染(这是Zustand新手常见坑)。

4.2 Vue侧:Pinia store定义与组件消费

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

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    role: 'viewer' as 'admin' | 'editor' | 'viewer',
    accessibleMenus: [] as string[],
    _loaded: false,
  }),
  getters: {
    // 带缓存的计算属性
    isAdmin: (state) => state.role === 'admin',
    menuCount: (state) => state.accessibleMenus.length,
  },
  actions: {
    async fetchMenus() {
      // 模拟API调用
      await new Promise((resolve) => setTimeout(resolve, 100));
      this.accessibleMenus = ['dashboard', 'orders', 'settings'];
      this._loaded = true;
    },
    updateRole(role: string) {
      this.role = role;
      // 业务逻辑:角色变化后强制刷新菜单
      this.fetchMenus();
    },
  },
});

// 组件中使用:

import { storeToRefs } from 'pinia';
import { usePermissionStore } from '@/stores/permission';

const store = usePermissionStore();
// 使用storeToRefs保持响应性链接,且不丢失TS类型
const { role, accessibleMenus, isAdmin } = storeToRefs(store);
const { updateRole, fetchMenus } = store;

注意:不能直接解构store,否则会丢失响应性。必须用storeToRefs处理state和getters,actions可以直接解构。

五、踩坑与优化:迁移中的真实问题

坑1:Zustand的create不带中间件时的行为
新版Zustand 5中,create必须显式引入devtools中间件才能使用Redux DevTools。发现store在DevTools中看不到状态变化,折腾了半小时才定位到是中间件缺失。

坑2:React 19的use Hook与Zustand的兼容性
在升级到React 19后,发现某些使用useTransition的组件中,Zustand的getState()返回值在transition期间可能不是最新——这是React 19的concurrent特性导致的。解决方法是使用useSyncExternalStoregetServerSnapshot参数显式返回当前值。

坑3:Pinia的state直接修改问题
团队里有人习惯直接写store.role = 'admin',这在Pinia中是可以的(不像Vuex需要commit),但会导致DevTools无法记录变更。最终通过强制代码规范:所有修改必须走actions,并在eslint中配置了no-direct-store-state-access规则。

优化:选择性持久化
订单筛选条件需要持久化到localStorage,但UI状态(如折叠面板)不需要。Zustand的persist中间件支持partialize

useOrderFilterStore.persist({
  name: 'order-filter-storage',
  partialize: (state) => ({ keyword: state.keyword, pageSize: state.pageSize }),
});

同样,Pinia的persist插件(pinia-plugin-persistedstate)配置:

export const usePermissionStore = defineStore('permission', {
  // ...
  persist: {
    key: 'permission-storage',
    pick: ['role'], // 只持久化角色
  },
});

六、效果数据:迁移前后的性能对比

使用React Profiler和Vue Devtools Performance Tab,在相同页面、相同操作(切换筛选状态、修改角色)下测得:

指标 迁移前 (Context/Props) 迁移后 (Zustand/Pinia) 提升幅度
React首屏渲染时间 840ms 310ms -63%
React筛选交互响应时间 210ms 45ms -78%
React重渲染组件数(每次操作) 平均23个 平均3.2个 -86%
Vue角色切换更新时间 180ms 42ms -77%
Vue依赖追踪触发器数量 214个 37个 -82%
打包体积增加 - +14KB (gzip) 可接受

额外收益
- 代码可读性提升:删除了约300行useContext嵌套逻辑和Props透传代码。
- 测试更容易:Zustand和Pinia的store都是纯JS对象,可直接在Jest/Vitest中mock。

七、总结与建议

这次迁移让我深刻意识到:状态管理库不是银弹,但也不是洪水猛兽。如果你的项目满足以下条件,强烈建议迁移:

  • 组件树深度超过3层,且频繁共享状态
  • 有多个组件需要响应同一个状态变化
  • 性能瓶颈出现在不必要的重渲染上

最后给三点实在建议
1. 不要全量替换:先找最痛苦的模块试点,用数据说服团队。
2. 选择器一定要用:无论是Zustand的useShallow还是Pinia的storeToRefs,不用它们等于没迁移。
3. 关注包体积:Zustand虽然小,但如果你只用Context能解决问题,别为了炫技引入新依赖。

迁移不是目的,让用户感知不到渲染延迟才是。希望这篇记录对正在纠结的你有所帮助。有具体问题欢迎在评论区交流,我会尽量回复。