一、问题背景:当Context成为性能瓶颈
我们的React 19项目里有个经典场景:一个用户列表页,包含筛选器、表格、分页器、批量操作栏四个组件。最初用Context管理筛选条件和选中行ID,结果每次点击分页器,整个列表页的41个子组件全部重渲染。用``一测,单次分页操作耗时从18ms飙升到247ms。
Vue 3.5项目则是另一个极端——用props传递一个currentStep到第四层子组件,中途经过3个完全不需要该数据的中间组件。虽然Vue的响应式系统优化了渲染,但每次修改都触发setup()重执行,传递链上7个组件的依赖收集全部失效。
核心问题不在于状态管理本身,而在于状态订阅的粒度太粗。Context的value变化会让所有消费该Context的组件重渲染,而props钻取则让中间层组件被迫参与状态传递。
二、环境与版本:选型前的硬性约束
// React项目 package.json关键依赖
{
"react": "19.0.0",
"react-dom": "19.0.0",
"zustand": "5.0.3",
"typescript": "5.6.3"
}
// Vue项目
{
"vue": "3.5.13",
"pinia": "3.0.1",
"vite": "6.0.5"
}
团队约定:所有状态管理库不得引入redux-devtools以外的调试插件,且bundle体积增幅不得超过20KB(gzip前)。最终选择Zustand v5是因为它基于useSyncExternalStore,可以精确到selector级别的订阅;Pinia v3则是因为其与Vue 3.5的reactive深度集成,且支持setup store语法。
三、方案设计:按状态类型拆分store
我们按业务域将全局状态拆成4个独立store,避免单一store导致的状态臃肿:
- authStore:用户信息、token,全局只读
- listStore:列表页的筛选条件、分页、选中行(带undo能力)
- uiStore:侧边栏折叠状态、主题、全局loading
- documentStore:多标签页的缓存状态
关键设计原则是状态的读写分离。例如listStore中,selectedRows只允许通过toggleRow和clearSelection两个action修改,杜绝组件内直接赋值。这在后续性能优化时能快速定位到所有修改入口。
四、核心实现:从Context到Zustand的迁移实录
4.1 React侧:用selector切割订阅粒度
迁移前,我们有一个ListContext:
// 迁移前:Context + useReducer
const ListContext = createContext}>(null!);
export const useList = () => useContext(ListContext);
迁移后,使用Zustand的create和useShallow:
// 迁移后:Zustand store + 精确selector
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
interface ListStore {
filter: Filter;
currentPage: number;
selectedRows: Record;
setFilter: (filter: Filter) => void;
setPage: (page: number) => void;
toggleRow: (id: string) => void;
}
export const useListStore = create((set) => ({
filter: {},
currentPage: 1,
selectedRows: {},
setFilter: (filter) => set({ filter, currentPage: 1 }),
setPage: (currentPage) => set({ currentPage }),
toggleRow: (id) =>
set((state) => {
const newSelected = { ...state.selectedRows };
if (newSelected[id]) delete newSelected[id];
else newSelected[id] = true;
return { selectedRows: newSelected };
}),
}));
// 组件内使用:只订阅自己需要的部分
const filter = useListStore((s) => s.filter);
const setPage = useListStore((s) => s.setPage); // action引用稳定,不会触发重渲染
关键点:useListStore((s) => s.filter)返回的是基本类型或独立对象的引用,不依赖useShallow也不会引起额外渲染。但如果有多个状态需要组合,必须用useShallow做浅比较:
const { filter, currentPage } = useListStore(
useShallow((s) => ({ filter: s.filter, currentPage: s.currentPage }))
);
4.2 Vue侧:Pinia的setup store与响应式解构陷阱
Pinia迁移时最坑的是解构丢失响应性。团队里两个同事都栽在这里——从store里const { filter } = store后,filter变成了普通对象。正确做法:
// 迁移后:Pinia setup store
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useListStore = defineStore('list', () => {
const filter = ref({});
const currentPage = ref(1);
const selectedRows = ref>({});
const totalSelected = computed(() => Object.keys(selectedRows.value).length);
function setFilter(newFilter: Filter) {
filter.value = newFilter;
currentPage.value = 1;
}
function toggleRow(id: string) {
if (selectedRows.value[id]) {
delete selectedRows.value[id];
} else {
selectedRows.value[id] = true;
}
}
return { filter, currentPage, selectedRows, totalSelected, setFilter, toggleRow };
});
// 组件内必须使用storeToRefs保持响应性
import { storeToRefs } from 'pinia';
const store = useListStore();
const { filter, currentPage } = storeToRefs(store);
const { toggleRow } = store; // action直接解构没问题
五、踩坑与优化:三个意想不到的性能杀手
5.1 React 19的use()与Zustand的兼容性
React 19新增的use()API可以直接消费Promise或Context,但Zustand v5的selector函数如果返回新创建的对象(比如(s) => ({ a: s.a, b: s.b })),会导致无限循环。因为useSyncExternalStore每次都会比较快照,新对象引用不同导致重渲染。必须配合useShallow或返回基本类型。
5.2 Vue 3.5的reactive解构陷阱
在Pinia的setup store中,如果直接const { filter } = store,filter是普通对象,修改它不会触发更新。但若返回时用了toRefs,则在组件内又需要.value访问,两难。最终我们统一约定:组件内一律使用storeToRefs。
5.3 列表页的批量操作性能优化
批量选择2000行时,原来的实现是循环调用toggleRow,导致2000次set操作。优化方案是增加一个toggleAll action,在store内部用一次set完成:
// 优化前:逐个调用
rows.forEach(row => toggleRow(row.id));
// 优化后:批处理
function toggleAll(ids: string[]) {
set((state) => {
const newSelected = { ...state.selectedRows };
ids.forEach(id => {
if (newSelected[id]) delete newSelected[id];
else newSelected[id] = true;
});
return { selectedRows: newSelected };
});
}
实测:2000行批量选择从12.8s降到0.3s,因为减少了1999次状态提交和对应的组件渲染调度。
六、效果数据:性能与体积的权衡
迁移完成后的数据(均使用Chrome 130的Performance面板,10次取中位数):
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| React列表页交互耗时 | 247ms | 43ms | -82.6% |
| React无效重渲染组件数 | 31 | 4 | -87.1% |
| Vue详情页首次加载内存 | 38.2MB | 22.1MB | -42.1% |
| Vue bundle体积 (gzip) | 89KB | 98KB | +10.1% |
| React bundle体积 (gzip) | 112KB | 94KB | -16.1% |
Vue项目体积增加是因为Pinia本身比手写props传递多了一些运行时(约9KB),但内存和渲染性能收益远超这个代价。React项目因为移除了大量Context的Provider嵌套和useMemo缓存,bundle反而瘦身。
七、总结:什么时候该迁移,什么时候别动
如果你的项目满足以下条件,强烈建议迁移:
- 状态传递超过3层,且中间层不需要该数据
- 多个组件需要共享同一份状态,且更新频率高
- 已经出现“点击一次,重渲染半个页面”的性能瓶颈
但如果只是两三个组件共享一个简单状态,用useState + props反而更清晰。状态管理库不是银弹,它带来的是订阅粒度的精细控制,代价是学习成本和架构约束。
最后说一句:迁移时不要一次性全改。我们花了三周时间,按业务模块逐个替换,每个模块独立验证性能。先治标(修性能问题),再治本(重构架构),这才是团队能接受的节奏。