浅谈H5 HTTP请求拦截
背景
在前端开发中,经常会遇到针对全局HTTP请求拦截的需求,比如底层统一检测第三方代码发送的请求是否合规,对不合规请求进行拦截;底层统一进行接口加固的改造,需要对请求添加额外参数;进行统一埋点统计或者性能监控等场景。
前端发起HTTP请求的方式
要拦截HTTP请求,首先就要搞清楚有哪些发送HTTP请求的方式。翻阅资料了解到前端大致有以下几种方式可以发起HTTP请求:
- XMLHttpRequest
- fetch
- jQuery.ajax(底层基于
XMLHttpRequest
进行封装) - axios(H5端底层基
XMLHttpRequest
或fetch
进行封装) - Taro.request(H5端底层基于
fetch
进行封装) - JSONP(通过
<script>
标签方式获取数据) 以上几种请求方式,只有XMLHttpRequest
、fetch
和JSONP
三种方式是最底层的发起请求的方法,像jQuery.ajax,axios和Taro.request基本上都是通过对上面某种方式的二次封装。
HTTP请求拦截
通过以上分析,我们知道只需要对XMLHttpRequest
、fetch
和JSONP
这三种底层的方式进行请求拦截即可满足大部分的场景。
我们可以在以下三个阶段调用一些钩子函数以获得对请求处理权:
onRequest
发送请求之前onResponse
收到响应之后onError
发生错误时
当然像jquery
、axios
和Taro.request
这些库本身也都提供了一些类似的拦截器设计,他们大致可以分为发送请求之前和响应返回之后(包括成功和失败),但是他们属于上层库的实现,无法从全局对所有HTTP请求进行拦截。
XMLHttpRequest拦截
XMLHttpRequest使用示例
var xhr = new XMLHttpRequest();
xhr.open("POST", "https://example.com/api/data", true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
var response = JSON.parse(xhr.responseText);
console.log(response);
}
};
var params = "username=test&password=12345"; // 参数字符串
xhr.send(params);
拦截方式
/**
* 检测是否需要触发请求
* @param {*} thisObj
* @param {*} onRequest
* @param {*} config
* @returns
*/
const checkNeedRequest = (thisObj, onRequest, config) => {
return new Promise((resolve) => {
let needRequest = true
if (typeof onRequest === "function") {
// sendHookResult=false才会阻止发送请求,否则都发送请求
let sendHookResult = onRequest.apply(thisObj, [config])
if (sendHookResult instanceof Promise) {
sendHookResult.then((realResult) => {
needRequest = realResult
resolve(needRequest)
})
.catch((error) => {
resolve(needRequest)
})
}
else {
needRequest = sendHookResult
resolve(needRequest)
}
}
else {
resolve(needRequest)
}
})
}
/**
* 拦截XMLHttpRequest
* @param window
* @param {*} option
*/
function XMLHttpRequestInterceptor(window, option) {
if (window.XMLHttpRequest) {
const { onRequest } = option
// 保存原始函数
const originalSend = window.XMLHttpRequest.prototype.send
const originalOpen = window.XMLHttpRequest.prototype.open
const originalAbort = window.XMLHttpRequest.prototype.abort
window.XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
this.method = method
this.url = url
this.async = async
this.user = user
this.password = password
originalOpen.apply(this, arguments)
}
window.XMLHttpRequest.prototype.abort = function () {
// 已经调用了abort方法的标识
this._abort = true
originalAbort.apply(this, arguments)
}
// 重新发送方法
window.XMLHttpRequest.prototype.send = function (body) {
console.log("XMLHttpRequest url:", this.url)
const xhr = this
xhr.body = body
xhr.addEventListener('abort', (e) => {
console.log("e:", e)
// 已经收到请求终止事件的标识,可能由多种愿意触发,比如调用了abort()方法,获取其他方式
xhr._aborted = true
})
const config = { type: "XMLHttpRequest", url: xhr.url, body, method: xhr.method, async: xhr.async, user: xhr.user, password: xhr.password }
checkNeedRequest(xhr, onRequest, config).then((needRequest) => {
xhr._needSend = needRequest
if (needRequest) {
// 是否调用了send()方法
xhr._send = true
originalSend.apply(xhr, arguments)
}
})
}
}
else {
console.error("window.XMLHttpRequest is null")
}
}
以上代码展示了一个基本的拦截方案。
拦截XMLHttpRequest请求的整体思路是实现一个XMLHttpRequest
的代理对象,然后覆盖全局的XMLHttpRequest
,这样一但上层调用new XMLHttpRequest
这样的代码时,其实创建的是我们代理对象实例,就可以在发送请求之前,以及返回后进行一些预处理。
其实对于XMLHttpRequest
的拦截,业内有一些比较优秀的实现方案,比如:Ajax-hook,这个库已经被多个大厂使用,相关代码也已经达到商用的标准。实现细节和详细原理也可以参考文档Ajax-hook原理解析。
需要注意的是:因为xhr的responseText属性并不是writeable的,这也就意味着你无法直接更改xhr.responseText的值,而Ajax-hook
最新版放弃了直接重写XMLHttpRequest.prototype
的实现方式,转而代理XMLHttpRequest
的实例就不会有这个问题。
fetch拦截
fetch使用示例
fetch('http://example.com/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: 'John' }),
})
.then(response => response.json())
.then(data => {
// 请求成功的处理逻辑
console.log(data);
})
.catch(error => {
// 请求失败的处理逻辑
console.error(error);
})
拦截方式
// fetch-proxy.js
var prototype = 'prototype'
export default function fetchProxy(proxy, win) {
win = win || window;
return proxyFetch(proxy, win)
}
function Handler(resolve, reject) {
this._resolve = resolve
this._reject = reject
}
Handler[prototype] = Object.create({
resolve: function resolve(res) {
this._resolve && this._resolve(res.response)
},
reject: function reject(err) {
this._reject && this._reject(err.error)
}
})
function makeHandler(next) {
function sub(resolve, reject) {
Handler.call(this, resolve, reject)
}
sub[prototype] = Object.create(Handler[prototype])
sub[prototype].next = next
return sub
}
function proxyFetch(proxy, win) {
const onRequest = proxy.onRequest
const onResponse = proxy.onResponse
const onError = proxy.onError
// 保存原始函数
const originalFetch = win.fetch
if (!originalFetch) {
console.error("fetch is null")
return {}
}
/**
* @param input url字符串或者Request对象
* @param init 配置项
*/
win.fetch = function (input, init) {
let config = { input, init }
return new Promise((resolve, reject) => {
let RequestHandler = makeHandler(function (cfg) {
cfg = cfg || config;
callFetch(cfg)
})
let ResponseHandler = makeHandler(function (res) {
this.resolve(res)
})
let ErrorHandler = makeHandler(function (err) {
this.reject(err)
})
// 调用真实请求接口
const callFetch = (config) => {
originalFetch.apply(this, [config.input, config.init]).then((response) => {
let handler = new ResponseHandler(resolve, reject)
let ret = {
config: config,
response: response,
}
if (!onResponse) {
return handler.resolve(ret)
}
onResponse(ret, handler)
}).catch((error) => {
let handler = new ErrorHandler(resolve, reject)
let err = { config: config, error: error }
if (onError) {
onError(err, handler)
} else {
handler.next(err)
}
})
}
if (onRequest) {
onRequest(config, new RequestHandler(resolve, reject))
}
else {
callFetch(config)
}
})
}
function unProxy() {
win.fetch = originalFetch
originalFetch = undefined
}
return {
originalFetch,
unProxy
}
}
以下是使用fetchProxy
的示例
// fetchProxy使用展示
const {originalFetch, unProxy} = fetchProxy({
//请求发起前进入
onRequest: (config, handler) => {
setTimeout(() => {
handler.next(config)
}, 1000)
},
//请求发生错误时进入
onError: (err, handler) => {
handler.next(err)
},
//请求成功后进入
onResponse: (response, handler) => {
handler.next(response)
}
})
拦截fetch
请求的整体思路是实现一个fetch
的代理对象,然后覆盖全局的fetch
,这样一但上层调用fetch()
这样的代码时,其实调用的是我们代理函数,就可以在发送请求之前,以及返回后进行一些预处理。
JSONP拦截
JSONP实现基本原理
JSONP实现跨域请求的原理简单的说,就是动态创建<script>
标签,然后利用<script>
的src
不受同源策略约束来跨域获取数据。
1、动态方式
function jsonpCallback(data) {
// 在这里处理响应数据
}
var scriptElement = document.createElement('script');
scriptElement.src = "//api.domain.com/getData?callback=jsonpCallback";
document.body.appendChild(scriptElement);
document.body.removeChild(scriptElement); // 请求发送后立即删除 <script> 元素
// 服务器将会返回类似于以下的响应:
jsonpCallback({ "name": "John", "age": 30 })
以上代码展示了通过动态创建<script>
标签的方式实现JSONP的请求。由于浏览器加载<script>
标签返回的数据默认会按照javascript
代码执行,所以返回的字符串中的jsonpCallback
会被当做函数执行,因此我们可以在全局定义的函数jsonpCallback
中获取到后端传递过来的数据data
拦截方式
根据以上实现方式,整个加载流程分为以下几个阶段:
- 创建
<script>
标签:document.createElement
- 赋值
src
属性 - 添加子节点到dom树的方法:
appendChild
、insertBefore
、insertAdjacentElement
等 - 加载完成事件
onload
- 加载报错事件
onerror
可以发现,对于动态创建<script>
标签的JSONP实现方案,我们唯一能做的就只能是创建和赋值src阶段进行拦截,当加载完成时回调函数的代码会自动执行,我们无法干涉。
// jsonp-proxy.js 拦截JSONP请求示例
(function () {
var originalCreateElement = document.createElement
function proxy(dom) {
var src
Object.defineProperty(dom, 'src', {
get: function () {
return src
},
set: function (newVal) {
src = newVal
dom.setAttribute('src', newVal)
}
})
var originalSetAttribute = dom.setAttribute
dom.setAttribute = function () {
var args = Array.prototype.slice.call(arguments)
var key = args[0]
var val = args[1]
// 根据val即url地址上是否存在callback=回调函数字样粗略的判断,是否请求的是JSONP的调用,但是此方法不是很可靠,因为像jquery里面的JSONP实现是可以指定callback的命名的。
if (key === 'src' && val.includes('callback=')) {
let url = val
console.log('请求地址:', url)
// 在调用之前做一些事情,比如修改url
originalSetAttribute.apply(dom, [key, url])
}
else {
originalSetAttribute.apply(dom, args)
}
}
}
// 重写创建节点函数
document.createElement = function (tagName) {
var dom = originalCreateElement.call(document, tagName)
dom.onload = function (evt) {
console.log("onload:", evt)
}
dom.onerror = function (err) {
console.log("onerror:", err)
}
if(tagName.toLowerCase() === 'script'){
proxy(dom)
}
return dom
}
})()
2、静态方式
不排除也可以通过非动态创建的<script>
标签实现JSONP的调用,示例如下:
<script type="text/javascript">
function jsonpCallback(data) {
// 在这里处理响应数据
}
</script>
<script type="text/javascript" src="//api.domain.com/getData?callback=jsonpCallback"></script>
// 服务器将会返回类似于以下的响应:
jsonpCallback({ "name": "John", "age": 30 })
拦截方式
由于<script>
标签已经提前放在了HTML页面上,所以我们很难干涉这样的JSONP的请求,目前暂无办法进行拦截。
总结
由以上分析可以知道,目前在XMLHttpRequest
和fetch
的拦截都可以做到onRequest
发送请求之前、onResponse
收到响应之后和onError
发送错误时三个阶段的完全拦截进行预处理。但是针对JSONP
的请求,目前只能对动态方式进行onRequest
发送请求之前的部分拦截。
不过综合以上三种拦截方式的实现,已经可以满足绝大部分场景的拦截需求了。
风险提示
重写底层请求组件可能带来的问题:前端上层应用埋点或性能监控引入的库本身就会重写,二者容易冲突,复写大概率也不怎么健壮,因此使用时需要考虑到兼容性问题。