一、问题背景: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,而是先做状态分域。我们按变更频率和订阅范围切成四块:

  1. userStore:登录态、用户信息、权限码。低频变更,全局订阅。
  2. tenantStore:当前租户、租户配置。中频,切换租户时整体刷新。
  3. filterStore:报表筛选条件(时间范围、维度、指标)。高频,滑块拖动时每秒变几十次。
  4. 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 适合低频、小范围的依赖注入,一旦状态变高频、订阅者变多,就该上真正的状态管理库。

几条落地建议:

  1. 先分域再迁移,高频状态单独成 store,别把 Context 原样搬过去;
  2. React 侧选择器是性能关键,多字段用 useShallow,数组状态永远返回新引用;
  3. Vue 侧 storeToRefs 别忘,action 可以放心解构;
  4. 迁移期做单向同步,别搞双向,否则状态源打架排查到怀疑人生;
  5. 高频写入配防抖 + 批量更新,库再好也救不了每秒 60 次 set。

Zustand 和 Pinia 都不是银弹,但在这个量级的项目里,它们用最小的改造成本换来了数量级的渲染优化,值。