**
一、问题背景:Context真的够用吗?
项目是一个运营中台,左侧是React 18.2写的配置面板,右侧嵌入Vue 3.3的实时数据看板。最初为了快速上线,状态管理全靠最朴素的方式:React侧用Context + useReducer,Vue侧用Props + emit逐层传递。
上线三个月后问题集中爆发:
- React侧:一个「全局筛选条件」Context包裹了整棵组件树,用户每切换一次时间范围,哪怕只是改了一个字段,所有消费该Context的组件全部重渲染。用React DevTools Profiler录制,一次筛选操作触发了63个组件重渲染,其中只有8个真正依赖该数据。
- Vue侧:看板有5层嵌套,一个
chartData要从顶层传到第5层的图表组件,中间3层纯做「二传手」。改一个字段名要动4个文件。 - 性能数据: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量化收益,再决定要不要全量迁移。数据会说服人。