一、问题背景:Context 不是状态管理库
项目是一个运营后台,React 18.2 负责数据看板和报表模块,Vue 3.4 负责配置中心(历史原因,两套栈并存,别问为什么)。早期状态很简单,全局就一个 AppContext,塞了用户信息、权限、主题、当前租户、字典缓存。业务组件之间传参靠 Props,跨层级就 useContext。
问题在报表模块上线后爆发。一个页面有 6 个筛选器、3 个图表、1 个明细表格,全部依赖 AppContext 里的 tenant 和 filters。用户拖动时间范围滑块时:
AppContext.Provider的 value 每次都是新对象,所有useContext消费者全部重渲染;- 明细表格 500 行 × 8 列,每行都订阅了 context,一次拖动触发 4100+ 次组件渲染;
- 输入框明显掉帧,React DevTools Profiler 里单次 commit 耗时 180ms。
Vue 侧类似,用 provide/inject 注入了一个 reactive 大对象,任何字段变更都让注入组件全部更新。
核心认知:Context / provide-inject 是依赖注入机制,不是状态管理方案。它们没有选择器、没有细粒度订阅、没有 DevTools 时间旅行,value 一变全量通知。状态一多、订阅者一多,就是灾难。
二、环境与版本
react 18.2.0
react-dom 18.2.0
zustand 4.5.2
vue 3.4.21
pinia 2.1.7
vite 5.2.8
typescript 5.4.5
@vitejs/plugin-react 4.2.1
选型对比(只看我们实际评估过的):
| 维度 | Context/Props | Redux Toolkit | Zustand | Jotai | Pinia(Vue) |
|---|---|---|---|---|---|
| 细粒度订阅 | 无 | 需 useSelector | 原生选择器 | 原子级 | 原生选择器 |
| 模板代码 | 少 | 多(slice) | 极少 | 少 | 少 |
| 包体积(gzip) | 0 | ~13KB | ~1.2KB | ~3KB | ~1.5KB |
| DevTools | 无 | 强 | 中(devtools中间件) | 弱 | 强 |
| 学习成本 | 低 | 高 | 低 | 中 | 低 |
结论:React 侧选 Zustand,理由是选择器 + 浅比较开箱即用、无需 Provider 包裹、能在组件外读写(请求拦截器里拿 token 很方便);Vue 侧选 Pinia,官方推荐、DevTools 完善、和 Vue 响应式系统天然契合。Redux Toolkit 对我们这种规模属于杀鸡用牛刀,Jotai 的原子拆分对现有代码改动太大。
三、方案设计:按"变更频率"切分 store
迁移不是把 Context 整个搬进 store,而是先做状态分域。我们按变更频率和订阅范围切成四块:
userStore:登录态、用户信息、权限码。低频变更,全局订阅。tenantStore:当前租户、租户配置。中频,切换租户时整体刷新。filterStore:报表筛选条件(时间范围、维度、指标)。高频,滑块拖动时每秒变几十次。dictStore:字典缓存。低频写、高频读,带 TTL。
关键设计原则:
- 高频状态单独成 store,避免和低频状态混在一个对象里导致无关组件重渲染;
- 组件只订阅自己需要的字段,用选择器函数,返回原始值或稳定引用;
- 派生数据放选择器里算,不放 store,避免冗余状态同步问题;
- 异步 action 放 store 内,但请求层保持独立,store 只做状态编排。
四、核心实现
4.1 React:Zustand store 与选择器
// stores/filterStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
export interface FilterState {
dateRange: [string, string];
dimension: 'day' | 'week' | 'month';
metrics: string[];
keyword: string;
setDateRange: (r: [string, string]) => void;
setDimension: (d: FilterState['dimension']) => void;
setMetrics: (m: string[]) => void;
setKeyword: (k: string) => void;
}
export const useFilterStore = create()(
subscribeWithSelector((set) => ({
dateRange: ['2024-01-01', '2024-03-31'],
dimension: 'day',
metrics: ['pv', 'uv'],
keyword: '',
setDateRange: (dateRange) => set({ dateRange }),
setDimension: (dimension) => set({ dimension }),
setMetrics: (metrics) => set({ metrics }),
setKeyword: (keyword) => set({ keyword }),
}))
);
// 选择器:只取需要的字段,多字段用 shallow 比较
export const useFilterSummary = () =>
useFilterStore(
(s) => ({ dateRange: s.dateRange, dimension: s.dimension }),
shallow
);
组件侧,原来 useContext(AppContext) 的解构改成按需订阅。注意 dateRange 是数组,每次 set 若传新数组引用会触发重渲染,所以选择器里返回数组时要么用 shallow,要么在 setter 里做引用复用:
// 改造前:一次 context 变更全量重渲染
const { filters, tenant } = useContext(AppContext);
// 改造后:只订阅 dimension,滑块改 dateRange 不会触发本组件
const dimension = useFilterStore((s) => s.dimension);
const setDimension = useFilterStore((s) => s.setDimension);
明细表格这种重组件,用 useShallow 包一层,并给行组件加 React.memo,把"订阅粒度"和"渲染粒度"都压下去。
4.2 Vue:Pinia store 与 storeToRefs
// stores/filter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useFilterStore = defineStore('filter', () => {
const dateRange = ref(['2024-01-01', '2024-03-31']);
const dimension = ref('day');
const metrics = ref(['pv', 'uv']);
const rangeLabel = computed(() => `${dateRange.value[0]} ~ ${dateRange.value[1]}`);
function setDateRange(r: [string, string]) {
dateRange.value = r;
}
function setDimension(d: typeof dimension.value) {
dimension.value = d;
}
return { dateRange, dimension, metrics, rangeLabel, setDateRange, setDimension };
});
组件里必须用 storeToRefs 解构,直接解构会丢响应性——这是 Vue 侧最常见的坑:
import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filter';
const store = useFilterStore();
const { dimension, rangeLabel } = storeToRefs(store); // 保持响应式
const { setDimension } = store; // action 直接解构没问题
{{ rangeLabel }}
五、踩坑与优化
坑 1:选择器返回新对象导致无限重渲染。 Zustand 默认用 Object.is 比较选择器返回值,(s) => ({ a: s.a, b: s.b }) 每次都是新对象,配合 useEffect 依赖会死循环。解法:多字段用 useShallow,或拆成多个单字段订阅。
坑 2:数组/对象状态原地修改不触发更新。 Zustand 的 set 是浅合并,s.metrics.push(x) 不会通知订阅者。统一改成 set({ metrics: [...s.metrics, x] })。
坑 3:Vue 直接解构 store 丢响应性。 见上,必须 storeToRefs。
坑 4:滑块拖动触发高频 set。 即使有选择器,每秒几十次 set 也会让图表重算。对 setDateRange 加了 80ms 防抖(用 lodash-es 的 debounce,注意在 store 外部创建避免每次调用重建),图表侧再配合 requestAnimationFrame 批量更新。
坑 5:迁移期间双状态源不一致。 我们按模块灰度迁移,过渡期 Context 和 store 并存,出现过一次"Context 改了 store 没改"的 bug。解法是写了个临时同步层:Context 的 setter 内部调用 store action,单向数据流,迁完一个模块删一个。
坑 6:DevTools 看不到状态。 Zustand 需要显式加 devtools 中间件,且和 subscribeWithSelector 组合时注意中间件顺序:devtools(subscribeWithSelector(...))。
六、效果数据
用 React Profiler 和 Vue DevTools 在同一个报表页面(500 行表格 + 3 图表 + 6 筛选器)上测:
| 指标 | 迁移前(Context) | 迁移后(Zustand) | 变化 |
|---|---|---|---|
| 单次滑块拖动渲染次数 | 4120 | 320 | -92% |
| 单次 commit 耗时 | 180ms | 24ms | -87% |
| 首屏可交互(TTI) | 2.8s | 1.6s | -43% |
| 首屏 JS 体积(gzip) | 基准 | +1.2KB | 可接受 |
Vue 侧配置中心页面,provide/inject 改成 Pinia 后,表单联动场景的组件更新次数从约 900 降到 110,输入延迟从可感知的卡顿变成无感。
迁移总工时约 5 人日(React 3 天、Vue 2 天),涉及 14 个 store 文件、60+ 组件改造,回归测试覆盖了主要业务流程。
七、总结
一句话:Context / provide-inject 适合低频、小范围的依赖注入,一旦状态变高频、订阅者变多,就该上真正的状态管理库。
几条落地建议:
- 先分域再迁移,高频状态单独成 store,别把 Context 原样搬过去;
- React 侧选择器是性能关键,多字段用
useShallow,数组状态永远返回新引用; - Vue 侧
storeToRefs别忘,action 可以放心解构; - 迁移期做单向同步,别搞双向,否则状态源打架排查到怀疑人生;
- 高频写入配防抖 + 批量更新,库再好也救不了每秒 60 次 set。
Zustand 和 Pinia 都不是银弹,但在这个量级的项目里,它们用最小的改造成本换来了数量级的渲染优化,值。