报告:我想拦截浏览器的请求
不知道大家平时有没有遇到这样的需求:拦截浏览器的请求。这里说的拦截,是指在客户端(浏览器)的拦截,因为如果是服务器的拦截,那么请求就已经发出去了,这可能并不是我们想看到的。
最近,我在搭建组件库文档平台,部分业务组件里深度封装了一些数据请求,又或者埋点上报,这就产生了几个问题:
- 跨域问题的报错;
- 文档不关心埋点是否上报,只关心组件是否正常显示,组件使用假数据就能满足要求;
- 文档产生的数据请求或者埋点上报额外消耗了服务器的资源,还可能造成数据的污染;
综上,需要在浏览器端就把组件发出的请求拦截下来,并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.