首页 报告:我想拦截浏览器的请求
文章
取消

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

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

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

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

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

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

代理XHR或fetch

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

1
2
3
4
5
6
7
8
9
10
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或者onloadendgetter就会抛出错误。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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方法的代理更简单,而且可以直接修改返回数据。

1
2
3
4
5
6
7
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:

1
2
3
4
5
6
7
8
9
  window.addEventListener('load', () => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js').then(() => {
        console.log('注册成功');
      }).catch((error) => {
        console.log('注册失败');
      });
    }
  });

这一步会下载sw.js并执行,然后依次经过下面的生命周期并触发回调。 service-worker-life.png 每个生命周期都有对应的监听方法,下面列举几个比较重要的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 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_,后面会讲解决办法。

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

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

1
2
3
self.addEventListener('install', event => {
  self.skipWaiting();
});

Chrome插件

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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"
      }
    ]
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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远程调试的端口。

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

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

一个比较简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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.debuggerAPI允许我们绑定具体的Tab页,然后使用CDP去检测网络请求。

manifest.json里配置background脚本:

1
2
3
4
5
{
  "background": {
    "service_worker": "background.js"
  }
}

background.js的代码大体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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

1
2
3
4
5
6
// Defer any network requests until the Service Worker instance is ready.
// This prevents a race condition between the Service Worker registration
// and application's runtime requests (i.e. requests on mount).
if (options.waitUntilReady) {
  deferNetworkRequestsUntil(workerRegistration)
}

总结

至此,我们分析了多种拦截浏览器请求的办法以及它们的优缺点,最后发现msw巧妙地将Service Worker和代理XHR/fetch两种技术糅合在一起,得以弥补各自的缺陷,完美解决我们的问题。

参考资料

  1. https://github.com/axios/axios/blob/v1.x/lib/adapters/xhr.js
  2. https://juejin.cn/post/7019704757556084750
  3. https://gist.github.com/buaawp/1c9e503c460a3f6dc1a4eac77fffdc06#file-extendxmlhttprequest-js
  4. https://github.com/wendux/ajax-hook
  5. https://developer.chrome.com/docs/extensions/reference/declarativeNetRequest/
  6. https://www.divante.com/pwabook/chapter/02-the-history-of-pwas
  7. https://developer.chrome.com/docs/workbox/service-worker-overview/
  8. https://web.dev/service-worker-lifecycle/
  9. https://developer.mozilla.org/en-US/docs/Web/API/Response/Response
  10. https://github.com/mswjs/msw
  11. https://livebook.manning.com/book/progressive-web-apps/chapter-4/
  12. https://docs.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/how-to/service-workers
  13. https://chromedevtools.github.io/devtools-protocol/tot/Fetch/
  14. https://github.com/ChromeDevTools/awesome-chrome-devtools
  15. https://github.com/cyrus-and/chrome-remote-interface
  16. https://developer.chrome.com/docs/extensions/reference/debugger/
本文由作者按照 CC BY-NC-ND 3.0 进行授权

eslint为什么没有实现max-len的autofix?

原来VSCode CLI这么有用