Ajax & Jsonp AOP最佳实践
一、技术背景
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 successconsole.log(response);}).catch(function (error) {// handle errorconsole.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 sentreturn config;}, function (error) {// Do something with request errorreturn 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 datareturn response;}, function (error) {// Any status codes that falls outside the range of 2xx cause this function to trigger// Do something with response errorreturn 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/[email protected]/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/[email protected]/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由双方约定,默认为callbackparams.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就插入文档中了,请求并加载srchead.appendChild(script);}// 使用ajax hook做aopah.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 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;}console.log(path);}}// aop 重写 appendChild 和 insertBeforeNode.prototype.appendChild = Node.prototype.appendChild.before(jsonpReqUrlRewrite)Node.prototype.insertBefore = Node.prototype.insertBefore.before(jsonpReqUrlRewrite)$('#native-ajax-btn').click(function() {// 原生ajaxmyAjax('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>