报告:我想拦截浏览器的请求

报告:我想拦截浏览器的请求

不知道大家平时有没有遇到这样的需求:拦截浏览器的请求。这里说的拦截,是指在客户端(浏览器)的拦截,因为如果是服务器的拦截,那么请求就已经发出去了,这可能并不是我们想看到的。

最近,我在搭建组件库文档平台,部分业务组件里深度封装了一些数据请求,又或者埋点上报,这就产生了几个问题:

  1. 跨域问题的报错;
  2. 文档不关心埋点是否上报,只关心组件是否正常显示,组件使用假数据就能满足要求;
  3. 文档产生的数据请求或者埋点上报额外消耗了服务器的资源,还可能造成数据的污染;

综上,需要在浏览器端就把组件发出的请求拦截下来,并mock数据。文档一般以静态站点的方式部署,这样做显然更加纯粹。

那么,该如何拦截浏览器的请求?分别有哪些方法?这篇文章将娓娓道来。

代理XHR或fetch

我们熟知的ajax和axios的底层都是封装了XHR( XMLHttpRequest ),如果我们可以修改全局的XHR,就能拦截ajax或axios发出的请求。下面是一种简单的写法:

const open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
  let redirectUrl = url;
  // 更换路由
  if (url === '/api/a') {
    redirectUrl = '/api/b';
  open.call(this, method, redirectUrl, ...args);

当我想如法炮制改写XHR的 onload 或者 onloadend 方法,chrome报了 Illeagal invocation 的错误。我猜想是因为浏览器对 XMLHttpRequest.prototype 做了拦截,如果触发 onload 或者 onloadend getter 就会抛出错误。

那么,有办法修改XHR的 onload 或者 onloadend 方法吗?

经过一番搜索,终于找到答案。大家的实现都是通过重写XHR的构造函数,对XHR实例上的方法进行加工,具体代码可以参考 这里 。简化一下就是:

const originalXHR = XMLHttpRequest;
XMLHttpRequest = function () {
  const xhr = new originalXHR();
  for (let attr in xhr) {
    let type = '';
    try {
      type = typeof xhr[attr]; // 某些浏览器上可能有异常
    } catch (e) {}
    if (type === 'function') { // open, send, addEventListener等
      this[attr] = function (...args) {
        return xhr[attr].bind(this, ...args);
    } else { // onload, onloadend, timeout等
      Object.defineProperty(this, attr, {
        get: function (...args) {
          return xhr[attr].bind(this, ...args);
        set: function (cb) {
          xhr[attr] = cb;
        enumerable: true,

不过,虽然我们在客户端拦截到了请求,但还是没办法不经过服务器而直接修改返回。如果你有解决办法,欢迎告诉我。

另外,axios之类的请求库也可能会修改XHR相关的方法,要注意相互覆盖的问题。

fetch方法的代理更简单,而且可以直接修改返回数据。

const originalFetch = window.fetch;
window.fetch = function (url) {
  if (url.indexOf('/api/user') > -1) {
    return Promise.resolve(new Response(JSON.stringify({name: 'Thomas'})));
  return originalFetch(url);

不过,对于通过html标签请求CSS、js、图片都不会经过XHR或fetch。

代理XHR或fetch要注意全局污染的问题。

Service Worker

从2016年Google提出概念,到2018年在移动端的爆火,PWA将Service Worker带进我们的视野。Service Worker相当于浏览器和服务器之间的一层代理,可以管理静态资源和网络请求的缓存,让用户能够离线访问应用以及获得良好的体验效果。

不同于一般的Http缓存策略(利用Http头部),Service Worker通过js直接管理缓存,我们可以在客户端接管所有的请求并且定制缓存策略。

先来回顾一下Service Worker的生命周期和使用方式。

首先,在主线程注册Service Worker:

  window.addEventListener('load', () => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js').then(() => {
        console.log('注册成功');
      }).catch((error) => {
        console.log('注册失败');

这一步会下载 sw.js 并执行,然后依次经过下面的生命周期并触发回调。

每个生命周期都有对应的监听方法,下面列举几个比较重要的:

// sw.js
self.addEventListener('install', event => {
  // 安装中
  event.waitUntil(
    // 可以预缓存重要的和长期的资源
self.addEventListener('activate', event => {
  // 已激活,成功控制客户端
  // 如果activate足够快,clients.claim的调用在请求之前,就能保证第一次请求页面也能拦截请求
  clients.claim();
  event.waitUntil(
    // 可以在这里清除旧的缓存
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  // 拦截/api/user接口的请求,返回自定义的response
  if (url.pathname == '/api/user') {
    event.respondWith(
      // response body只能接受String, FormData, Blob等类型,所以这里序列化
      new Response(JSON.stringify({ name: 'Javen' }), {
        headers: {
          'Content-Type': 'application/json',

我们要拦截浏览器的请求,关键在于监听 fetch 事件,匹配请求的url,然后返回mock数据。

值得注意的是:默认情况下,除非对页面本身( index.html )的请求经过了Service Worker,否则页面中的其他请求都不会经过Service Worker。也就是说,初次请求页面(未安装Service Worker)时, index.html 未经过Service Worker,所以当第二次请求页面(刷新页面)或者进入网站的其他页面时, fetch 的拦截才生效。不过,我们可以在 activate 事件里调用 clients.claim() ,让Service Worker立即控制所有未被控制的页面,从而尽可能保证初次请求也能被拦截到。之所以说“尽可能”,是因为如果 clients.claim() 的调用慢于请求的发起,Service Worker还是拦截不到请求。权且把这个坑记为 问题1 ,后面会讲解决办法。

self.addEventListener('activate', event => {
  // 如果activate足够快,clients.claim的调用在请求之前,就能保证初次请求页面也能拦截请求
  self.clients.claim();

另外,当有新版的Service Worker时,它在install之后进入waiting阶段,等待上一次的Service Worker结束(比如关掉页面)后,才会变成activate状态。为了让新版Service Worker能够自动激活,我们可以在 install 事件里调用 skipWaiting 方法:

self.addEventListener('install', event => {
  self.skipWaiting();

Chrome插件

大多前端开发的同学都知道 Proxy SwitchyOmega 这款chrome插件,它可以拦截浏览器的请求并转发,配合 whistle 等代理工具可以很方便地开发调试、定位问题。

chrome插件可以阻止、转发请求,还能修改request headers和response headers。

下面是一个很简单的转发请求的例子,使用Manifest V3的写法:

// manifest.json
  "name": "Http proxy",
  "description": "拦截浏览器请求",
  "version": "1.0",
  "manifest_version": 3,
  "permissions": ["declarativeNetRequest"],
  "host_permissions": ["*://127.0.0.1/*"],
  "declarative_net_request": {
    "rule_resources": [
        "id": "ruleset",
        "enabled": true,
        "path": "rules.json"
}


// rules.json
    "id": 1,
    "priority": 1,
    "action": {
      "type": "redirect",
      "redirect": {
        "url": "http://127.0.0.1:3000/api/b"
    "condition": {
      "urlFilter": "http://127.0.0.1:3000/api/a",
      "resourceTypes": ["xmlhttprequest"]
]

这样, /api/a 的请求就转发给了 /api/b

虽然chrome插件能阻止和转发请求,但它不能不经过服务器直接修改响应。

CDP

全称Chrome DevTools Protocol,它允许开发者检测、检查、调试和分析 Chromium、Chrome 和其他基于 Blink 的浏览器。VS Code、Puppeteer、React Native远程调试工具等都有用到CDP。

下面就来看看如何利用CDP拦截浏览器请求。

首先,在命令行运行以下命令, 9222 是chrome远程调试的端口。

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=remote-profile

我们还会用到 chrome-remote-interface ,它是实现CDP对应js语言的一套接口实现。

一个比较简单的例子:

const CDP = require('chrome-remote-interface');
async function intercept(url) {
  let client;
  try {
    client = await CDP();
    const { Network, Page, Fetch } = client;
    // 所有网络请求发出前触发,打印能看到请求的url
    Network.requestWillBeSent((params) => {
      console.log(params.request.url);
    Fetch.requestPaused((params) => {
      // 拦截到请求并返回自定义body
      Fetch.fulfillRequest({
        requestId: params.requestId,
        responseCode: 200,
        body: Buffer.from(JSON.stringify({ name: 'Thomas' })).toString(
          'base64'
    // 符合以下模式的请求才会Fetch.requestPaused事件
    await Fetch.enable({
      patterns: [
          urlPattern: '*/api/user',
          resourceType: 'XHR',
    // 允许跟踪网络,这时网络事件可以发送到客户端
    await Network.enable();
    await Page.enable();
    await Page.navigate({ url });
    // 等待页面加载
    await Page.loadEventFired();
  } catch (err) {
    console.error(err);
// 我在本地3000端口起了服务,随便换一个网址也能看到页面各种资源的请求
intercept('http://127.0.0.1:3000/');

至此,我们知道CDP能拦截浏览器的请求并mock数据,但要在9222端口起chrome实例,并且需要在本地有一个client。这种方式显然是不满足我们的要求的。

Chrome插件+CDP

Chrome插件虽然没办法直接修改接口响应,但它结合CDP却可以。Chrome插件的 chrome.debugger API允许我们绑定具体的Tab页,然后使用CDP去检测网络请求。

manifest.json 里配置 background 脚本:

{
  "background": {
    "service_worker": "background.js"

background.js 的代码大体如下:

chrome.runtime.onInstalled.addListener(() => {
  chrome.debugger.getTargets((list) => {
    const target = list.find((item) => item.url === 'http://127.0.0.1:3000/');
    const tabId = target.tabId;
    // 绑定到想要的tab
    chrome.debugger.attach({ tabId }, '1.3', async () => {
      chrome.debugger.onEvent.addListener(
        async (source, method, params) => {
          // 当前tab且拦截到对应的接口请求
          if (source.tabId === tabId && method === 'Fetch.requestPaused') {
            await chrome.debugger.sendCommand(
              { tabId },
              'Fetch.fulfillRequest',
                requestId: params.requestId,
                responseCode: 200,
                body: btoa(JSON.stringify({ name: 'thomas' })),
      await chrome.debugger.sendCommand({ tabId }, 'Fetch.enable', {
        patterns: [
            urlPattern: '*/api/user',
            resourceType: 'XHR',

这样,就可以在浏览器端直接mock数据了。

小结

文章讲了几种拦截浏览器请求的方法,并分析了它们的优劣和适用场景。

代理XHR可能出现 onload 等方法被覆盖的问题,不太安全。而且它只能转发请求,还需要服务端配合才能mock数据。无论代理XHR或者fetch,都要注意执行顺序和全局污染的问题。

Service Worker可以直接在客户端就将请求拦截下来,并返回mock数据,但初次注册的时候它无法保证一定能拦截请求。

CDP也一样能拦截浏览器请求并mock数据,但它需要打开调试的chrome实例,并且需要有一个client做监听。

Chrome插件能阻止和转发请求,但如果要mock数据,需要服务端或者CDP的配合。但无论如何,插件是相对独立的模块,使用时需要额外的安装,这样感觉会比较割裂和麻烦。

上面介绍了那么多种方法,好像多多少少都有些问题,难道就没有比较好的解决方案吗?

有,那就是开源库 msw

msw

Mock Service Worker 的缩写,使用Service Worker拦截请求的一个API mock库。它在Service Worker的解决方案上,又代理了全局的XHR和fetch,保证Service Worker激活之后才发出接口请求。这就完美解决了上面的 问题1 。具体代码在 src/setupWorker/start/createStartHandler.ts

// Defer any network requests until the Service Worker instance is ready.