一、问题背景
去年下半年接手了一个中后台系统,前端是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重渲染次数在个位数,其实没必要折腾。