不知道大家平时有没有遇到这样的需求:拦截浏览器的请求。这里说的拦截,是指在客户端(浏览器)的拦截,因为如果是服务器的拦截,那么请求就已经发出去了,这可能并不是我们想看到的。
最近,我在搭建组件库文档平台,部分业务组件里深度封装了一些数据请求,又或者埋点上报,这就产生了几个问题:
- 跨域问题的报错;
- 文档不关心埋点是否上报,只关心组件是否正常显示,组件使用假数据就能满足要求;
- 文档产生的数据请求或者埋点上报额外消耗了服务器的资源,还可能造成数据的污染;
综上,需要在浏览器端就把组件发出的请求拦截下来,并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
或者onloadend
的getter
就会抛出错误。
那么,有办法修改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
并执行,然后依次经过下面的生命周期并触发回调。 每个生命周期都有对应的监听方法,下面列举几个比较重要的:
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.debugger
API允许我们绑定具体的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两种技术糅合在一起,得以弥补各自的缺陷,完美解决我们的问题。
参考资料
- https://github.com/axios/axios/blob/v1.x/lib/adapters/xhr.js
- https://juejin.cn/post/7019704757556084750
- https://gist.github.com/buaawp/1c9e503c460a3f6dc1a4eac77fffdc06#file-extendxmlhttprequest-js
- https://github.com/wendux/ajax-hook
- https://developer.chrome.com/docs/extensions/reference/declarativeNetRequest/
- https://www.divante.com/pwabook/chapter/02-the-history-of-pwas
- https://developer.chrome.com/docs/workbox/service-worker-overview/
- https://web.dev/service-worker-lifecycle/
- https://developer.mozilla.org/en-US/docs/Web/API/Response/Response
- https://github.com/mswjs/msw
- https://livebook.manning.com/book/progressive-web-apps/chapter-4/
- https://docs.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/how-to/service-workers
- https://chromedevtools.github.io/devtools-protocol/tot/Fetch/
- https://github.com/ChromeDevTools/awesome-chrome-devtools
- https://github.com/cyrus-and/chrome-remote-interface
- https://developer.chrome.com/docs/extensions/reference/debugger/