|

前端请求拦截和数据虚拟化的思路和原理

最近在做的一些项目中大量的使用了 mock 数据以便模拟请求数据返回,其中用到了 mockjs 来做请求劫持和数据模拟。很早之前使用 mockjs 的时我便思考了 mockjs 的原理,从使用的方式上来推测作者的实现过程,但一直没有整理,今天就来补之前的坑。

大体上来说,mockjs 的项目可以分为两个较大的模块,一个是数据的 mock ,其中使用到了 Random 来生成虚拟数据;另一部分则是请求拦截,可以兼容 IE 和常见现代浏览器,初步推测作者在实现请求拦截是通过重写 XMLHttpRequestActiveXObject 来实现的,为此我再不看源码的情况下,也尝试编写一个 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 方法,将数据返回;

基于以上的分析,我们其实主要重写 opensend 方法即可,对于需要被拦截的请求,按照虚拟方法处理,没有匹配拦截的请求,则使用原生方法处理。

代码实现

重写原生方法用到了包装器,我们需要重新包装 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 ,这在使用时多少会有一些不便,后续我也将尝试解决这个问题。

类似文章

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注