一、问题背景:Context 和 Props 不是不能用,是贵
项目是一个后台管理系统,React 18.2 + TypeScript 4.9 + Vite 4.1,Vue 子项目是 Vue 3.2 + Pinia 2.1 混用。最初为了快速上线,全局状态直接用 React Context 和 Vue provide/inject,局部状态靠 Props 层层传递。
问题在数据量上来后暴露:
- React 端
FilterContext里放了filters、setFilters、resetFilters、loading、error五个值。任意一个变化,所有useContext(FilterContext)的组件全部重渲染。用 React DevTools Profiler 录了一次筛选操作,提交阶段 37 个组件重渲染,耗时 186ms。 - Vue 端
provide('globalState', reactive({...}))类似,24 个 watcher 被触发,其中 11 个是无关的。 - Props drilling 最深处有 6 层,中间组件只做透传,改一个字段要动 4 个文件。
不是 Context 和 provide 本身慢,是它们没有选择器粒度。状态管理库的核心价值就是“按需订阅”。
二、环境与版本
- React 18.2.0、react-dom 18.2.0、TypeScript 4.9.5、Vite 4.1.0
- Zustand 4.4.1、immer 10.0.2
- Vue 3.2.47、Pinia 2.1.6、vue-tsc 1.0.24
- 测试机:MacBook Pro M1 16G,Chrome 114
- 性能采集:React DevTools Profiler、Vue DevTools、Chrome Performance
三、方案设计:为什么选 Zustand 和 Pinia
对比过 Redux Toolkit、Jotai、Recoil、MobX、Zustand;Vue 侧对比过 Vuex 4、Pinia。
| 维度 | Context | Redux Toolkit | Zustand | Pinia |
|---|---|---|---|---|
| 选择器粒度 | 无 | 有 | 有 | 有 |
| 模板代码 | 少 | 多 | 少 | 少 |
| 包体积 | 0 | ~13KB | ~1.2KB | ~1.5KB |
| TS 推断 | 一般 | 好 | 好 | 好 |
| 迁移成本 | - | 高 | 低 | 低 |
选 Zustand 的原因:无需 Provider、无 boilerplate、选择器天然支持、可在组件外调用 store。Pinia 是 Vue 官方推荐,和 Vuex 比去掉 mutations,TS 推断更好。
四、核心实现
4.1 React:从 Context 迁移到 Zustand
迁移前的 Context:
// 迁移前:FilterContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface Filters { keyword: string; status: string; page: number; }
interface FilterCtx {
filters: Filters;
setFilters: (f: Partial) => void;
resetFilters: () => void;
}
const Ctx = createContext(null);
export function FilterProvider({ children }: { children: ReactNode }) {
const [filters, setFiltersState] = useState({ keyword: '', status: '', page: 1 });
const setFilters = (f: Partial) => setFiltersState(p => ({ ...p, ...f }));
const resetFilters = () => setFiltersState({ keyword: '', status: '', page: 1 });
return {children};
}
export function useFilter() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('useFilter must be used within FilterProvider');
return ctx;
}
迁移后的 Zustand store:
// 迁移后:useFilterStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface Filters { keyword: string; status: string; page: number; }
interface FilterState {
filters: Filters;
loading: boolean;
setFilters: (f: Partial) => void;
resetFilters: () => void;
setLoading: (v: boolean) => void;
}
export const useFilterStore = create()(
immer((set) => ({
filters: { keyword: '', status: '', page: 1 },
loading: false,
setFilters: (f) => set((s) => { Object.assign(s.filters, f); }),
resetFilters: () => set((s) => { s.filters = { keyword: '', status: '', page: 1 }; }),
setLoading: (v) => set((s) => { s.loading = v; }),
}))
);
组件里按需订阅:
// 只订阅 keyword,keyword 变化才重渲染
const keyword = useFilterStore((s) => s.filters.keyword);
// 只订阅 action,action 引用稳定,永不因状态变化重渲染
const setFilters = useFilterStore((s) => s.setFilters);
关键点:action 从 store 里取,引用固定,组件不会因为 filters 变化而重渲染。
4.2 Vue:从 provide/inject 迁移到 Pinia
迁移前的 provide/inject:
import { reactive, provide } from 'vue';
const globalState = reactive({ keyword: '', status: '', page: 1, loading: false });
provide('globalState', globalState);
迁移后的 Pinia store:
// 迁移后:stores/filter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useFilterStore = defineStore('filter', () => {
const keyword = ref('');
const status = ref('');
const page = ref(1);
const loading = ref(false);
const hasFilter = computed(() => keyword.value !== '' || status.value !== '');
function setFilters(payload: Partial) {
if (payload.keyword !== undefined) keyword.value = payload.keyword;
if (payload.status !== undefined) status.value = payload.status;
if (payload.page !== undefined) page.value = payload.page;
}
function reset() {
keyword.value = '';
status.value = '';
page.value = 1;
}
return { keyword, status, page, loading, hasFilter, setFilters, reset };
});
组件里用 storeToRefs 保持响应式,action 直接解构:
import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filter';
const store = useFilterStore();
const { keyword, hasFilter } = storeToRefs(store);
const { setFilters, reset } = store;
五、踩坑与优化
- Zustand 选择器返回对象会引发无限重渲染。
useFilterStore((s) => ({ a: s.a, b: s.b }))每次返回新对象,默认Object.is比较失败。解决:用useShallow或拆成两个选择器。
import { useShallow } from 'zustand/react/shallow';
const { a, b } = useFilterStore(useShallow((s) => ({ a: s.a, b: s.b })));
-
Pinia 解构 store 会丢响应式。必须用
storeToRefs,action 可以直接解构,因为 action 是函数引用。 -
immer 的
Object.assign(s.filters, f)在嵌套层级深时有性能开销。我们把filters拍平成三个独立字段后,更新耗时从 0.8ms 降到 0.2ms。 -
Context 迁移期间不能一刀切。我们保留了一个
FilterProvider作为兼容层,内部用 Zustand,外部 API 不变,组件可以逐个迁移,两周内完成。 -
Vue 端
reactive大对象在 DevTools 里序列化慢。Pinia 的 setup store 用ref更轻,DevTools 采集时间从 120ms 降到 45ms。
六、效果数据
用 React DevTools Profiler 和 Chrome Performance 各采集 5 次,取中位数:
| 指标 | 迁移前 Context | 迁移后 Zustand | 变化 |
|---|---|---|---|
| 筛选操作重渲染组件数 | 37 | 8 | -78% |
| 提交阶段耗时 | 186ms | 42ms | -77% |
| 首屏可交互 TTI | 2.8s | 2.1s | -25% |
| 列表滚动 FPS | 42 | 58 | +38% |
Vue 端:
| 指标 | 迁移前 provide | 迁移后 Pinia | 变化 |
|---|---|---|---|
| watcher 触发数 | 24 | 6 | -75% |
| 筛选响应耗时 | 96ms | 31ms | -68% |
| 内存占用 | 48MB | 41MB | -15% |
包体积:Zustand + immer 增加 1.2KB + 5.6KB,Pinia 增加 1.5KB,gzip 后总增量不到 4KB,可接受。
七、总结
Context 和 provide/inject 适合低频、全局、变化少的状态,比如主题、语言。一旦状态更新频繁、订阅者多,选择器粒度就是刚需。Zustand 和 Pinia 的迁移成本比想象中低,React 端可以用兼容层渐进迁移,Vue 端几乎可以逐文件替换。
三条经验:第一,选择器返回对象一定要用 useShallow 或拆字段;第二,Pinia 解构必须 storeToRefs;第三,状态结构尽量拍平,immer 的嵌套更新在深层级下并不便宜。
迁移不是银弹,但在这个项目里,37 到 8 的重渲染数字,值得动手。