1. 问题背景:Context和Props在中期项目里怎么变味的
项目是一个运营后台,React 18.2 + TypeScript 4.9 做主体,Vue 3.3 + TypeScript 5.0 做部分嵌入模块。最开始只有3个页面,用Props传数据完全够。半年后模块涨到14个,公共状态包括:当前用户、权限点、全局筛选条件、主题、未读消息数。
React侧的问题是Context套娃。App里放了AuthContext,里面套ThemeContext,再套FilterContext,再套PermissionContext,最深处组件要读第7层。每次全局筛选变化,FilterContext.Provider的value是新对象,导致所有消费组件全部重渲染。用React DevTools Profiler录了一次,提交阶段47个组件重渲染,耗时420ms,输入框打字都卡。
Vue侧的问题稍好,但Props钻透严重。FilterPanel → FilterGroup → FilterItem → FilterInput,4层。中间组件根本不关心筛选值,但为了往下传,不得不声明props,TypeScript类型也越写越长。而且Vue的响应式在多层props下,修改时触发更新范围不好控制,出现过一次改筛选条件导致整个表格重新请求。
我们试过React.memo + useMemo包Context value,也试过Vue的provide/inject。React.memo能压到28个组件重渲染,但代码里到处是useCallback,维护成本高。provide/inject解决了钻透,但注入的响应式对象一旦变大,追踪开销明显,且类型推断弱。最终决定迁移到专门的状态管理库:React用Zustand 4.4.7,Vue用Pinia 2.1.7。
2. 环境与版本
- React:18.2.0,react-dom 18.2.0,TypeScript 4.9.5,Vite 4.4.9
- Vue:3.3.4,TypeScript 5.0.4,Vite 4.4.9
- 状态库:zustand 4.4.7,pinia 2.1.7
- 测试:@testing-library/react 14.0.0,@vue/test-utils 2.4.1
- 性能采集:React DevTools Profiler 4.28,Vue DevTools 6.6,Chrome Performance
- 包体积:rollup-plugin-visualizer 5.9.2
3. 方案设计:为什么选Zustand和Pinia
对比过Redux Toolkit、MobX、Jotai、Valtio、Zustand。Redux Toolkit模板代码多,一个筛选状态要写slice、action、selector,团队里3个人写出来3种风格。MobX响应式好,但和React 18并发特性配合时,外部可变状态容易在StrictMode下出问题。Jotai原子化适合细粒度,但我们的筛选条件是一个整体对象,拆原子反而麻烦。Zustand的优势是:store就是一个hook,选择器天然支持浅比较,不需要Provider包裹,包体积2.9KB gzip。
Vue侧对比过Vuex 4和Pinia。Vuex 4的mutation/action分离在TypeScript下类型推断差,Pinia直接是组合式API风格,defineStore返回的store实例类型完整,且支持$subscribe做持久化。Pinia 2.1.7 gzip后约1.8KB。
迁移原则:不一次性全改,按模块灰度。先建store,再让新组件用store,旧组件保持Context/Props,通过适配层读写同一份状态,避免双份数据源。适配层用自定义hook和composable实现。
4. 核心实现:React用Zustand,Vue用Pinia
先看React侧。原来FilterContext的写法:
// 旧:FilterContext.tsx
const FilterContext = createContext void }>(null!);
export function FilterProvider({ children }: { children: React.ReactNode }) {
const [filter, setFilter] = useState({ keyword: '', status: 'all', dateRange: [] });
const value = useMemo(() => ({ filter, setFilter }), [filter]);
return {children};
}
迁移到Zustand 4.4.7:
// store/filterStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
export interface Filter {
keyword: string;
status: 'all' | 'active' | 'inactive';
dateRange: [string, string] | [];
}
interface FilterState {
filter: Filter;
setKeyword: (keyword: string) => void;
setStatus: (status: Filter['status']) => void;
setDateRange: (range: [string, string] | []) => void;
reset: () => void;
}
const initialFilter: Filter = { keyword: '', status: 'all', dateRange: [] };
export const useFilterStore = create()(
subscribeWithSelector((set) => ({
filter: initialFilter,
setKeyword: (keyword) => set((s) => ({ filter: { ...s.filter, keyword } })),
setStatus: (status) => set((s) => ({ filter: { ...s.filter, status } })),
setDateRange: (dateRange) => set((s) => ({ filter: { ...s.filter, dateRange } })),
reset: () => set({ filter: initialFilter }),
}))
);
组件里按需选择,避免整个filter对象引起重渲染:
// components/KeywordInput.tsx
import { useFilterStore } from '../store/filterStore';
export function KeywordInput() {
const keyword = useFilterStore((s) => s.filter.keyword);
const setKeyword = useFilterStore((s) => s.setKeyword);
return setKeyword(e.target.value)} />;
}
// components/StatusSelect.tsx
export function StatusSelect() {
const status = useFilterStore((s) => s.filter.status);
const setStatus = useFilterStore((s) => s.setStatus);
return (
setStatus(e.target.value as any)}>
全部
启用
禁用
);
}
注意这里用了subscribeWithSelector,因为后面要做持久化和跨store订阅。选择器返回的是原始值(string),Zustand默认用Object.is比较,只有keyword变化时KeywordInput才重渲染,StatusSelect不受影响。
Vue侧原来用provide/inject:
import { provide, ref } from 'vue';
const filter = ref({ keyword: '', status: 'all', dateRange: [] });
provide('filter', filter);
迁移到Pinia 2.1.7:
// stores/filter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export interface Filter {
keyword: string;
status: 'all' | 'active' | 'inactive';
dateRange: [string, string] | [];
}
export const useFilterStore = defineStore('filter', () => {
const filter = ref({ keyword: '', status: 'all', dateRange: [] });
const hasKeyword = computed(() => filter.value.keyword.trim().length > 0);
function setKeyword(keyword: string) {
filter.value.keyword = keyword;
}
function setStatus(status: Filter['status']) {
filter.value.status = status;
}
function reset() {
filter.value = { keyword: '', status: 'all', dateRange: [] };
}
return { filter, hasKeyword, setKeyword, setStatus, reset };
});
组件里用storeToRefs保持响应式解构:
import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filter';
const store = useFilterStore();
const { filter } = storeToRefs(store);
Pinia的响应式追踪是精确到属性的,filter.keyword变化只触发依赖它的组件,不会像原来provide整个ref对象那样,任何属性变化都通知所有注入组件。
5. 迁移步骤与踩坑
迁移分四步,每步可回滚:
第一步,建store,不改旧代码。React里create出store,Vue里defineStore。此时store和Context/inject并存,但没人用。
第二步,写适配层。React侧写useFilter hook,内部读store,返回和旧Context一样的接口,旧组件改一行import即可。Vue侧写useFilterCompat composable,内部调store,返回ref对象。这样旧组件不用大改。
// React适配层
export function useFilter() {
const filter = useFilterStore((s) => s.filter);
const setFilter = useFilterStore((s) => s.setFilter);
return { filter, setFilter };
}
第三步,逐个组件替换。先换叶子组件,再换中间组件,最后删Provider。每换一个,跑一次单测和Profiler。
第四步,删旧代码,移除Context和provide。
踩坑记录:
坑1:Zustand在React 18 StrictMode下,create的store是模块级单例,这没问题,但如果在组件内调用create会每次渲染新建store导致状态丢失。必须放在模块顶层。
坑2:Pinia在SSR或微前端场景下,store实例要挂到app上。我们嵌入模块用了createPinia()并app.use(pinia),但主应用和子应用各有一个Pinia实例,状态不互通。后来改成主应用通过props传pinia实例给子应用,或者用setActivePinia。最终方案是子应用不建新Pinia,直接复用主应用的。
坑3:Zustand选择器返回新对象会导致无限重渲染。比如useFilterStore((s) => ({ keyword: s.filter.keyword })),每次返回新对象,Object.is比较失败。必须用useShallow:
import { useShallow } from 'zustand/react/shallow';
const { keyword, status } = useFilterStore(
useShallow((s) => ({ keyword: s.filter.keyword, status: s.filter.status }))
);
坑4:Pinia的storeToRefs只对state和getter有效,对action无效。解构action直接用store.method,不要放进storeToRefs。
坑5:持久化。Zustand用persist中间件,Pinia用pinia-plugin-persistedstate。注意版本:zustand 4.4.7的persist中间件要求createJSONStorage,直接写localStorage会类型报错。Pinia插件2.1.7要和pinia 2.1.7配套,版本不一致时$subscribe不触发。
6. 效果数据
迁移前后用同一套测试用例,Chrome 115,MacBook Pro M1,16GB。
React侧:
- 全局筛选更新重渲染组件数:47 → 9
- 提交阶段耗时:420ms → 68ms
- 输入框打字延迟:从按键到字符显示,P95从180ms → 32ms
- 首屏可交互时间(TTI):2.1s → 1.4s
- 包体积:gzip后增加2.9KB(zustand)+ 0.3KB(适配层)
Vue侧:
- 筛选更新触发组件更新数:31 → 7
- 更新耗时:210ms → 45ms
- 表格重新请求次数:从每次筛选变化请求3次(因为多个watch触发)→ 1次
- 包体积:gzip后增加1.8KB(pinia)
代码量:React侧删掉Context相关代码约420行,新增store和适配层约180行,净减240行。Vue侧删掉provide/inject和props声明约310行,新增store约120行,净减190行。
维护成本:新人上手时间从平均2天理解Context嵌套,降到半天理解store。TypeScript类型报错从每周约15个(多是Context value类型不匹配)降到约3个。
7. 总结
Context和Props不是不能用,是项目到中期、状态跨5层以上、更新频率高的时候,维护成本和性能都会成为瓶颈。Zustand和Pinia不是银弹,但在这个场景下,它们用更少的代码换来了更精确的更新和更好的类型体验。
迁移的关键是灰度,不要一次性全改。适配层是核心,它让新旧代码共享同一份状态,避免双数据源。选择器要返回原始值,返回对象必须用useShallow。Pinia在微前端下注意实例复用。
如果重来一次,我会在项目有第5个页面、出现第3个全局状态时就开始用store,而不是等到Context套到7层。状态管理库的引入成本,远低于后期重构成本。