一、问题背景:Context 不是不能用,是用错了地方
先说结论:Context 适合低频、全局、变化不频繁的值(主题、locale、用户信息)。但我们把它当成了 Redux 用——一个 AppContext 里塞了 30 多个字段,任何一次 dispatch 都会让所有 useContext 的组件重渲染。
具体症状:
- 订单列表页有 200 行数据,勾选任意一行复选框,整个页面(包括顶部统计卡片、筛选栏、分页器)全部重渲染。React DevTools Profiler 显示单次交互 commit 耗时 180ms,其中 90% 的组件 props 根本没变。
- Context value 每次 render 都是新对象引用,
useMemo依赖写错一次就前功尽弃,code review 时没人能保证不漏。 - Vue 子应用更原始:
props从App.vue透传到第 7 层叶子组件,中间层组件被迫声明一堆自己用不到的 props,改一个字段名要动 5 个文件。
压垮骆驼的最后一根稻草是一个跨模块的"当前选中门店"状态:订单、库存、报表三个模块都要读,用 Context 后切门店会触发三个模块同时重渲染,其中两个模块当时根本不在可视区。
二、环境与版本
- React 18.2.0 + TypeScript 4.9.5 + Vite 4.3.9
- 状态库:Zustand 4.4.1(选它是因为 API 最小、无 Provider 嵌套、TS 推导好)
- Vue 3.3.4 + Pinia 2.1.4(Vue 官方推荐,没有悬念)
- 性能测量:React DevTools Profiler 4.28、Chrome 118 Performance 面板,测试机 MacBook Pro M1 / 16G,数据取 5 次中位数
对比过的方案(React 侧):
| 方案 | 包体积(gzip) | 学习成本 | 重渲染控制 | 结论 |
|---|---|---|---|---|
| Context + useReducer | 0 | 低 | 差,需手动拆多个 Context | 现状,放弃 |
| Redux Toolkit 1.9 | ~13KB | 中 | 好,需 useSelector | 团队嫌样板代码多 |
| Zustand 4.4 | ~1.2KB | 低 | 好,selector 粒度细 | 采用 |
| Jotai 2.4 | ~3KB | 中 | 原子级最优 | 适合细粒度,但我们状态偏"模块级",略重 |
Vue 侧没得选,Pinia 就是事实标准,setup store 写法跟 Composition API 心智一致。
三、方案设计:按"状态生命周期"切分,而不是按页面切
迁移前先做了一件事:把所有状态按生命周期分类,而不是按页面归类。这一步比选库重要得多。
- 服务端状态(列表数据、详情):本来就不该进全局 store,用 TanStack Query / SWR 管,这次不动。
- 模块级 UI 状态(筛选条件、分页、选中项):进各自模块的 store,一个模块一个文件。
- 全局状态(当前门店、用户权限、主题):单独
globalStore,字段控制在 10 个以内。 - 表单临时状态:留在组件内
useState,不迁移。
React 侧文件结构:
src/stores/
globalStore.ts // 门店、用户、权限
orderStore.ts // 订单模块
inventoryStore.ts // 库存模块
关键设计决策:store 里只放数据和同步 action,异步请求留在组件或 hooks 里。Zustand 官方也建议这么干,避免 store 变成"什么都往里塞"的垃圾桶。异步拉数据后调 setOrders(data) 即可。
四、核心实现
React 侧:Zustand store 与 selector 写法
先看 globalStore。注意 create 的泛型写法,以及 action 和 state 放在同一层(Zustand 4.x 推荐,不再需要分成两个参数):
// src/stores/globalStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface GlobalState {
currentStoreId: string | null;
permissions: string[];
setCurrentStore: (id: string) => void;
setPermissions: (p: string[]) => void;
}
export const useGlobalStore = create()(
subscribeWithSelector((set) => ({
currentStoreId: null,
permissions: [],
setCurrentStore: (id) => set({ currentStoreId: id }),
setPermissions: (p) => set({ permissions: p }),
}))
);
// 派生 selector 单独导出,避免组件里写内联对象导致每次新引用
export const selectCurrentStoreId = (s: GlobalState) => s.currentStoreId;
export const selectPermissions = (s: GlobalState) => s.permissions;
组件里这样用,一次只订阅一个字段:
// src/pages/order/OrderList.tsx
import { useGlobalStore, selectCurrentStoreId } from '@/stores/globalStore';
import { useOrderStore } from '@/stores/orderStore';
export function OrderList() {
// 只订阅 currentStoreId,门店权限变化不会触发本组件重渲染
const currentStoreId = useGlobalStore(selectCurrentStoreId);
const orders = useOrderStore((s) => s.orders);
const toggleSelect = useOrderStore((s) => s.toggleSelect);
return (
toggleSelect(id),
}}
/>
);
}
这里有个必须强调的点:不要写 useOrderStore((s) => ({ orders: s.orders, total: s.total }))。Zustand 4.x 默认用 Object.is 比较返回值,返回新对象每次都会判定为变化,等于没优化。要么拆成两次调用,要么用 useShallow:
import { useShallow } from 'zustand/react/shallow';
const { orders, total } = useOrderStore(
useShallow((s) => ({ orders: s.orders, total: s.total }))
);
useShallow 是 Zustand 4.4 才正式从 zustand/shallow 挪到 zustand/react/shallow 的,老版本路径不一样,升级时容易 import 错。
Vue 侧:Pinia setup store
Vue 侧用 setup 语法,和组件写法一致,迁移成本最低:
// src/stores/orderStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useOrderStore = defineStore('order', () => {
const orders = ref([]);
const selectedIds = ref>(new Set());
const selectedCount = computed(() => selectedIds.value.size);
function toggleSelect(id: string) {
const next = new Set(selectedIds.value);
next.has(id) ? next.delete(id) : next.add(id);
selectedIds.value = next; // 必须赋新引用,Set 的 mutation 不触发响应
}
return { orders, selectedIds, selectedCount, toggleSelect };
});
组件里用 storeToRefs 解构保持响应性,方法直接从 store 拿:
import { storeToRefs } from 'pinia';
import { useOrderStore } from '@/stores/orderStore';
const orderStore = useOrderStore();
const { orders, selectedCount } = storeToRefs(orderStore);
const { toggleSelect } = orderStore;
五、踩坑与优化
坑 1:Context 迁移时把"服务端状态"也搬进了 store。 第一版把订单列表数据放进 Zustand,结果切门店时要手动清空、手动 refetch、手动处理竞态,写了 80 行样板。后来退回 TanStack Query,store 只留 selectedIds 这种纯 UI 状态,代码少了三分之一。
坑 2:Vue 的 Set 响应性。 上面代码里 selectedIds.value = next 这行是踩坑后改的。直接 selectedIds.value.add(id) 在 Vue 3.3 里对 ref(new Set()) 不会触发更新(深层响应式对 Set 的 mutation 追踪有边界),必须换引用。这个 bug 上线前测试没发现,因为测试用例是刷新页面验证的。
坑 3:Zustand 的 selector 里做计算。 有同事写了 useOrderStore((s) => s.orders.filter(o => o.status === 'paid')),每次 store 任何字段变化都重新 filter,200 条数据下 CPU 占用明显。改成在 store 里维护 paidOrders 派生字段,或用 useMemo 包一层。
优化: 给 Zustand 加了 subscribeWithSelector 中间件,在非组件环境(比如埋点、WebSocket 回调)里监听门店切换:
useGlobalStore.subscribe(
selectCurrentStoreId,
(id, prevId) => {
if (id !== prevId) reportStoreSwitch(id);
}
);
六、效果数据
迁移前后用同一份测试数据(订单 200 条、门店 12 个)在 Chrome Performance 面板测 5 次取中位数:
| 指标 | 迁移前(Context/props) | 迁移后(Zustand/Pinia) | 变化 |
|---|---|---|---|
| 订单列表首屏渲染 | 1180ms | 420ms | -64% |
| 勾选单行 commit 耗时 | 180ms | 22ms | -88% |
| 切门店触发重渲染组件数 | 47 | 7 | -85% |
| Vue 侧 props 透传最深层级 | 7 层 | 0(store 直取) | - |
| 状态相关代码行数 | ~2100 | ~1400 | -33% |
代码行数下降主要来自删掉中间的 props 声明和 Context Provider 嵌套。有个反直觉的点:包体积几乎没变(Zustand +1.2KB gzip,Pinia +4KB),因为删掉的 Context 样板和 props 类型定义差不多抵消了。
七、总结
三句话:
- Context 不是状态管理库,它解决的是依赖注入,不是高频状态分发。字段超过 10 个、更新频率高于每秒一次,就该考虑换。
- 迁移的关键不是选库,是先把状态按生命周期分类。服务端状态、模块 UI 状态、全局状态混在一起,换什么库都救不了。
- selector 粒度决定性能上限。Zustand 的
useShallow、Pinia 的storeToRefs,这两个 API 用错,迁移等于白做。
如果重来一次,我会在项目初期就直接上 Zustand/Pinia,而不是"先用 Context 顶着,以后再说"。这个"以后"的迁移成本,是初期多写 200 行 store 代码的十倍。