一、问题背景:Context 和 Props 撑不住了
项目是一个后台管理系统,React 18.2 + TypeScript 5.3,Vue 子模块是 Vue 3.4 + Vite 5。早期为了快,全局状态直接用 Context 包了一层,局部状态靠 props 往下传。
问题在业务变复杂后集中爆发:
- Props 透传地狱。一个订单详情页,从页面组件到最底层的操作按钮,中间隔了 4 层组件,每层都要把
order、onUpdate、permissions原样往下传。改一个字段要动 5 个文件。 - Context 重渲染失控。Provider 的 value 是一个对象字面量,每次父组件渲染都会生成新引用,导致消费该 Context 的所有组件全部重渲染。列表页滚动时,明明只有一行数据变了,整页 200 多个单元格都在 re-render。
- Vue 那边 provide/inject 也有类似问题,注入的响应式对象粒度太粗,改一个字段触发整棵子树更新。
我们用 React DevTools Profiler 抓了一次列表页交互:单次筛选操作触发 187 次组件渲染,commit 阶段耗时 46ms,明显掉帧。
二、环境与版本
- React 18.2.0,ReactDOM 18.2.0
- Vue 3.4.21,Vite 5.2.0
- 状态库候选:Zustand 4.5.2、Redux Toolkit 2.2.3、Jotai 2.6.4、Pinia 2.1.7
- 性能采集:React DevTools Profiler、Chrome Performance 面板、自定义 render 计数 HOC
三、方案设计:为什么选 Zustand 和 Pinia
对比了几个候选:
| 方案 | 包体积(gzip) | 样板代码 | 选择器粒度 | 学习成本 |
|---|---|---|---|---|
| Context | 0 | 少 | 无(整对象订阅) | 低 |
| Redux Toolkit | ~13KB | 多(slice/store/provider) | 中(useSelector) | 中高 |
| Zustand | ~1.2KB | 极少 | 细(selector + shallow) | 低 |
| Jotai | ~4KB | 少(atom) | 原子级 | 中 |
| Pinia | ~6KB | 少 | 细(storeToRefs) | 低 |
选 Zustand 的核心理由:没有 Provider 包裹,store 是模块级单例,组件用 selector 订阅,天然避免无关重渲染;API 极简,迁移成本低。Vue 侧选 Pinia,因为它是 Vue 官方推荐,和 devtools 集成好,storeToRefs 能精确解构响应式字段。
设计原则定三条:
1. 按业务域拆 store(orderStore、userStore、uiStore),不搞一个大 store。
2. 组件只订阅自己用到的字段,禁止 const state = useStore() 全量订阅。
3. 异步请求放 store 的 action 里,组件不直接调 API。
四、核心实现
4.1 React 侧:从 Context 到 Zustand
迁移前的 Context 写法:
// 迁移前:Context + 对象 value,重渲染重灾区
const OrderContext = createContext(null);
export function OrderProvider({ children }: { children: React.ReactNode }) {
const [order, setOrder] = useState(null);
const [loading, setLoading] = useState(false);
// 每次渲染都是新对象,所有消费者全部重渲染
const value = { order, loading, setOrder, setLoading };
return {children};
}
// 消费端
function OrderCell({ id }: { id: string }) {
const { order } = useContext(OrderContext)!; // 订阅了整个 value
return {order?.items[id]?.name};
}
迁移后的 Zustand store:
// stores/orderStore.ts
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
interface OrderState {
order: Order | null;
loading: boolean;
fetchOrder: (id: string) => Promise;
updateItem: (itemId: string, patch: Partial) => void;
}
export const useOrderStore = create((set, get) => ({
order: null,
loading: false,
fetchOrder: async (id) => {
set({ loading: true });
try {
const data = await api.getOrder(id);
set({ order: data, loading: false });
} catch (e) {
set({ loading: false });
throw e;
}
},
updateItem: (itemId, patch) => {
const order = get().order;
if (!order) return;
set({
order: {
...order,
items: { ...order.items, [itemId]: { ...order.items[itemId], ...patch } },
},
});
},
}));
// 组件里精确订阅,只关心单个 item
function OrderCell({ id }: { id: string }) {
const itemName = useOrderStore((s) => s.order?.items[id]?.name);
return {itemName};
}
关键点:selector 返回的是原始值(字符串),Zustand 用 Object.is 比较,只有该字段变化才重渲染。如果 selector 返回对象,必须配合 useShallow:
import { useShallow } from 'zustand/react/shallow';
const { order, loading } = useOrderStore(
useShallow((s) => ({ order: s.order, loading: s.loading }))
);
迁移步骤我按这个顺序做,每步都能独立验证:
- 建 store 文件,先把 state 和 action 搬过去,组件暂时不动。
- 在入口移除 Provider,确认应用能正常启动(store 是单例,不需要包裹)。
- 逐个组件替换 useContext 为 useStore + selector,替换一个测一个。
- 删除旧 Context 文件和所有 props 透传参数,用 TS 编译报错兜底,确保没有遗漏。
- 跑一遍 Profiler,对比渲染次数。
4.2 Vue 侧:provide/inject 到 Pinia
// stores/useOrderStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useOrderStore = defineStore('order', () => {
const order = ref(null);
const loading = ref(false);
const itemCount = computed(() => order.value?.items.length ?? 0);
async function fetchOrder(id: string) {
loading.value = true;
try {
order.value = await api.getOrder(id);
} finally {
loading.value = false;
}
}
return { order, loading, itemCount, fetchOrder };
});
组件里用 storeToRefs 解构,保持响应式:
import { storeToRefs } from 'pinia';
import { useOrderStore } from '@/stores/useOrderStore';
const store = useOrderStore();
const { order, loading } = storeToRefs(store); // 解构后仍是响应式
注意 storeToRefs 只对 state 和 getter 有效,action 直接 store.fetchOrder() 调。
五、踩坑与优化
坑 1:selector 返回新对象导致无限重渲染。 一开始写了 useOrderStore((s) => ({ order: s.order })),每次返回新对象,Object.is 永远 false,组件疯狂重渲染。解决:用 useShallow 或者拆成多个单字段 selector。
坑 2:在 render 期间调 action 触发 set。 有个组件在渲染函数体里直接 updateItem,导致 React 报 "Cannot update a component while rendering"。解决:放到 useEffect 或事件回调里。
坑 3:Pinia 解构丢失响应式。 Vue 侧直接 const { order } = store 解构,order 变成普通值。必须用 storeToRefs。这个坑文档里有,但实际写的时候还是会忘。
优化 1:列表虚拟化配合细粒度订阅。 列表项组件订阅自己的 item,配合 react-window 只渲染可视区域,滚动时渲染数从 200+ 降到 15 左右。
优化 2:devtools 中间件。 Zustand 加 devtools 中间件,Pinia 自带 devtools,调试时能直接看 state 快照和 action 调用链,排查问题快很多。
六、效果数据
用 Profiler 和 Performance 面板采集,同一台机器(M1 MacBook,Chrome 124),同一份测试数据(订单列表 200 行,每行 8 个字段):
| 指标 | 迁移前(Context/Props) | 迁移后(Zustand/Pinia) | 变化 |
|---|---|---|---|
| 列表页首屏渲染 | 1.82s | 1.21s | -33% |
| 单次筛选 commit 耗时 | 46ms | 13ms | -72% |
| 单次筛选渲染组件数 | 187 | 52 | -72% |
| props 透传平均层数 | 4 层 | 0 层 | - |
| 相关代码行数 | 约 1200 行 | 约 780 行 | -35% |
首屏提升主要来自两处:去掉了 Provider 嵌套带来的额外渲染,以及列表项细粒度订阅减少了无效更新。代码行数下降是因为删掉了大量 props 类型定义和透传代码。
内存占用变化不大(Zustand 单例常驻约 200KB 堆内存,可接受)。
七、总结
Context/Props 不是不能用,小项目、状态简单时它够用且零依赖。但当出现"改一个字段动五个文件"和"Profiler 里一片红"的时候,就该考虑状态管理库了。
我的建议:
- React 项目优先试 Zustand,体积小、无 Provider、selector 粒度细,迁移心智负担低。
- Vue 项目直接用 Pinia,官方生态,storeToRefs 用熟就行。
- 迁移按 store 建文件 → 去 Provider → 逐组件替换 → 删旧代码 → 跑 Profiler 的顺序来,每步可回滚。
- 一定用 selector 精确订阅,别全量订阅 store,否则等于换了个地方踩 Context 的坑。
这次迁移总共花了约 3 个工作日(React 侧 2 天,Vue 侧 1 天),收益是实打实的:渲染次数降七成,代码少三成,后面加新功能的心理负担小了很多。如果你的项目也在被 props 透传和 Context 重渲染折磨,可以试试这条路。