用Proxy和全局拦截实现Fetch请求的灵活控制方案

上官利娇 前端 阅读 1,701
赞 154 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个数据看板,用户反馈“点开页面要等五秒才出数据”,我第一反应是“不可能吧,接口就几个 GET,连 mock 都跑得飞快”。结果自己点开一试——真卡。F12 打开 Network,发现首页要发 12 个 fetch 请求,其中 4 个是重复的 /api/user/profile(因为三个组件各自 init 时都调了一次),还有 3 个是串行依赖的(A 成功后才发 B,B 成功后才发 C)。最离谱的是,有个 fetch('/api/dashboard/metrics') 响应头里没设 Cache-Control,但后端返回的 JSON 其实每小时才变一次,前端却次次重拉。

用Proxy和全局拦截实现Fetch请求的灵活控制方案

首屏渲染完,控制台还飘着三四个 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 控制,欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论