由一个 bug 引发对 axios 的刨根问底

阅读数:145 2019 年 9 月 27 日 11:17

由一个bug引发对axios的刨根问底

在做一个 vue 技术栈的 h5 项目的时候,出现了这么一个 bug,不同路由下的请求接口是同一个,如果在网络较慢的情况下进行路由的快速切换就会导致两个路由下的数据混在一起,以下是从解决这个 bug 引发的一系列思考。

1 问题现象

网络调为 mid-tier mobile 后,快速切换路由会发现第二个路由 (待客户签署) 的数据和第一个路由 (起草协议) 数据发生了累加:

由一个bug引发对axios的刨根问底

问题现象

2 解决效果

添加了路由切换后取消请求的功能后数据正常:

由一个bug引发对axios的刨根问底

解决效果

3 具体实现方式

初始入口文件中通过 axios 生成 cancelToken:

由一个bug引发对axios的刨根问底

axios 的拦截器的 request 配置中添加参数 cancelToken

由一个bug引发对axios的刨根问底

4 原理分析

1.axios 简介

首先我们根据 axios 官方文档可以知道,axios 原生支持取消请求:

由一个bug引发对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 的工作流程,像一个管道一样:

由一个bug引发对axios的刨根问底

axios 请求流转

看上图发现 request 发出之前有个 interceptors,查看 axios 文档不难发现,这个 interceptors 提供了设置请求或响应被处理之前拦截到请求或响应去处理他们(其实就是个 promise 中间件):

由一个bug引发对axios的刨根问底

2. 源码探究

其实我们在开始的问题解决代码的贴图中就是使用了 interceptors 对 request 发出前添加了 cancelToken 配置,那么问题来了:

<1>. 为什么在这里加了 cancelToken 的配置就可以实现取消未完成的请求呢?

<2>. 是 axios.interceptors.request 的魔力吗,怎么会如此神奇呢?

带着问题我们一步一步看:

<1>、各类请求的公共方法 request
在源码文件 core/Axios.js 有这么几行核心代码,无论请求方法是什么类型的都会走一个 request 方法:

由一个bug引发对axios的刨根问底

那么这个 request 到底做了什么?

由一个bug引发对axios的刨根问底

粗略的解释上图代码:

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 链式调用:

由一个bug引发对axios的刨根问底

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:

由一个bug引发对axios的刨根问底

创建 interceptor.request.fullfilled 和 interceptor.request.rejected 过程:

由一个bug引发对axios的刨根问底

此图的详细解释如下:

复制代码
/*
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 的链式调用:

由一个bug引发对axios的刨根问底

这时候的 promise 链式调用就变成了这个样子:

由一个bug引发对axios的刨根问底

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
};
};

由一个bug引发对axios的刨根问底

在执行了 cancel 之后就会 cancelToken.promise 就变为 reject 了,~~ 整个 promise 调用就都是 rejected 了,接着 adapter 这个 promise 对象也变为 reject 了,整个 promise 链都为 reject 了,请求取消,game over🙃🙃🙃。

用一幅图表示如下:

由一个bug引发对axios的刨根问底

<6>、回头望月
再回过头看看我们最开始的解决方案,有没有恍然大悟?

其实我们的解决方式就是同一个路由下的请求公用一个 canceltoken,虽然多个请求会生成多个 promise 链,但是在 adapterPromise 局部的 cancelToken.promise 却是同一个,这样在执行 axios.cancelToken.source().cancel 方法时候就会作用于全部 promise 链,一旦 cancel 一执行,所有未完成的请求都会取消,相对应的 promise 链都会变为 rejected。

初始入口文件中通过 axios 生成 cancelToken:

由一个bug引发对axios的刨根问底

axios 的拦截器的 request 配置中添加参数 cancelToken

由一个bug引发对axios的刨根问底

5 思考:知道原理后我们还能做什么?

可以通过 axios 取消特定的未完成请求。

学习 axios 对 promise 的妙用,比如参考 axios 请求取消的实现方式我们是不是也可以对 fetch 请求进行包装呢?

作者介绍:
孟浩然(企业代号名),目前负责贝壳人店平台中心相关前端工作。

本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。

原文链接:

https://mp.weixin.qq.com/s/2ADjfJPge391xpdikM08qQ

评论

发布