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 的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版本ajax
const 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.js
const jsonp = require('jsonp');
jsonp(url, opts, fn)
总结完以上的使用方法,可以看到基本覆盖了海外前端所有的开发场景。现在开始研究下怎么做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 interceptor
axios.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 interceptor
axios.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的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 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;
}
}
}
// 关键一步:AOP 重写 appendChild 和 insertBefore
Node.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切入!大功告成。
总结:
1、使用 ajaxhook 对ajax的所有使用场景aop。
2、对 appendChild() & insertBefore() 函数进行切入,完成对jsonp的所有使用场景aop。
以下是完整体验DEMO:
<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>