前端请求拦截和数据虚拟化的思路和原理
最近在做的一些项目中大量的使用了 mock 数据以便模拟请求数据返回,其中用到了 mockjs
来做请求劫持和数据模拟。很早之前使用 mockjs 的时我便思考了 mockjs 的原理,从使用的方式上来推测作者的实现过程,但一直没有整理,今天就来补之前的坑。
大体上来说,mockjs 的项目可以分为两个较大的模块,一个是数据的 mock ,其中使用到了 Random 来生成虚拟数据;另一部分则是请求拦截,可以兼容 IE 和常见现代浏览器,初步推测作者在实现请求拦截是通过重写 XMLHttpRequest
和 ActiveXObject
来实现的,为此我再不看源码的情况下,也尝试编写一个 Ajax 拦截器,来实现对请求的拦截和数据的模拟。
设计模式
将设计模式放在首位,第一是为了学以致用,第二是由于它确实很重要。在模拟 mockjs
实现的过程中,需要使用的设计模式主要就是单例模式和包装器模式。
单例模式提供一个全局唯一实例对象,以便实现全局访问和管理。包装器模式则借助拦截 XMLHTTPRequest
对象,重新包装实现了 XMLHTTPRequest 对象,进而将原生请求包装为一个虚拟的 Ajax 请求,其内部则还是通过原生 XMLHTTPRequest 对象来实现。
这样设计的目的也是显而易见的。首先,单例模式为我们提供一个全局的单例对象,无论在哪里引入,我们都可以实现这个对象,对象之间的数据可以共享(mockjs 在使用时貌似没有体现出共享,比较隐式);包装器模式则为我们提供了一个重写 Ajax 请求的可能。
基于此,在还没有阅读 mockjs 源码的情况下,我试着根据开发时的用例,反向推理一个简单的拦截器的实现。
拦截原理
暂且不说 ActiveXObject 的实现,大同小异。
常见的现代浏览器是通过 XMLHTTPRequest 对象来创建 Ajax 请求的,其中 mockjs 的 mock 方法定义如下:
mock(rurl, rtype, template)
// rurl: 请求地址匹配
// rtype: 请求方法匹配
// template: 请求返回值
整体上看,mockjs 的请求时基于请求地址和请求方法来匹配并拦截的。
回到原生的 Ajax 使用上来看,我们通过原生 JavaScript 写一个 Ajax 请求是这样的:
function ajax(url, method, data = null) {
let xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status <= 400) {
Promise.resolve(xhr.responseText);
}
else {
Promise.reject(xhr.response);
}
}
}
xhr.send(data);
}
所以可以看出,xhr.open()
方法提供了所有的匹配信息,xhr.send()
发送提供了请求的触发。那么综上,我们主要实现 open 和 send 方法即可:
- 在用户调用 open 方法时注册请求拦截器;
- 在用户调用 send 方法时定义 response 并触发
onreadystatechange
方法,将数据返回;
基于以上的分析,我们其实主要重写 open
和 send
方法即可,对于需要被拦截的请求,按照虚拟方法处理,没有匹配拦截的请求,则使用原生方法处理。
代码实现
重写原生方法用到了包装器,我们需要重新包装 open 和 send 方法。
首先,定义一个工具方法,用来获取数据类型。
/**
* 获取数据类型
*
* @param {any} obj
* @returns string
*/
function getTypeOf(obj) {
let type = Object.prototype.toString.call(obj);
return type.replace(/\[object\s|\]/g, '');
}
export { getTypeOf }
定义 request.mock.js
,编写 RequestMock 对象,定义对象方法,为了简化,我们不去实现 Random 方法,仅实现 mock 方法。
import { getTypeOf } from "../utils"
const RequestMock = {
_urls: new Set(),
_method: new Map(),
_response: new Map(),
/**
* 拦截并返回虚拟数据
*
* @param {string} url 请求地址
* @param {enum} method 请求类型
* @param {object}} template 返回数据
*/
mock(url, method, template) {
let regUrl;
if ('RegExp' === getTypeOf(url)) {
regUrl = url;
}
else if ('String' === getTypeOf(url)) {
regUrl = new RegExp(url.replace(/\//g, '\\\/'));
}
else {
throw "url type error";
}
this._urls.add(regUrl);
this._method.set(regUrl, method);
this._response.set(regUrl, template);
},
/**
* 获取请求的模板数据
*
* @param {string} url 请求地址
* @param {enum} method 请求类型
*/
getTemplate(url, method) {
for (let u of this._urls) {
if (!u.test(url)) {
continue;
}
if (method === this._method.get(u)) {
return this._response.get(u);
}
}
return null;
}
}
然后定义拦截器,用以拦截 open 和 send 方法,拦截器通过重新原型方法来实现。
import { getTypeOf } from "../utils"
const xhr_open = XMLHttpRequest.prototype.open;
const xht_send = XMLHttpRequest.prototype.send;
/**
* 重写 open 方法
*
* @param {emun} method 请求方法
* @param {string} url 请求地址
*/
XMLHttpRequest.prototype.open = function (method, url) {
// 如果没有注册拦截匹配,则使用原生方法发送请求
let templateMatch = RequestMock.getTemplate(url, method);
if (null === templateMatch) {
xhr_open.call(this, method, url);
return;
}
// 如果可以匹配到,则标记当前实例为mock实例
this._mock = true;
}
/**
* 发送的请求数据
*
* @param {string} body 数据体
*/
XMLHttpRequest.prototype.send = function (body) {
if (this._mock) {
this.onreadystatechange();
return;
}
xht_send.call(this, body);
}
从上面的代码可以看出拦截过程,首先,保存原生的 open 和 send 方法,其次,通过重新定义原型方法,实现对原生方法的重写,实则是对原生方法的包装,让原生方法实现我们需要的功能。
在 open 方法中,通过 RequestMock.getTemplate 方法尝试获取请求匹配的数据模板,如果没有获取到,则调用原生方法,后面的过程就是一个正常的原生 Ajax 请求过程。如果匹配成功,则对当前请求对象打一个 _mock
标记。
然后是 send 方法,在这个方法中可以看到,首先是判断当前的请求对象是否具备 _mock
标记,如果具备,则说明这是一个被拦截的请求,当最后的 send 方法调用后,则尝试通过 onreadystatechange
来响应请求。
外部调用也比较简单:
import RequestMock from "./src/mock/request.mock"
RequestMock.mock("/hello/world", "get", data);
// 后面正常发起 Ajax 请求即可
存在的问题
如果直接按照上面的代码运行,大概率会报错,这是由于响应不匹配造成的。所以请求报错,但并不代表我们的拦截没有成功。
可以尝试以下的手写 Ajax 请求,来查看具体的过程:
function ajax(url, method, data = null) {
let xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.onreadystatechange = function () {
// 打印当前的 URL 和响应信息
console.log(url, xhr.readyState, xhr.status)
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status <= 400) {
console.info(xhr.responseText);
}
else {
console.error(xhr.response);
}
}
}
// 最后发送数据
xhr.send(data);
}
ajax('/hello/world', 'get')
此时就不会报错,说明请求是正常拦截了,只是打印变成了这样:
main.js:29 /hello/world 0 0
此时的响应没有状态码,没有响应头,什么都没有,只是最简单的响应。
如果尝试在 XMLHttpRequest.prototype.send 中设置 response 响应信息,会得到一个错误:
Uncaught TypeError: Cannot set property responseText of #<XMLHttpRequest> which has only a getter
也是比较好理解的,这是由于内部响应信息对外只有 getter 访问器属性,也就是只读,我们无法通过编程来实现对响应数据的写入,如果尝试写入,则会出现上面的错误。
小总结
通过上面的过程,虽然可以实现拦截,但是我却没有想到响应体是只读属性,无法通过编程来写入,所以导致只能拦截但是却得不到正确的响应。
解决上述问题的办法也是有的,我猜测 mockjs 也是这样实现的:重写整个 XMLHTTPRequest。
重写目的,就是解决响应数据不能写入的问题,当我们把 XMLHTTPRequest 当成一个普通对象重写时,此时我们定义的属性就是完全可读可写的,这样一来将掌握整个请求的过程。
基于这样的思路,后续我将尝试重写 XMLHTTPRequest ,实现更全面的拦截控制。
另外,这样是一个方法的总结,虽然之前就有对 mockjs 原理的思考,也想到了 XMLHTTPRequest 和 ActiveXObject 的劫持,但始终没有去手动实现,所以也就没有遇到上述的问题。所以很多时候,简单的造一点轮子对理解原理会有很大帮助。
刚才整理文章的时候顺便 clone 了一份 mockjs 的源码,简单过了一遍
xhr
部分的代码,从注释来看它确实是完全重写了 xhr ,它通过请求参数来判断到底使用原生对象还是重写后的对象,可见思路是完全一致的。既然思想一致,后续我就不去重复造轮子了,而是阅读和理解 mockjs 优秀的部分,吸收精华思想。
另外我也在使用中发现了 mockjs 的一些不足,例如它似乎无法 unmock ,这在使用时多少会有一些不便,后续我也将尝试解决这个问题。