**

一、问题背景:Context真的够用吗?

项目是一个运营中台,左侧是React 18.2写的配置面板,右侧嵌入Vue 3.3的实时数据看板。最初为了快速上线,状态管理全靠最朴素的方式:React侧用Context + useReducer,Vue侧用Props + emit逐层传递。

上线三个月后问题集中爆发:

  1. React侧:一个「全局筛选条件」Context包裹了整棵组件树,用户每切换一次时间范围,哪怕只是改了一个字段,所有消费该Context的组件全部重渲染。用React DevTools Profiler录制,一次筛选操作触发了63个组件重渲染,其中只有8个真正依赖该数据。
  2. Vue侧:看板有5层嵌套,一个chartData要从顶层传到第5层的图表组件,中间3层纯做「二传手」。改一个字段名要动4个文件。
  3. 性能数据:Lighthouse测出TBT(Total Blocking Time)420ms,交互到下一次绘制(INP)平均180ms,已经接近「需改进」阈值。

结论很明确:Context适合低频、稳定的全局数据(如主题、用户信息),Props适合父子直连。但高频更新的业务状态,必须换方案。

二、环境与版本

先交代清楚技术栈,避免版本差异导致行为不一致:

  • React 18.2.0 + TypeScript 5.3.3 + Vite 5.0.10
  • Vue 3.3.11 + TypeScript 5.3.3 + Vite 5.0.10
  • 状态库选型:React侧 Zustand 4.5.0,Vue侧 Pinia 2.1.7
  • 性能采集:React DevTools Profiler、Vue Devtools 6.6、Chrome Performance面板
  • 测试机:MacBook Pro M1 / 16GB,Chrome 121

选型理由:项目体量中等,不需要Redux那么重的模板代码,也不想要MobX的隐式响应。Zustand的selector订阅和Pinia的setup语法刚好匹配团队习惯,且两者API风格接近,降低心智负担。

三、方案设计:为什么是它们

我对比了三个候选方案,用表格说话:

维度 Context/Props Redux Toolkit Zustand / Pinia
重渲染控制 全量订阅,需手动memo 需useSelector+shallow selector精确订阅
模板代码 多(slice/thunk) 极少
学习成本
跨组件通信
DevTools 中(够用)

核心设计原则:按「更新频率」切分store,而不是按「业务模块」。高频的筛选条件单独一个store,低频的用户配置另一个。这样selector的粒度天然更细。

四、核心实现:迁移步骤与代码

React侧:Context → Zustand

第一步,建store。把原来Context里的filter状态抽出来:

// stores/useFilterStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface FilterState {
  dateRange: [string, string];
  channel: string;
  setDateRange: (range: [string, string]) => void;
  setChannel: (channel: string) => void;
}

export const useFilterStore = create()(
  subscribeWithSelector((set) => ({
    dateRange: ['2024-01-01', '2024-01-31'],
    channel: 'all',
    setDateRange: (dateRange) => set({ dateRange }),
    setChannel: (channel) => set({ channel }),
  }))
);

第二步,组件里用selector订阅,只取自己需要的字段。这是性能提升的关键:

// components/ChannelSelect.tsx
import { useFilterStore } from '@/stores/useFilterStore';

export function ChannelSelect() {
  // 只订阅channel,dateRange变化不会触发本组件重渲染
  const channel = useFilterStore((s) => s.channel);
  const setChannel = useFilterStore((s) => s.setChannel);

  return (
     setChannel(e.target.value)}>
      全部
      微信
      抖音

  );
}

注意setChannel这种函数引用是稳定的,单独取出来不会造成额外渲染。如果selector返回对象,必须用useShallow,否则每次都是新引用:

import { useShallow } from 'zustand/react/shallow';

// 错误:每次返回新对象,必然重渲染
// const { channel, dateRange } = useFilterStore((s) => ({ channel: s.channel, dateRange: s.dateRange }));

// 正确:浅比较
const { channel, dateRange } = useFilterStore(
  useShallow((s) => ({ channel: s.channel, dateRange: s.dateRange }))
);

Vue侧:Props → Pinia

Vue侧用setup语法,把原来逐层传的chartData收进store:

// stores/useDashboardStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useDashboardStore = defineStore('dashboard', () => {
  const rawData = ref([]);
  const filterChannel = ref('all');

  // 只有filterChannel变化时,这个computed才重算
  const chartData = computed(() =>
    rawData.value.filter((_, i) => filterChannel.value === 'all' || i % 2 === 0)
  );

  async function fetchData() {
    const res = await fetch('/api/dashboard');
    rawData.value = await res.json();
  }

  return { rawData, filterChannel, chartData, fetchData };
});

第5层的图表组件直接用,中间3层彻底解放:

import { storeToRefs } from 'pinia';
import { useDashboardStore } from '@/stores/useDashboardStore';

const store = useDashboardStore();
// storeToRefs保持响应性,直接解构store会丢失响应
const { chartData } = storeToRefs(store);




    数据点:{{ chartData.length }}

五、踩坑与优化

坑1:Zustand的selector返回新数组。 我一开始写useFilterStore((s) => s.dateRange.map(...)),结果每次渲染都触发,因为map返回新数组。改成在store里用computed派生,或者用useShallow

坑2:Pinia解构丢响应性。 const { chartData } = store这行看起来没问题,但解构后就变成普通值了。必须用storeToRefs

坑3:SSR场景下的store污染。 项目后来加了SSR,Zustand的模块级store在服务端会跨请求共享。解决方法是把store实例挂到Context上,每个请求创建一次。

优化点:给Zustand加了subscribeWithSelector中间件,在store外部监听特定字段变化,用来触发埋点上报,避免在组件里写useEffect。

六、效果数据

迁移用了约3人日,覆盖React侧12个组件、Vue侧8个组件。重构前后对比:

指标 重构前 重构后 变化
单次筛选重渲染组件数 63 18 ↓71.4%
TBT 420ms 145ms ↓65.5%
INP 180ms 45ms ↓75%
首屏JS体积 312KB 298KB ↓4.5%

重渲染次数用React Profiler的「Render count」统计,INP取Chrome Performance面板连续10次操作的中位数。JS体积略降是因为删掉了不少层层传递的props类型定义和memo包装。

七、总结

Context和Props不是不能用,而是要用对场景。判断标准很简单:数据更新频率 × 消费组件数量。两者都高,就上状态库;都低,Context完全够。

Zustand和Pinia的共同优势是「selector级订阅」,这是Context做不到的。迁移成本比想象中低,真正的难点不在API,而在重新梳理状态的边界——哪些状态该放一起,哪些该拆开。我建议按更新频率切分,而不是按页面或模块,这样性能收益最明显。

如果你的项目也卡在Context重渲染上,不妨先拿一个高频模块试点,用Profiler量化收益,再决定要不要全量迁移。数据会说服人。