做网页应用时,我们离不开用浏览器测试,特别是Chrome和Firefox这样调试工具强大的浏览器。并且很多时候测试内容与后端接口相关。
聪明的前端儿为了进度不被后端小伙伴们不稳定的接口拖累,发明了mock。事到如今,我们有用本地应用mock的,如Mockon;有用自己Node服务器mock的;有用接口协作工具的,如YAPI;还有用Service Worker的,如msw……其实,还有一种方式——浏览器扩展。浏览器扩展可以让我们减少窗口切换浪费的时间,大大提升调试效率。
今天,让我们谈谈怎么用Chrome浏览器拦截网页中发出的请求,如果符合预设的条件,返回mock的数据。
听起来很简单,不过自Chrome发现这类API有一些安全隐患之后,便不再提供官方API支持(原先在webRequest中可更改相应的内容)。所以我们只能绕一些弯路:在页面中插入一段代码代理请求的JS API。
为什么要在页面中插入代码,扩展不能吗
答案是不能啊亲!让我们看看Chrome扩展的架构设计:
开发人员需要考虑5个部分:打开的页面(可以是任意标签页)、每个页面上运行的扩展的content_script
、扩展的弹出窗口、以及一个后台脚本和扩展的设置界面。每个部分都独立运行在自己的黑箱里,互相之间通信只能依靠Chrome提供的一些接口。比如sendMessage,storage。
如果我们想获取或操作用户页面上的内容,就得依靠content_script
。它是扩展中唯一可以获取页面文档document
的地方。然而这仅限于document
,为了保护用户,content_script
里的window
对象与页面中的window
对象并非同一个。也就是说他们运行的JS API是隔离开来的。因此,我们不得不“偷偷”在用户页面中插入一段代码协助操作页面中的接口。
拦截fetch
拦截fetch其实很简单,只需要将原API调个包:
// 存一下原来的fetch
const f = window.fetch
// 改写成自己的
window.fetch = (req, config?) => {
// hjack方法检查是否有mock,有的话返回一个Response Object
return hijack(req, config)
// 没有的话抛出,扔回给原fetch处理
.catch(() => f(req, config))
}
复制代码
拦截XHR
当应用有转成es5标准代码时,除了拦截fetch还需要考虑拦截XHR。与fetch不同,拦截XHR就比较麻烦了。
让我们回顾一下XHR的用法:
var get = new XMLHttpRequest();
get.open('GET', '/api', true);
get.onload = function () {
// 请求结束
};
get.send(null);
var post = new XMLHttpRequest();
post.open("POST", '/api', true);
post.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
post.onreadystatechange = function() {
if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
// 请求结束
}
}
post.send(JSON.stringify({}));
复制代码
XHR的请求设定、发送与返回结果处理都分布在不同的方法中,同fetch一样“偷天换日”就得每个都照顾到。
幸好找到有位小伙伴开源了一个包叫ajax-hook,把这些代码都给写好啦!使用ajax-hook,我们只需要这样就可以了:
import { proxy } from 'ajax-hook'
proxy({
onRequest: (config, handler) =>
hijack(config)
.then(({ response }) => {
// 找到mock,让handler帮忙把mock数据交到下一步处理
return handler.resolve({
config,
status: 200,
headers: [],
response,
})
})
// 没找到mock就可以用handler.next让原XHR处理
.catch(() => handler.next(config)),
onResponse: (response, handler) => {
// handler帮忙响应数据(无论是线上返回还是mock)
handler.resolve(response)
},
})
复制代码
配置Chrome扩展,插入这段拦截代码
插入这段代码我们需要至少三个文件:
intercept.js
content_script.js
manifest.json
复制代码
我们把以上代码整理一下放入intercept.js
:
import { proxy } from 'ajax-hook'
function hijack(url, { method }) {
return new Promise((resolve, reject) => {
// 稍后再替换这段代码
console.log(`拦截请求 ${method} ${url}`)
reject();
})
}
proxy({
onRequest: (config, handler) =>
hijack(config.url, config)
.then(({ response }) => {
return handler.resolve({
config,
status: 200,
headers: [],
response,
})
})
.catch(() => handler.next(config)),
onResponse: (response, handler) => {
handler.resolve(response)
},
})
if (window.fetch) {
const f = window.fetch
window.fetch = (req, config?) => {
return hijack(req, config)
.then(({ response }) => {
return new Response(response, {
headers: new Headers([]),
status: 200,
})
})
.catch(() => f(req, config))
}
}
复制代码
注意这里我们引用了一个第三方包,需要考虑用打包工具打包。
然后在content_script.js
中将这个文件插入文档中:
const interceptScript = document.createElement('script')
interceptScript.src = chrome.runtime.getURL('intercept.js')
document.head.prepend(interceptScript)
复制代码
然后一定要记得manifest.json
中声明对应的权限:
{
"name": "My Chrome Extension",
"description": "",
"manifest_version": 2,
"version": "1.0.0",
"permissions": [],
"content_scripts": [
{
"js": ["content_script.js"],
"matches": ["*://*/*"],
"all_frames": true
}
],
"content_security_policy": "script-src 'self' *; object-src 'self'",
"web_accessible_resources": ["intercept.js"]
}
复制代码
其中,content_scripts
和content_security_policy
是为我们的content_script.js
能够在浏览器中运行准备。需要声明准备访问的域名。这里你看到我在content_scripts
中写的是*://*/*
,而content_security_policy
中只用了*
,理论上来说两者皆可对应所有页面,不过实验中发现content_scripts
不写那么详细好像没法正常工作?♀️。
另外在web_accessible_resources
必须声明我们即将插入的代码文件,否则页面是无法访问到这个文件的。
编辑存储mock数据
现在我们还缺一个简单的界面来设置mock数据。这个页面可以在Chrome右上角的弹出框里,或是在调试工具框里,或是一个单独的页面,看你喜欢放哪。这里我就跳过UI的部分,假设最终在页面上调用如下接口把数据存入了Chrome Storage中:
chrome.storage.local.set({ key: value }, callback)
复制代码
注意Chrome的storage接口有几种,其中我们可以用来存储的主要是local和sync。它们除了容量大小区别之外,还有一点很重要的是如名字所示,一个是在本地,另一个是让Google账号同步的存储。那一般情况下我们还是尽量选择本地存储。
假设我们把mock都存储在键值为mock
的数据中,包含匹配的完整URL、方法以及mock的响应数据,如下:
chrome.storage.local.get(['mock'], function(result) {
console.log(result.mock);
/* 打印:
{
'GET http://www.example.com/api/test': {
response: '{"msg":"This is a mock"}'
}
}
*/
});
复制代码
那么如何让插入在页面中的代码获取到这些信息呢?它隶属于页面环境,是无法访问扩展的存储的哦!
这里我们可以借助于Chrome的页面与扩展的通信消息机制。
页面中通过如下接口向指定扩展发送消息:
chrome.runtime.sendMessage(
EXTENSION_ID,
message,
options,
(response) => {
// 如果有响应的话可以在回调中处理
}
)
复制代码
而扩展中通过如下接口处理收到的消息:
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
// 处理收到的信息,可以通过sendResponse给发消息方响应
})
复制代码
让我们在之前的intercept.js
中向扩展发送消息。而扩展中则可以处理消息,查询我们是否有对应的mock设置,然后回应页面的请求。
在此之前,我们首先发现有一个问题:EXTENSION_ID
是神马?
所有Chrome扩展都有个ID。已经发布的扩展ID在url里就可以找到。比如这个Google Translate的URL的最后一部分:
https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb
复制代码
本地安装的扩展在浏览器里输入chrome://extensions
回车就可以看到图标下面的扩展ID。
然而细心的小伙伴很快会发现本地调试的话每次重新加载ID都会更新。那怎么办呢?
我们可以通过chrome.runtime.id
在扩展中获取目前的ID。在页面中插入一个全局变量让intercept.js
读取。
修改content_script.js
如下:
const extensionGlobals = document.createElement('script')
extensionGlobals.innerText = `window.__EXTENTION_ID__ = "${chrome.runtime.id}";`
document.head.prepend(extensionGlobals)
const interceptScript = document.createElement('script')
interceptScript.src = chrome.runtime.getURL('intercept.js')
document.head.prepend(interceptScript)
复制代码
然后就可以让intercept.js
给扩展发消息了:
import { proxy } from 'ajax-hook'
function hijack(url, { method }) {
return new Promise((resolve, reject) => {
console.log(`拦截请求 ${method} ${url}`)
chrome.runtime.sendMessage(
window.__EXTENSION_ID__,
{
type: 'request_mock',
url,
method
},
{},
(response) => {
if (response) resolve(response);
else reject();
}
)
})
}
proxy({
onRequest: (config, handler) =>
hijack(config.url, config)
.then(({ response }) => {
return handler.resolve({
config,
status: 200,
headers: [],
response,
})
})
.catch(() => handler.next(config)),
onResponse: (response, handler) => {
handler.resolve(response)
},
})
if (window.fetch) {
const f = window.fetch
window.fetch = (req, config?) => {
return hijack(req, config)
.then(({ response }) => {
return new Response(response, {
headers: new Headers([]),
status: 200,
})
})
.catch(() => f(req, config))
}
}
复制代码
扩展中最好在background_script
中处理消息,因为它不同于弹出框、调试框,它不需要用户打开就会初始化并一直在后台运行。打开background_script
需要在manifest.json
中声明一下:
{
"name": "My Chrome Extension",
"description": "",
"manifest_version": 2,
"version": "1.0.0",
"permissions": [
"background",
"*://*/*"
],
"background": {
"scripts": ["background_script.js"],
"persistent": true
},
"externally_connectable": {
"matches": [
"*://*/*"
]
},
"content_scripts": [
{
"js": ["content_script.js"],
"matches": ["*://*/*"],
"all_frames": true
}
],
"content_security_policy": "script-src 'self' *; object-src 'self'",
"web_accessible_resources": ["intercept.js"]
}
复制代码
创建background_script.js
,处理我们收到的request_mock
:
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
if (message && message.type === 'request_mock') {
const { method, url } = message;
chrome.storage.local.get(['mock'], function({ mock }) {
const matchedMock= mock[`${method.toUpperCase()} ${url}`];
sendResponse(matchedMock);
});
}
});
复制代码
好了,这样一个mock插件就初步完成了。
插入代码拦截的缺陷
像这样插入代码拦截请求的方式基本上可以获得所有原请求的信息,然而它有一个致命的缺陷——无法拦截在文档加载期间发出的请求。
这是因为我们的插入行为是一种DOM操作,需要等待文档加载完成才可以开始插入的操作。目前我没有找到比较好可以绕过的方法。
如果想要覆盖完整一些,可能需要借助于Chrome的代理接口加一个外部的mock服务器处理了。