cover_image

Ajax & Jsonp AOP最佳实践

三七互娱技术团队
2022年10月10日 11:44

一、技术背景

22年初启动了【二级业务接口分离项目】,原有架构上,A域提供了活动类接口和SDK类接口,SDK类接口流量占比达98%,有强烈的性能优化需求。故需要在此基础上分离A域接口,

改造前为:A域接口 = 活动类接口(/api/a1,/api/a2...) + SDK类接口(/api/b1,/api/b2...)

改造后为:A域接口 = 活动类接口(/api/a1,/api/a2...),B域接口 = 活动类接口(/api/b1,/api/b2...)

因为专题类需求数量庞大,一个一个检查修改效率极低,故需要一种更通用便捷的解决方法。综合评估下来后,技术方案直指AOP(面向切面编程)。

解决思路:前端期望使用一个通用脚本,通过aop切入编程方式识别特定接口,在请求发送前 对ajax 和 jsonp 的url主域名进行重写。

二、原生版与框架版 ajax 和jsonp的实现回顾

我们先回顾一下原生版 ajax 与 jsonp 的2种原生实现:

<script>// 原生ajax实现function myAjax(url) {    const events = ['load', 'loadend', 'timeout', 'error', 'readystatechange', 'abort']    const xhr = new XMLHttpRequest();    //setTimeout(()=>xhr.abort(),100)    xhr.open('get', url, true);    xhr.send();    events.forEach(function(e) {        xhr['on' + e] = function(event) {            console.log('on' + e, xhr.readyState, event)        }        xhr.addEventListener(e, function(event) {            console.log(e, xhr.readyState, event)        })    });    xhr.addEventListener('load', function(event) {        console.log('response', xhr.response)    })} // 原生 jsonp请求实现function myJsonp(params) {    params = params || {};    params.data = params.data || {};    const callbackName = params.jsonp;    const head = document.querySelector('head');    const script = document.createElement('script');    params.data['callback'] = callbackName;    const formateData = data => {        let arr = [];        for (let key in data) {            arr.push(encodeURIComponent(key) + '=' + data[key])        }        return arr.join('&');    }    const data = formateData(params.data);    script.src = `${params.url}?${data}`;    window[callbackName] = function(jsonData) {        head.removeChild(script);        clearTimeout(script.timer);        window[callbackName] = null;        params.success && params.success(jsonData)    }    if (params.time) {        script.timer = setTimeout(() => {            window[callbackName] = null;            head.removeChild(script)            params.error && params.error({                message: '超时'            })        }, time);    }    head.appendChild(script);}</script>

再看看框架版对ajax 和jsonp的封装:

// jquery版本ajax$.ajax({    url: "https://a-domain.37games.com/activity/execute",    data: {        name: 'christmas2021',        action: 'check_time'    },    success: function(result) {        alert(result.msg);    }});$.get("https://a-domain.37games.com/activity/execute", {    name: 'christmas2021',    action: 'check_time'}).done(function(result) {    alert(result.msg);});$.post("https://a-domain.37games.com/activity/execute", {    name: 'christmas2021',    action: 'check_time'}).done(function(result) {    alert(result.msg);});  // axios版本ajaxconst axios = require('axios');axios.get('/user?ID=12345')  .then(function (response) {    // handle success    console.log(response);  })  .catch(function (error) {    // handle error    console.log(error);  })  .then(function () {    // always executed  });  // jquery版本jsonp$.ajax({    url: "https://a-domain.37games.com/activity/execute",    data: {        name: 'christmas2021',        action: 'check_time'    },    jsonp: 'callback',    async: true,    dataType: 'jsonp',    success: function(result) {        alert(result.msg);    }});  // @jsonp版本jsonp// https://github.com/webmodules/jsonp/blob/master/index.jsconst jsonp = require('jsonp');jsonp(url, opts, fn)

总结完以上的使用方法,可以看到基本覆盖了海外前端所有的开发场景。现在开始研究下怎么做aop切入。

三、快速了解一下aop

AOP全称为Aspect-oriented programming,很明显这是相对于Object-oriented programming而言。Aspect可以翻译为“切面”或者“侧面”,所以AOP也就是面向切面编程。

AOP中有一些概念需要介绍一下,虽然我们不一定要严格执行。

  • joint-point:原业务方法;

  • advice:拦截方式

  • point-cut:拦截方法

关于这三个概念我们可以串起来可以这么理解:
当我们使用AOP改造一个原业务方法(joint-point)时,比如加入日志发送功能(point-cut),我们要考虑在什么情况下(advice)发送日志,是在业务方法触发之前还是之后;还是在抛出异常的时候,还是由日志发送是否成功再决定是否执行业务方法。

// 假设我们有一个对象myObject, 并且该对象有一个doSomething方法:var myObject = {    doSomething: function(a, b) {        return a + b;    }}; // 现在我们想拓展它,在执行那个方法之后打印出刚刚执行的结果:var remover = meld.after(myObject, 'doSomething', function(result) {    console.log('myObject.doSomething returned: ' + result);}); // 试试执行看:myObject.doSomething(1, 2); // Logs: "myObject.doSomething returned: 3" // 由此可以看出,AOP接口通常需要三个参数,被修改的对象,被修改对象的方法(joint-point),以及触发的时机(adivce),还有触发的动作(point-cut)。// 上面说了那么多的概念,Javascript的实现原理其实非常简单;function doAfter(target, method, afterFunc){    var func = target[method];    return function(){        var res = func.apply(this, arguments);        afterFunc.apply(this, arguments);        return res;      };}

四、Ajax 框架版和原生版 2种切入编程方式

1、Jquery版本 ajax:Jquery的$.ajax() 函数已经为我们提供了全局切入的hook,因为$.ajax()方法已经兼容了ajax和jsonp,所以一次切入则可全局应用!

我们来看下以下这段代码:

// 全局重写:此段代码切入了jquery函数的所有ajax和jsonp请求var _ajax = $.ajax;$.ajax = function(opt) {    var fn = {        // 核心代码        beforeSend: function(XHR, settings) {            // 这里做请求发送前做了预置工作            var pathMap = [                '/activity/execute',                '/api/',                '/ajax/',            ];            for (var path of pathMap) {                if (settings.url.includes(path)) {                    settings.url = settings.url.replace(/a-domain/, 'b-domain');                }            }        },        error: function(XMLHttpRequest, textStatus, errorThrown) {},        success: function(data, textStatus) {},        complete: function(XHR, TS) {}    }     if (opt.beforeSend) {        fn.beforeSend = opt.beforeSend;    }    if (opt.error) {        fn.error = opt.error;    }    if (opt.success) {        fn.success = opt.success;    }    if (opt.complete) {        fn.complete = opt.complete;    }     //扩展增强处理    var _opt = $.extend(opt, {        //全局允许跨域        xhrFields: {            withCredentials: true        },        error: function(XMLHttpRequest, textStatus, errorThrown) {            //错误方法增强处理            fn.error(XMLHttpRequest, textStatus, errorThrown);        },        success: function(data, textStatus) {            //成功回调方法增强处理            fn.success(data, textStatus);        },        beforeSend: function(XHR, settings) {            //提交前回调方法            fn.beforeSend(XHR, settings);        },        complete: function(XHR, TS) {            //请求完成后回调函数 (请求成功或失败之后均调用)。            fn.complete(XHR, TS);        }    });    if (opt.xhrFields) {        _opt.xhrFields = opt.xhrFields;    }     //调用native ajax 方法    return _ajax(_opt);}  // 局部重写$.ajax({    type: "post",    data: studentInfo,    contentType: "application/json",    url: "/Home/Submit",    // 核心代码:这里进行了发送请求前的重写操作    beforeSend: function () {        // 禁用按钮防止重复提交        $("#submit").attr({ disabled: "disabled" });    },    success: function (data) {        if (data == "Success") {            //清空输入框            clearBox();        }    },    complete: function () {        $("#submit").removeAttr("disabled");    },    error: function (data) {        console.info("error: " + data.responseText);    }});

使用jquery,幸福来得就是这么突然~然鹅,显示往往没辣么美好。。。

Jquery的切入重写仅局限在使用jquery $.ajax(),$.get(),$.post() 相关方法的基础上,并不对底层的ajax的 XMLHttpRequest 类进行重写,因此非Jquery技术栈直接被狙击。

2、axios Interceptor 拦截器切入:

提前axios的aop,很容易联想到官方提供的拦截器api。

首先看看官方对拦截器的应用,拦截器会在请求发送前或请求响应后 插入自定义处理钩子。

/ Add a request interceptoraxios.interceptors.request.use(function (config) {    // Do something before request is sent    return config;  }, function (error) {    // Do something with request error    return Promise.reject(error);  }); // Add a response interceptoraxios.interceptors.response.use(function (response) {    // Any status code that lie within the range of 2xx cause this function to trigger    // Do something with response data    return response;  }, function (error) {    // Any status codes that falls outside the range of 2xx cause this function to trigger    // Do something with response error    return Promise.reject(error);  });

剖析一下axios interceptors的原理,见源码解读:https://juejin.cn/post/6844904035292561415#heading-8

interceptors底层实现同 aop 对函数进行合适时机的切入,而是在内部构建和维护一个基于promise的「事件数组链」的形式,request拦截器unshift在数组头部,response拦截器push在数组尾部,再遍历数组使用promise.then 对「事件数组链」进行串接消费!见以下核心代码细节:

// 初始化 chain 数组,添加 undefined 是为了对应上 Promise 的 onFulfilledFn 和 onRejectedFn。var chain = [dispatchRequest, undefined]; // promise 是一个已经 resolve 的 Promise 对象var promise = Promise.resolve(config); // 遍历所有的请求拦截器,添加到 chain 数组最前面// 这边用的 unshift 添加,所以会导致先添加的后执行this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {  chain.unshift(interceptor.fulfilled, interceptor.rejected);}); // 遍历所有的响应拦截器,添加到 chain 数组最前面// 这边用的 push 添加,所以会导致先添加的先执行this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {  chain.push(interceptor.fulfilled, interceptor.rejected);}); // 循环 chain 数组,先循环执行请求拦截器方法,然后执行 dispatchRequest,最后循环执行响应拦截器方法while (chain.length) {  promise = promise.then(chain.shift(), chain.shift());}

至此,我们掌握了axios底层对请求拦截的实现原理。掌握这个细节主要为了理解重写底层XMLHttpRequest构造器是否存在冲突。

3、原生版本 ajax 切入(终极解决方案)

我们迫切寻找一种更友好的方式,对底层XMLHttpRequest类进行切入,不负期望,我们寻找到了业界比较成功的事件,它就是 ajaxhook。

现在看看 ajaxhook 的使用,它对底层的XMLHttpRequest 进行重写,所以也同时兼容了原生ajax请求和jq版的ajax请求,这是ajax aop的最佳方案。

<script src="https://unpkg.com/ajax-hook@2.0.3/dist/ajaxhook.min.js"></script><script>ah.proxy({    onRequest: (config, handler) => {        const pathMap = [            '/activity/execute',            '/api/',            '/ajax/',        ];        for (const path of pathMap) {            if (config.url.includes(path)) {                config.url = config.url.replace(/a-domain/, 'b-domain');                break;            }        }        handler.next(config);    },    onError: (err, handler) => {        if (err.config.url === 'https://bb/') {            // todo        } else {            handler.next(err)        }    },    onResponse: (response, handler) => {        if (response.config.url === location.href) {            // todo        } else {            handler.next(response)        }    }})</script>  // 在ajaxhook切入后,不管是jq或者原生js的ajax请求调研,均能正确触发预置钩子!AOP成功!

讲完ajax的aop,我们接下来看看jsonp的aop如何实现。

五、Jsonp 切入编程方式

Jsonp的aop比较绕一些。我们知道jsonp的底层实现是基于构建script标签,并通过src发起请求,在全局的callbackName进行回调捕获与数据处理。

我们分析了原生jsonp和 jsonp框架的事项,我们需要寻求aop的最佳时机。

先聚焦到原生jsonp实现的这一段代码:

图片

再翻看jsonp框架源码,具体位置在:https://github.com/webmodules/jsonp/blob/master/index.js

图片

可以发现,基本所有的jsonp实现中,都是使用Node.appendChild() 或 Node.insertBefore() 这2个dom插入方法 进行script标签的插入。

由此可见,Node.appendChild() 或 Node.insertBefore() 这2个方法是aop的最佳时机所有function的构造函数都属于Function类,我们可对Function类进行AOP,实现如下

// jsonp aop实现Function.prototype.before = function(func) {    const __self = this;    return function() {        if (func.apply(this, arguments) === false) {            return false;        }        return __self.apply(this, arguments)    }}Function.prototype.after = function(func) {    const __self = this;    return function() {        const ret = __self.apply(this, arguments)        if (ret === false) {            return false;        }        func.apply(this, arguments);        return ret;    }}  // before & after aop callbackfunction jsonpReqUrlRewrite(thisEle) {    let isHTMLScriptElement = Object.prototype.toString.call(thisEle) === '[object HTMLScriptElement]';    if (!isHTMLScriptElement) return false;    const pathMap = [        '/activity/execute',        '/api/',        '/ajax/',    ];    for (const path of pathMap) {        if (thisEle.src.includes(path)) {            thisEle.src = thisEle.src.replace(/a-domain/, 'b-domain');            break;        }    }} // 关键一步:AOP 重写 appendChild 和 insertBeforeNode.prototype.appendChild = Node.prototype.appendChild.before(jsonpReqUrlRewrite)Node.prototype.insertBefore = Node.prototype.insertBefore.before(jsonpReqUrlRewrite)  // 原生版jsonp调用测试:myJsonp({    url: 'https://a-domain.37games.com/activity/execute',    jsonp: 'callback',    data: {        name: 'christmas2021',        action: 'check_time'    },    success(res) {        console.log('jsonp success:', res);    },    error(err) {        console.log(err);    }})// 框架版jsonp调用测试:$.ajax({    url: "https://a-domain.37games/activity/execute",    data: {        name: 'christmas2021',        action: 'check_time'    },    jsonp: 'callback',    async: true,    dataType: 'jsonp',    // beforeSend: function(jqXHR, settings) {    //     //在请求前修改url    //     settings.url = settings.url.replace(/a-domain/, 'b-domain')    // },    success: function(result) {        alert(result.msg);    }});

至此,我们完成了jsonp版本的aop切入!大功告成。

六、总结与完整DEMO

总结:

1、使用 ajaxhook 对ajax的所有使用场景aop。

2、对 appendChild() & insertBefore()  函数进行切入,完成对jsonp的所有使用场景aop。

以下是完整体验DEMO:

<!DOCTYPE html><html> <head lang="zh-cmn-Hans">    <meta charset="UTF-8">    <title>Ajax & Jsonp AOP</title>    <meta name="renderer" content="webkit">    <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">    <meta name="viewport" content="width=device-width,initial-scale=0.5,user-scalable=no" />    <meta name="keywords" content="Ajax hook Demo">    <script src="http://cdn.bootcss.com/jquery/3.1.0/jquery.min.js"></script>    <script src="https://unpkg.com/ajax-hook@2.0.3/dist/ajaxhook.min.js"></script></head><style>html {    font-size: 20px;    -webkit-user-select: none;}</style> <body>    <div style="background: #000; font-size: 38px; color: #ffef68; text-shadow: 2px 2px 10px #ffef68; width: 400px; height: 120px; text-align: center">        <div style="padding-top: 30px"> Ajax & Jsonp Hook !</div>    </div>    <br />    <button id="native-ajax-btn">native-ajax-btn</button>    <button id="jquery-ajax-btn">jquery-ajax-btn</button>    <button id="native-jsonp-btn">native-jsonp-btn</button>    <button id="jquery-jsonp-btn">jquery-jsonp-btn</button>     <script>    // 原生ajax实现    function myAjax(url) {        const events = ['load', 'loadend', 'timeout', 'error', 'readystatechange', 'abort']         const xhr = new XMLHttpRequest();         //setTimeout(()=>xhr.abort(),100)        xhr.open('get', url, true);        xhr.send();        events.forEach(function(e) {            xhr['on' + e] = function(event) {                console.log('on' + e, xhr.readyState, event)            }            xhr.addEventListener(e, function(event) {                console.log(e, xhr.readyState, event)            })        });        xhr.addEventListener('load', function(event) {            console.log('response', xhr.response)        })    }      // 原生 jsonp请求实现    function myJsonp(params) {        //先对params进行处理,防止为空        params = params || {};        params.data = params.data || {};        //后台传递数据时调用的函数名        const callbackName = params.jsonp;        // 拿到dom元素head,先不进行操作        const head = document.querySelector('head');        //创建script元素,先不进行操作        const script = document.createElement('script');        //传递给后台的data数据中,需要包含回调参数callback。        //callback的值是 一个回调函数的函数名,后台通过该回调函数名调用传递数据,这个参数名的key由双方约定,默认为callback        params.data['callback'] = callbackName;        //对请求data进行格式化处理        const formateData = data => {            let arr = [];            for (let key in data) {                //避免有&,=,?字符,对这些字符进行序列化                arr.push(encodeURIComponent(key) + '=' + data[key])            }            return arr.join('&');        }        const data = formateData(params.data);        //设置script请求的url跟数据        script.src = `${params.url}?${data}`;        //全局函数 由script请求后台,被调用的函数,只有后台成功响应才会调用该函数        window[callbackName] = function(jsonData) {            //请求移除scipt标签            head.removeChild(script);            clearTimeout(script.timer);            window[callbackName] = null;            params.success && params.success(jsonData)        }        //请求超时的处理函数        if (params.time) {            script.timer = setTimeout(() => {                //请求超时对window下的[callbackName]函数进行清除,由于有可能下次callbackName发生改变了                window[callbackName] = null;                //移除script元素,无论请求成不成功                head.removeChild(script)                //这里不需要清除定时器了,clearTimeout(script.timer); 因为定时器调用之后就被清除了                 //调用失败回调                params.error && params.error({                    message: '超时'                })            }, time);        }        //往head元素插入script元素,这个时候,script就插入文档中了,请求并加载src        head.appendChild(script);    }      // 使用ajax hook做aop    ah.proxy({        onRequest: (config, handler) => {            const pathMap = [                '/activity/execute',                '/api/',                '/ajax/',            ];            for (const path of pathMap) {                if (config.url.includes(path)) {                    config.url = config.url.replace(/a-domain/, 'b-domain');                    break;                }            }            handler.next(config);        },        onError: (err, handler) => {            if (err.config.url === 'https://bb/') {                handler.resolve({                    config: err.config,                    status: 200,                    headers: { 'content-type': 'text/text' },                    response: 'hi world'                })            } else {                handler.next(err)            }        },        onResponse: (response, handler) => {            if (response.config.url === location.href) {                handler.reject({                    config: response.config,                    type: 'error'                })            } else {                handler.next(response)            }        }    })      // jsonp aop实现    Function.prototype.before = function(func) {        const __self = this;        return function() {            if (func.apply(this, arguments) === false) {                return false;            }            return __self.apply(this, arguments)        }    }    Function.prototype.after = function(func) {        const __self = this;        return function() {            const ret = __self.apply(this, arguments)            if (ret === false) {                return false;            }            func.apply(this, arguments);            return ret;        }    }     // before & after aop callback    function jsonpReqUrlRewrite(thisEle) {        let isHTMLScriptElement = Object.prototype.toString.call(thisEle) === '[object HTMLScriptElement]';        if (!isHTMLScriptElement) return false;        const pathMap = [            '/activity/execute',            '/api/',            '/ajax/',        ];        for (const path of pathMap) {            if (thisEle.src.includes(path)) {                thisEle.src = thisEle.src.replace(/a-domain/, 'b-domain');                break;            }            console.log(path);        }    }     // aop 重写 appendChild 和 insertBefore    Node.prototype.appendChild = Node.prototype.appendChild.before(jsonpReqUrlRewrite)    Node.prototype.insertBefore = Node.prototype.insertBefore.before(jsonpReqUrlRewrite)      $('#native-ajax-btn').click(function() {        // 原生ajax        myAjax('https://a-domain.37games.com/activity/execute');    })     $('#jquery-ajax-btn').click(function() {        // jquery post        $.post("https://a-domain.37games.com/activity/execute", {                name: 'christmas2021',                action: 'check_time'            })            .done(function(result) {                alert(result.msg);            });    })     $('#native-jsonp-btn').click(function() {        myJsonp({            url: 'https://a-domain.37games.com/activity/execute',            jsonp: 'callback',            data: {                name: 'christmas2021',                action: 'check_time'            },            success(res) {                console.log('jsonp success:', res);            },            error(err) {                console.log(err);            }        })    })     $('#jquery-jsonp-btn').click(function() {        $.ajax({            url: "https://a-domain.37games.com/activity/execute",            data: {                name: 'christmas2021',                action: 'check_time'            },            jsonp: 'callback',            async: true,            dataType: 'jsonp',            // beforeSend: function(jqXHR, settings) {            //     //在请求前修改url            //     settings.url = settings.url.replace(/a-domain/, 'b-domain')            // },            success: function(result) {                alert(result.msg);            }        });    })</script></body> </html>


继续滑动看下一个
三七互娱技术团队
向上滑动看下一个