在做一个 vue 技术栈的 h5 项目的时候,出现了这么一个 bug,不同路由下的请求接口是同一个,如果在网络较慢的情况下进行路由的快速切换就会导致两个路由下的数据混在一起,以下是从解决这个 bug 引发的一系列思考。
1 问题现象
网络调为 mid-tier mobile 后,快速切换路由会发现第二个路由(待客户签署)的数据和第一个路由(起草协议)数据发生了累加:
问题现象
2 解决效果
添加了路由切换后取消请求的功能后数据正常:
解决效果
3 具体实现方式
初始入口文件中通过 axios 生成 cancelToken:
axios 的拦截器的 request 配置中添加参数 cancelToken
4 原理分析
1.axios 简介
首先我们根据 axios 官方文档可以知道,axios 原生支持取消请求:
axios 文档里介绍的取消 axios 请求有以下两种方式:
// 第一种:使用 CancelToken
const { CancelToken, isCanCel } = axios;
const source = CancelToken.source();
axios.get('/user/12345', {
cancelToken: source.token
}).catch(thrown => {
if (isCancel(thrown)) {
// 获取 取消请求 的相关信息
console.log('Request canceled', thrown.message);
} else {
// 处理其他异常
}
});
axios.post('/user/12345', {
name: 'new name'
}, {
cancelToken: source.token
})
// 取消请求。
source.cancel('Operation canceled by the user.');
// 第二种:还可以通过传递一个 executor 函数到 CancelToken 的构造函数来创建 cancel token:
const CancelToken = axios.CancelToken;
let cancel;
axios.get('/user/12345', {
cancelToken: new CancelToken(function executor(c) {
// executor 函数接收一个 cancel 函数作为参数
cancel = c;
})
});
// 取消请求
cancel();
复制代码
那么它究竟是怎么做到的?
第一步我们先要清楚一个请求在 axios 的工作流程,像一个管道一样:
axios 请求流转
看上图发现 request 发出之前有个 interceptors,查看 axios 文档不难发现,这个 interceptors 提供了设置请求或响应被处理之前拦截到请求或响应去处理他们(其实就是个 promise 中间件):
2.源码探究
其实我们在开始的问题解决代码的贴图中就是使用了 interceptors 对 request 发出前添加了 cancelToken 配置,那么问题来了:
<1>.为什么在这里加了 cancelToken 的配置就可以实现取消未完成的请求呢?
<2>. 是 axios.interceptors.request 的魔力吗,怎么会如此神奇呢?
带着问题我们一步一步看:
<1>、各类请求的公共方法 request
在源码文件 core/Axios.js 有这么几行核心代码,无论请求方法是什么类型的都会走一个 request 方法:
那么这个 request 到底做了什么?
粗略的解释上图代码:
1、定义了一个数组 chain,这个数组中包含了 dispatchRequest 对象和 undefined 两个元素。
2、将请求传入的 config 对象转为 promise 对象。
3、经过拦截器处理后的 chain 变为:
[dispatchRequest, undefined,
interceptor.response.fulfilled,
interceptor.response.rejected]
4、返回一个 promise 链式调用之后的的执行之后的返回结果:
Promise.resolve(config)
.then(interceptor.request.fulfilled, interceptor.request.rejected)
.then(dispatchRequest, undefined)
.then(interceptor.response.fulfilled, interceptor.response.rejected)
下面用一幅图直观的描述 axios 的 promise 链式调用:
promise 链式调用
看到这里想必大家都会有疑问,拦截器相关的 interceptor.request.fullfilled 和 interceptor.request.rejected 等是什么,怎么来的,有什么用?请看下一个 Interceptor 源码解析。
<2>、Interceptor 源码解析
interceptor.request.fullfilled 和 interceptor.request.rejected 是什么?
答:是两个函数,分别是 promise 在 resolve 和 reject 状态的回调函数。
interceptor.request.fullfilled 和 interceptor.request.rejected 怎么来的,有什么用?
源码里提供了 InterceptorManeger.js 文件,用来创建 interceptor:
创建 interceptor.request.fullfilled 和 interceptor.request.rejected 过程:
此图的详细解释如下:
/*
Axios.js核心代码
*/
Axios.prototype.request = function request(config) {
...
// 在这里调用了forEach方法,而这个方法在下边InterceptorManager.js中
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
// 将参数interceptor加到chain数组头部
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
};
/*
InterceptorManager.js文件核心代码
*/
function InterceptorManager() {
this.handlers = []; // 用来保存所有拦截器注册的函数
}
// 这个use方法用来给拦截器注册回调函数
InterceptorManager.prototype.use = function use(fulfilled, rejected) {
// 这里的handles中存的是由fulfilled和rejected组成的object
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1;
};
...
/*
将所有handles中注册的函数{
fulfilled: fulfilled,
rejected: rejected
},
遍历给fn(这里的fn可以理解为unshiftRequestInterceptors)执行一遍,
即作为unshiftRequestInterceptors函数的参数
*/
InterceptorManager.prototype.forEach = function forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) {
fn(h);
}
});
};
/*
utils.js核心代码
*/
function forEach(obj, fn) {
...
if (isArray(obj)) {
// fn(理解为unshiftRequestInterceptors)遍历执行,参数是this.handles中的元素
for (var i = 0, l = obj.length; i < l; i++) {
fn.call(null, obj[i], i, obj);
}
}
...
}
复制代码
讲到这里还是没有看到 cancelToken,接着往下看。
<3>、dispatchRequest 源码解析
interceptor.request 逻辑执行结束之后,就到 dispatchRequest 了,查看源码我们发现这才是真正发出请求的地方,截取的浏览器端发请求的核心代码如下:
/*
dispatchRequest.js核心代码
*/
module.exports = function dispatchRequest(config) {
...
// 这里的adapter其实就是获取发送请求的方式,浏览器中为xhr,node端为http
var adapter = config.adapter || defaults.adapter;
/*
这里then参数中为promise的两个回调函数,那么adapter这个promise是什么样的,
请看下边的xhr.js源码分析
*/
return adapter(config).then(function onAdapterResolution(response) {
throwIfCancellationRequested(config);
// Transform response data
response.data = transformData(
response.data,
response.headers,
config.transformResponse
);
return response;
}, function onAdapterRejection(reason) {
if (!isCancel(reason)) {
throwIfCancellationRequested(config);
// Transform response data
if (reason && reason.response) {
reason.response.data = transformData(
reason.response.data,
reason.response.headers,
config.transformResponse
);
}
}
return Promise.reject(reason);
});
};
复制代码
其实这里不难发现第一次画的 promise 链式调用图中,在 dispatchRequest 函数内部又有 promise 的链式调用:
这时候的 promise 链式调用就变成了这个样子:
promise 链式调用 2
dispatchRequest 分解后的形成了又一个以 adapterPromise 开始的 promise 链式调用,而这个 adapter 在浏览器中其实就是 Xhr.js 返回的 promise 对象,这里也是真正发出请求的地方。
通过查看源码我们终于发现!!!其中有对 cancelToken 的处理逻辑!!!
那么 cancelToken 到底是怎么起作用的? 请看下一节,xhr.js 源码分析:
<4>、xhr.js 源码解析
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
...
if (config.cancelToken) {
/*
如果config中有cancelToken参数,则讲onCanceled注册为 config.cancelToken.promise的resolve回调函数,在onCanceled方法中就可以取消请求,并将adapter.promise状态变为reject
*/
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) {
return;
}
request.abort();
reject(cancel);
// Clean up request
request = null;
});
}
...
});
};
复制代码
其实看到这里大家应该已经看懂了,在这个 cancelTokenPromise 状态变为 rejected 的话整个 promise 链都变为 rejected,请求也就会被取消掉。
那么问题又来了,cancelToken.promise 是什么,这个 promise 什么时候会执行 resolve 方法进行请求取消呢?请看下面的源码分析。
<5>、CancelToken 源码解析
function CancelToken(executor) {
...
var resolvePromise;
// 在这里将cancelToken.promise的resolve函数赋值给了resolvePromise变量
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});
var token = this;
// 在执行executor这个方法时候会执行resolvePromise,即xhr.js中注册的onCancel,那什么时候会执行executor呢?请看接下里的source方法
executor(function cancel(message) {
if (token.reason) {
// Cancellation has already been requested
return;
}
token.reason = new Cancel(message);
resolvePromise(token.reason);
});
}
...
/*看看源码得知实际调用axios.cancelToken.source()方法生成取消令牌的时候实际上生成了包含两个属性(token,cancel)的对象,在执行cancel方法的时候就会执行上述方法executor,也就是说这里的cancel一执行就执行了onCancel方法,就会取消请求*/
CancelToken.source = function source() {
var cancel;
var token = new CancelToken(function executor(c) {
cancel = c;
});
return {
token: token,
cancel: cancel
};
};
复制代码
在执行了 cancel 之后就会 cancelToken.promise 就变为 reject 了,~~整个 promise 调用就都是 rejected 了,接着 adapter 这个 promise 对象也变为 reject 了,整个 promise 链都为 reject 了,请求取消,game over🙃🙃🙃。
用一幅图表示如下:
<6>、回头望月
再回过头看看我们最开始的解决方案,有没有恍然大悟?
其实我们的解决方式就是同一个路由下的请求公用一个 canceltoken,虽然多个请求会生成多个 promise 链,但是在 adapterPromise 局部的 cancelToken.promise 却是同一个,这样在执行 axios.cancelToken.source().cancel 方法时候就会作用于全部 promise 链,一旦 cancel 一执行,所有未完成的请求都会取消,相对应的 promise 链都会变为 rejected。
初始入口文件中通过 axios 生成 cancelToken:
axios 的拦截器的 request 配置中添加参数 cancelToken
5 思考:知道原理后我们还能做什么?
可以通过 axios 取消特定的未完成请求。
学习 axios 对 promise 的妙用,比如参考 axios 请求取消的实现方式我们是不是也可以对 fetch 请求进行包装呢?
作者介绍:
孟浩然(企业代号名),目前负责贝壳人店平台中心相关前端工作。
本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。
原文链接:
https://mp.weixin.qq.com/s/2ADjfJPge391xpdikM08qQ
评论