一、问题背景

去年下半年接手了一个中后台系统,前端是React 18和Vue 3混用的架构——主框架用React,几个历史业务模块用Vue 3嵌在微前端里。订单模块是核心,页面上有筛选栏、表格、分页、批量操作按钮,数据流全靠React Context往下传,Vue子应用那边用provide/inject。

一开始没什么问题,直到订单量涨到单页200条、筛选条件增加到12个。产品提了个需求:筛选条件变化时表格要实时刷新,不能点查询按钮。改完之后用户反馈卡,我打开React DevTools的Profiler一看,一次筛选条件变更触发了47次组件重渲染,其中大部分是跟筛选无关的表格行组件。原因很直接:Context的value是个对象,每次筛选变化都创建新引用,所有Consumer全部重渲染,哪怕它们只用了value里的某一个字段。

Vue那边情况稍好,provide/inject本身不做响应式追踪的优化,但因为Vue的响应式系统是细粒度的,问题没React这么夸张。不过两个框架各维护一套状态逻辑,代码重复度高,改一个字段要改两处,维护成本上来了。

这时候开始认真考虑迁移到专门的状态管理库。

二、环境与版本

先把环境交代清楚,避免读者对号入座时踩版本坑:

  • React 18.2.0,ReactDOM 18.2.0
  • Vue 3.3.4,Vite 4.4.9
  • 迁移前状态方案:React Context(createContext + useReducer)、Vue provide/inject + reactive
  • 迁移后:Zustand 4.4.1(React侧)、Pinia 2.1.6(Vue侧)
  • 构建工具:Vite 4.4.9,React插件@vitejs/plugin-react 4.0.4
  • 测试:Vitest 0.34.4 + @testing-library/react 14.0.0
  • 监控:React DevTools Profiler 4.28,Vue DevTools 6.6

选Zustand而不是Redux Toolkit,理由后面方案对比里说。Pinia基本是Vue 3的默认答案,没什么争议。

三、方案设计

3.1 方案对比

候选方案在团队里过了一遍,简单列下结论:

方案 包体积(gzip) 学习成本 细粒度订阅 适用场景
React Context 0(内置) 低 不支持,需手动拆分 低频更新、层级浅
Redux Toolkit 1.9 ~13KB 中高 需配合reselect 大型团队、强约束
Zustand 4.4 ~1.2KB 低 支持selector 中后台、按需订阅
Jotai 2.4 ~3.5KB 中 原子级 复杂派生状态
Pinia 2.1 ~4KB 低 支持getter Vue 3项目
Vue provide/inject 0(内置) 低 响应式细粒度但无隔离 简单共享

关键决策点是"细粒度订阅"。React Context的问题是value引用变化导致全量Consumer重渲染,Zustand的selector机制能让组件只订阅自己关心的字段,字段不变就不重渲染。这是性能提升的核心来源,不是库本身有多快,而是订阅粒度变细了。

Redux Toolkit功能全但样板代码多,团队里三个前端有两个没深度用过,迁移成本不划算。Jotai的原子模型适合派生状态复杂的场景,我们这个订单模块状态结构比较扁平,用不上。

Vue侧Pinia是顺理成章的选择,provide/inject在跨层级共享时缺少devtools支持和模块化组织,Pinia的store分模块、支持热更新、devtools时间旅行,迁移收益明确。

3.2 状态结构设计

迁移前Context里是一个大对象:

// 迁移前:OrderContext.jsx
const OrderContext = createContext();

function OrderProvider({ children }) {
  const [state, dispatch] = useReducer(orderReducer, initialState);
  // state = { filters, list, pagination, selectedIds, loading, error }
  return (

      {children}

  );
}

问题在于value={{ state, dispatch }}每次渲染都创建新对象,Provider下所有Consumer都会重渲染。

迁移后拆成Zustand store,用selector隔离订阅:

// 迁移后:store/orderStore.js
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

export const useOrderStore = create(
  subscribeWithSelector((set, get) => ({
    filters: { status: '', keyword: '', dateRange: null },
    list: [],
    pagination: { page: 1, pageSize: 20, total: 0 },
    selectedIds: new Set(),
    loading: false,
    error: null,

    setFilter: (key, value) =>
      set((state) => ({
        filters: { ...state.filters, [key]: value },
      })),

    fetchList: async () => {
      set({ loading: true, error: null });
      try {
        const { filters, pagination } = get();
        const res = await api.getOrders({ ...filters, ...pagination });
        set({ list: res.data, pagination: { ...pagination, total: res.total } });
      } catch (e) {
        set({ error: e.message });
      } finally {
        set({ loading: false });
      }
    },
  }))
);

组件里这样用:

// 只订阅filters,list变化不会触发这个组件重渲染
function FilterBar() {
  const filters = useOrderStore((s) => s.filters);
  const setFilter = useOrderStore((s) => s.setFilter);
  // ...
}

// 只订阅list和loading
function OrderTable() {
  const list = useOrderStore((s) => s.list);
  const loading = useOrderStore((s) => s.loading);
  // ...
}

注意selector返回的是原始值或稳定引用。如果selector返回新对象(比如s => ({ a: s.a, b: s.b })),Zustand默认用Object.is比较,每次都是新引用,等于没优化。这个坑后面踩坑部分细说。

Vue侧Pinia的store:

// stores/order.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useOrderStore = defineStore('order', () => {
  const filters = ref({ status: '', keyword: '', dateRange: null });
  const list = ref([]);
  const pagination = ref({ page: 1, pageSize: 20, total: 0 });
  const loading = ref(false);
  const error = ref(null);

  const hasSelection = computed(() => list.value.some((i) => i.selected));

  function setFilter(key, value) {
    filters.value[key] = value;
  }

  async function fetchList() {
    loading.value = true;
    error.value = null;
    try {
      const res = await api.getOrders({ ...filters.value, ...pagination.value });
      list.value = res.data;
      pagination.value.total = res.total;
    } catch (e) {
      error.value = e.message;
    } finally {
      loading.value = false;
    }
  }

  return { filters, list, pagination, loading, error, hasSelection, setFilter, fetchList };
});

四、核心实现与迁移步骤

迁移分四步走,每步可回滚,避免一次性大改导致线上事故。

第一步:建store,不改组件。 先把状态和操作逻辑搬到store里,组件继续读Context,但Context内部改为从store取值。这一步组件无感知,跑通测试即可。

// 过渡期:OrderProvider内部桥接
function OrderProvider({ children }) {
  const store = useOrderStore();
  // 保留原Context接口,内部指向store
  return (

      {children}

  );
}

第二步:逐个组件替换Consumer。 按组件粒度从叶子节点往上改,每改一个组件跑一次Profiler对比重渲染次数。FilterBar、Pagination、BatchActions先改,这三个订阅的字段最少,收益最明显。

第三步:删除Context。 确认没有组件依赖Context后删掉Provider和createContext,清理useReducer相关代码。

第四步:Vue侧同步。 Vue子应用里把provide/inject换成Pinia store,因为微前端隔离,两个store互不影响,但字段命名对齐方便维护。

迁移中改了一个关键点:原来Context里selectedIds用数组存,迁移时改成Set。原因是批量选择时数组的includes是O(n),200条数据下每次勾选都要遍历,改成Set后是O(1)。这个改动跟状态库无关,但迁移时顺手做了,Profiler上批量操作的耗时降了约40ms。

五、踩坑与优化

坑1:selector返回新对象导致无效重渲染。 最开始写useOrderStore(s => ({ list: s.list, loading: s.loading })),Profiler显示重渲染次数没降。原因是每次selector执行返回新对象,Object.is比较永远为false。解法是用useShallow(Zustand 4.4自带):

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

const { list, loading } = useOrderStore(
  useShallow((s) => ({ list: s.list, loading: s.loading }))
);

或者干脆拆成两个独立的selector调用,更直观。我倾向于拆开,代码可读性好。

坑2:异步action里的竞态。 筛选条件快速变化时,先发的请求后返回,会把旧数据覆盖新数据。迁移前Context里没处理这个问题,迁移时加了个请求序号:

let requestId = 0;
fetchList: async () => {
  const currentId = ++requestId;
  set({ loading: true });
  const res = await api.getOrders(get().filters);
  if (currentId !== requestId) return; // 丢弃过期响应
  set({ list: res.data, loading: false });
}

坑3:Pinia的store在微前端里重复实例化。 Vue子应用被qiankun加载时,如果主应用和子应用都装了Pinia,会出现两个实例。解法是在子应用入口用主应用传入的pinia实例,或者子应用独立createPinia,注意不要跨应用共享store。我们选了后者,隔离更彻底。

坑4:Zustand的subscribeWithSelector中间件顺序。 如果同时用devtools和subscribeWithSelector,中间件嵌套顺序会影响devtools里的action名称显示。正确顺序是devtools(subscribeWithSelector(...)),反了的话action追踪会丢失。

优化:用useStore.getState()在事件回调里读状态。 组件里用hook订阅会触发重渲染,但事件回调里只需要读当前值,用useOrderStore.getState().filters即可,避免不必要的订阅。

六、效果数据

用React DevTools Profiler在同一个订单列表页(200条数据、12个筛选项)做了对比,场景是连续切换3次筛选条件,记录每次交互的commit耗时和重渲染组件数:

指标 Context方案 Zustand方案 变化
单次筛选交互平均耗时 180ms 32ms -82%
单次交互重渲染组件数 47 6 -87%
首屏渲染耗时 420ms 390ms -7%
批量勾选200条耗时 96ms 54ms -44%
内存占用(堆快照) 18.2MB 16.8MB -8%

Vue侧用Vue DevTools的Performance面板测,筛选交互从平均110ms降到28ms,主要收益来自Pinia的getter缓存和组件级订阅。首屏变化不大,因为Vue的响应式本来就细粒度。

包体积方面,Zustand gzip后1.2KB,Pinia 4KB,对整体bundle影响可以忽略(主包gzip后约380KB)。

需要说明的是,这些数字跟具体业务结构强相关。如果Context的value本身拆得很细、Consumer层级浅,迁移收益不会这么明显。我们收益大的核心原因是之前Context的value是个大对象,所有Consumer无差别重渲染。

七、总结

迁移到Zustand和Pinia这件事,本质上不是换了个库,而是把"粗粒度广播"换成了"细粒度订阅"。React Context不是不能用,而是它要求开发者手动做拆分和memo,心智负担高,团队里新人很容易写出全量重渲染的代码。状态管理库用API层面强制了selector模式,配合useShallow这类工具,把优化做成了默认行为。

如果重来一次,我会更早做迁移,并且在第一步"建store不改组件"的阶段多停留一段时间,用测试覆盖住行为再动组件。另外selector返回新对象这个坑值得单独写进团队规范,比看文档管用。

Vue侧Pinia的迁移基本无痛,provide/inject到Pinia的映射很直接,收益主要在代码组织和devtools体验上。两个框架混用的项目里,状态库的字段命名对齐能省不少沟通成本。

最后贴一下迁移后的测试用例,保证store行为可回归:

// orderStore.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { useOrderStore } from './orderStore';

describe('orderStore', () => {
  beforeEach(() => {
    useOrderStore.setState({
      filters: { status: '', keyword: '', dateRange: null },
      list: [],
      pagination: { page: 1, pageSize: 20, total: 0 },
      loading: false,
      error: null,
    });
  });

  it('setFilter 只更新指定字段', () => {
    useOrderStore.getState().setFilter('status', 'paid');
    expect(useOrderStore.getState().filters.status).toBe('paid');
    expect(useOrderStore.getState().filters.keyword).toBe('');
  });

  it('fetchList 失败时设置error并重置loading', async () => {
    // mock api 抛错
    await expect(useOrderStore.getState().fetchList()).resolves.toBeUndefined();
    expect(useOrderStore.getState().loading).toBe(false);
  });
});

代码和数字都在上面了,有类似场景的同学可以对照自己的Profiler数据判断要不要迁。如果Context的Consumer重渲染次数在个位数,其实没必要折腾。