一、问题背景:当Context成为性能瓶颈
事情发生在我们的一个报表配置平台。最初用Context(React 18.2)管理全局的筛选条件、表格配置、用户偏好。但随着业务迭代,组件树变成这样:
... 20+个子组件
问题很典型:任何一个Provider的value变化,都会导致其下所有消费组件重新渲染。哪怕只是改了一个筛选框的值,整个表格组件(包含500行虚拟滚动)都要经历协调过程。Chrome Performance录制显示,一次简单的输入操作(onChange -> setState -> Context更新)耗时高达180ms,输入明显掉帧。在Vue那边,虽然Provide/Inject有响应式优化,但过度注入同样导致依赖追踪混乱,watchEffect频繁触发。
二、环境与版本:技术栈快照
- React 18.2.0 / Next.js 14.0.4
- Vue 3.5.1(Composition API)
- 状态库:Zustand 4.5.2(React)、Pinia 2.1.7(Vue)
- 构建工具:Vite 5.0(Vue)、Webpack 5.9(React)
- 性能采集:Chrome DevTools Performance面板、React Profiler 6.0
核心诉求:不重构业务代码,只替换状态传递层。要求新方案能保留现有hook/API调用方式(如useFilter、useTableConfig),降低迁移阻力。
三、方案设计:为什么是Zustand和Pinia
对比了Redux Toolkit、Jotai、MobX以及Vuex后,最终定下这两个库,原因很务实:
- 零Provider包裹:Zustand和Pinia都不需要顶层Provider,直接import store即可使用。这直接砍掉了三层嵌套Provider。
- 细粒度订阅:Zustand的
useStore支持选择器(selector),只有选中值变化才会触发组件更新;Pinia的storeToRefs配合computed能达到同样效果。 - 异步支持:Zustand的
create支持异步action,Pinia天然支持async/await,无需中间件。 - 迁移成本低:两者都支持在组件外定义store,意味着我们可以先新建store,再逐步替换useContext调用。
架构设计:
// 旧结构:Provider嵌套
-> ->
// 新结构:扁平化Store
zustand/store/filterStore.ts
zustand/store/tableConfigStore.ts
zustand/store/userStore.ts
核心原则:按业务域拆分store,而不是一个大store。每个store独立管理自己的state和action。
四、核心实现:代码改造实录
4.1 React:从Context到Zustand
迁移前(Context写法):
// filterContext.tsx
const FilterContext = createContext(null!);
export const useFilter = () => useContext(FilterContext);
export const FilterProvider = ({ children }) => {
const [filters, setFilters] = useState({ keyword: '', dateRange: [] });
const updateKeyword = (kw: string) => setFilters(prev => ({ ...prev, keyword: kw }));
return (
{children}
);
};
// 消费组件
function SearchInput() {
const { filters, updateKeyword } = useFilter();
return updateKeyword(e.target.value)} />;
}
迁移后(Zustand写法):
// filterStore.ts
import { create } from 'zustand';
interface FilterState {
keyword: string;
dateRange: [Date, Date] | null;
setKeyword: (kw: string) => void;
setDateRange: (range: [Date, Date] | null) => void;
}
export const useFilterStore = create((set) => ({
keyword: '',
dateRange: null,
setKeyword: (keyword) => set({ keyword }),
setDateRange: (dateRange) => set({ dateRange }),
}));
// 消费组件(关键:用selector精准订阅)
function SearchInput() {
const keyword = useFilterStore(state => state.keyword);
const setKeyword = useFilterStore(state => state.setKeyword);
return setKeyword(e.target.value)} />;
}
// 如果组件同时依赖多个store值,可以用useShallow避免不必要渲染
import { useShallow } from 'zustand/react/shallow';
const { keyword, dateRange } = useFilterStore(useShallow(state => ({
keyword: state.keyword,
dateRange: state.dateRange,
})));
注意点:Zustand默认是浅比较(Object.is),如果selector返回新对象(如state => ({ a: state.a, b: state.b })),会导致无限渲染。必须配合useShallow或直接返回原始值。
4.2 Vue:从Provide/Inject到Pinia
迁移前(Vue 3 Provide/Inject):
import { provide, reactive } from 'vue';
const filters = reactive({ keyword: '', dateRange: [] });
const updateKeyword = (kw) => { filters.keyword = kw; };
provide('filterState', { filters, updateKeyword });
import { inject } from 'vue';
const { filters, updateKeyword } = inject('filterState');
迁移后(Pinia):
// store/filter.ts
import { defineStore } from 'pinia';
export const useFilterStore = defineStore('filter', {
state: () => ({
keyword: '',
dateRange: null as [Date, Date] | null,
}),
actions: {
setKeyword(kw: string) {
this.keyword = kw;
},
setDateRange(range: [Date, Date] | null) {
this.dateRange = range;
},
},
});
// 组件中使用
import { storeToRefs } from 'pinia';
import { useFilterStore } from '../store/filter';
const filterStore = useFilterStore();
// 关键:使用storeToRefs解构才会保持响应性,且实现按需更新
const { keyword, dateRange } = storeToRefs(filterStore);
五、踩坑与优化:那些文档没写的细节
坑1:Zustand的selector与React.memo冲突
如果组件内用了React.memo包裹,但selector返回的是函数(如action),会导致memo失效。解决方案:action在store外定义时用useShallow包裹,或者直接不memo组件,因为Zustand已经做了细粒度订阅。
坑2:Pinia的store实例在组件外使用
在router或工具函数中调用store,必须确保Pinia实例已激活。解决:在main.ts中const pinia = createPinia(); app.use(pinia);后,全局导出一个useStore函数,内部判断activePinia。
性能优化:使用useCallback稳定action引用
在Zustand中,如果action函数在每次渲染时重建,会导致订阅的组件重新渲染。优化方案:
// 在store外部定义action,避免每次create时重建
const setKeyword = (kw: string) => useFilterStore.setState({ keyword: kw });
// 组件中直接引用外部函数
const keyword = useFilterStore(s => s.keyword);
// 不需要从store取setKeyword了
优化:视觉验证
用React Profiler录制,发现迁移后,点击搜索按钮时,只有SearchInput和ResultTable两个组件重新渲染,而之前的Context版本至少15个组件参与渲染。
六、效果数据:性能提升直观可见
在同样硬件(MacBook Pro M1)上用Chrome Performance录制2分钟操作(输入、筛选、翻页):
| 指标 | Context/Provide | Zustand/Pinia | 提升幅度 |
|---|---|---|---|
| 输入延迟(ms) | 180 | 45 | 75% |
| 组件更新次数/次操作 | 23 | 8 | 65% |
| 渲染耗时(ms) | 120 | 42 | 65% |
| 首次加载bundle体积(gzip) | 285KB | 249KB(去掉Provider代码) | 12.6% |
更直观的感受:迁移前,在筛选框输入时,表格会出现明显的白屏闪烁(重渲染);迁移后,输入流畅无感,表格仅在点击“应用筛选”时才更新。
七、总结与建议
这次迁移花了3天完成,核心工作量在识别哪些状态需要全局共享,而不是写store代码。建议遵循以下原则:
- 能用props传递的,不要进store。store只放跨多层级共享且频繁变动的状态(如筛选条件、用户信息)。
- 按业务域拆分store,不要搞一个巨型store。否则就失去了细粒度订阅的意义。
- 迁移时先建store,再逐步替换。一个模块一个模块来,避免大爆炸式重构,方便回滚。
对于新项目,我强烈建议直接上Zustand/Pinia,省去Provider嵌套的麻烦。但也要注意,状态管理库不是银弹,如果你的项目组件树只有2-3层,Context完全够用,别过度设计。
最后,附上本次迁移的commit记录:删掉了4个Provider文件,新增了5个store文件,净删200余行代码。代码越少,维护越轻松,这大概是这次重构最爽的地方。