用Proxy和全局拦截实现Fetch请求的灵活控制方案
优化前:卡得不行
上周上线一个数据看板,用户反馈“点开页面要等五秒才出数据”,我第一反应是“不可能吧,接口就几个 GET,连 mock 都跑得飞快”。结果自己点开一试——真卡。F12 打开 Network,发现首页要发 12 个 fetch 请求,其中 4 个是重复的 /api/user/profile(因为三个组件各自 init 时都调了一次),还有 3 个是串行依赖的(A 成功后才发 B,B 成功后才发 C)。最离谱的是,有个 fetch('/api/dashboard/metrics') 响应头里没设 Cache-Control,但后端返回的 JSON 其实每小时才变一次,前端却次次重拉。
首屏渲染完,控制台还飘着三四个 pending 的 fetch,滚动一下,又触发两个新的 —— 不是懒加载逻辑写的,是某个监听 scroll 的 hook 里没做节流,直接 fetch('/api/scroll-ads?pos='+scrollTop),滚三下,发了七次。
找到瘼颈了!
先用 Chrome DevTools 的 Network → Waterfall 看请求时间线:60% 的请求在排队(Queueing),不是后端慢,是浏览器并发限制(Chrome 默认同源最多 6 个 TCP 连接)。再切到 Performance 录一段,发现 JS 主线程在 fetch 回调里密集解析大数组、反复 setState,导致 120ms 的长任务。
然后加了简单埋点:
const start = performance.now();
fetch('/api/data').then(res => {
console.log('fetch time:', performance.now() - start);
});
发现同一接口连续调三次,耗时分别是 1200ms、1180ms、1210ms —— 后端压根没动,纯属白发。
结论很清晰:不是网络慢,是重复请求 + 无缓存 + 无节流 + 回调阻塞主线程 四连击。
核心方案:Fetch 拦截层 + 轻量级请求管理
没上 Axios,也没搞复杂的请求库。就两件事:全局拦截 fetch,统一加缓存和去重;关键请求用 requestIdleCallback 做防抖降频。
先写个拦截器:
const FETCH_CACHE = new Map();
// 生成 cache key:method + url + sorted query string
function getCacheKey(input, init) {
const url = typeof input === 'string' ? input : input.toString();
const params = new URLSearchParams(new URL(url).search);
const sortedQuery = [...params.entries()].sort().map(([k,v]) => ${k}=${v}).join('&');
return ${init?.method || 'GET'}:${url.split('?')[0]}?${sortedQuery};
}
// 拦截 fetch
const originalFetch = window.fetch;
window.fetch = async function(input, init = {}) {
const key = getCacheKey(input, init);
// GET 请求且带 cache hint,走内存缓存
if (init.method === 'GET' && init.headers?.['X-Cache'] === 'memory') {
if (FETCH_CACHE.has(key)) {
const { data, timestamp } = FETCH_CACHE.get(key);
// 5s 内缓存有效
if (Date.now() - timestamp Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
});
}
}
}
// 真实请求
const res = await originalFetch(input, init);
// 缓存响应(仅 GET)
if (init.method === 'GET' && res.ok) {
const data = await res.clone().json();
FETCH_CACHE.set(key, { data, timestamp: Date.now() });
}
return res;
};
然后在业务代码里这么用:
// 组件 A
useEffect(() => {
fetch('/api/user/profile', {
headers: { 'X-Cache': 'memory' }
}).then(r => r.json()).then(setProfile);
}, []);
// 组件 B(同一页面另一处)
useEffect(() => {
fetch('/api/user/profile', {
headers: { 'X-Cache': 'memory' }
}).then(r => r.json()).then(setProfileAgain); // 第二次直接命中内存缓存,毫秒级
}, []);
注意:这里 X-Cache: memory 是我自定义的标记,不走标准 HTTP Cache,就是为了绕过服务端不配 Cache-Control 的坑。你也可以换成 headers: { 'X-Use-Cache': 'true' },看团队习惯。
再解决滚动触发爆炸问题:
let scrollFetchTimer = null;
function debouncedScrollFetch(scrollTop) {
if (scrollFetchTimer) clearTimeout(scrollFetchTimer);
scrollFetchTimer = setTimeout(() => {
// 只有空闲时才发请求
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
fetch(/api/scroll-ads?pos=${Math.round(scrollTop)})
.then(r => r.json())
.then(showAds);
}, { timeout: 2000 });
} else {
fetch(/api/scroll-ads?pos=${Math.round(scrollTop)})
.then(r => r.json())
.then(showAds);
}
}, 300);
}
滚动事件里只做节流,真正 fetch 推到 idle 时间片里执行,主线程完全不卡。
顺手优化:JSON 解析移到 Worker(小众但真有用)
有个接口返回 8MB 的 JSON(别问,是历史遗留报表),res.json() 直接让主线程卡住 300ms+。我懒得改后端分页,就甩给 Web Worker:
// parser.worker.js
self.onmessage = async ({ data }) => {
try {
const parsed = JSON.parse(data);
self.postMessage({ success: true, data: parsed });
} catch (e) {
self.postMessage({ success: false, error: e.message });
}
};
// 主线程
const worker = new Worker('./parser.worker.js');
worker.postMessage(largeJsonString);
worker.onmessage = ({ data }) => {
if (data.success) setData(data.data);
};
主线程不再卡,解析时间从 300ms 降到 120ms(Worker 多线程并行),而且 UI 完全流畅。
优化后:流畅多了
改完上线,用户没再提“卡”。我自己测:
- 首屏数据加载:从平均 5.2s → 820ms(降幅 84%)
- 重复请求次数:12 次 → 4 次(去重 + 缓存)
- 主线程长任务:从每秒 2~3 个(>100ms)→ 归零
- 滚动广告请求:从最多 17 次/秒 → 稳定 2~3 次/秒(且不阻塞渲染)
最爽的是,不用动任何后端代码,纯前端就能扛住这波流量。
性能数据对比
这是我在本地用 Lighthouse 跑的两次对比(same device, same network throttling):
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| FCP(首次内容绘制) | 3.4s | 1.1s | +68% |
| LCP(最大内容绘制) | 5.7s | 1.3s | +77% |
| Total Blocking Time | 1280ms | 42ms | -97% |
| Requests(首屏) | 12 | 4 | -67% |
注意:LCP 提升这么大,主要靠把大 JSON 解析移出主线程 + 数据预缓存。不是所有项目都需要 Worker,但只要遇到大 JSON,这招就是银弹。
踩坑提醒:这三点一定注意
- Map 缓存不要无限涨:我加了简单清理逻辑(超过 100 条就清一半),不然内存悄悄爆。生产环境一定要加 size 限制和 LRU。
- POST 请求不能缓存:上面代码只处理 GET,千万别把 POST 也塞进缓存,否则表单重复提交就炸了。
- fetch 拦截后,mock 工具可能失效:比如 MSW(Mock Service Worker)是基于底层 request 拦截的,而我们覆盖了 window.fetch,它就收不到。解决方案:MSW 文档里写了怎么兼容,搜 “MSW with custom fetch” 就行。
以上是我踩坑后的总结,希望对你有帮助
这个方案不是最优雅的(比如没做 SW 缓存兜底,也没接入 Sentry 监控缓存命中率),但它简单、可控、见效快。上线三天没报任何 fetch 相关 bug,我就知道这事成了。
如果你有更好的 fetch 拦截实践,比如用 Proxy 代理整个 fetch API、或者结合 AbortController 做更细粒度的 cancel 控制,欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论