前言
对于取消请求,Axios
官方曾经推出了CancelToken
来实现该功能。而在 2021 年 10 月推出的AxiosV0.22.0
版本中却把CancelToken
打上 👎deprecated 的标记,意味废弃。与此同时,推荐 AbortController
来取而代之,如下所示:
大家也可以点击axios#cancellation来阅读关于图片上的文档出处。
针对这个新的用法,我对此进行学习且总结出这篇文章,这篇文章主要的内容点如下:
AbortController
是什么?Axios
内部是如何运用AbortController
的?- 个人分析:
Axios
为什么推荐用AbortController
替代CancelToken
?
下面就直接开始进入本文的内容吧。
本文所分析的Axios
源码版本为v0.27.2
AbortController
是什么?
直接引用MDN AbortController来介绍:
AbortController 接口表示一个控制器对象,允许你根据需要中止一个或多个 Web 请求。
你可以使用 AbortController.AbortController() 构造函数创建一个新的 AbortController。使用 AbortSignal 对象可以完成与 DOM 请求的通信。
可能上面的概念会有点抽象,下面我直接通过一个例子来展示如何用AbortController
中断用XHR
请求,代码如下所示:
import { useRef, useState } from "react";
export default function App() {
const [message, setMessage] = useState("");
const controller = useRef();
const [loading, setLoading] = useState(false);
const requestVideo = () => {
setMessage("下载中");
setLoading(true);
// 创建AbortController实例且存放到controller上
// 注意这里每次请求都会创建一个新的AbortController实例,是因为AbortController实例调用abort后,
// AbortController实例的状态signal就为aborted不能更改
controller.current = new AbortController();
const xhr = new XMLHttpRequest();
xhr.open("get", "https://mdn.github.io/dom-examples/abort-api/sintel.mp4");
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
setMessage("下载成功");
setLoading(false);
}
};
// 监听AbortController实例的abort事件,当AbortController实例调用abort方法时,就会触发该事件执行回调
controller.current.signal.addEventListener("abort", () => {
setMessage("下载中止");
setLoading(false);
xhr.abort();
});
xhr.send();
};
// 调用AbortController实例的abort方法,从而触发上面注册在abort事件的回调的执行
const abortDownload = () => {
controller.current.abort();
};
return (
<>
<div>
<button disabled={loading} onClick={requestVideo}>
Download video
</button>
<button disabled={!loading} onClick={abortDownload}>
Abort Download
</button>
</div>
<div>{message}</div>
</>
);
}
交互效果如下所示:
大家也可以在这里CodeSandbox体验上面的代码例子。体验的时候最好把网络环境设为“Slow 3G”,这样子接口响应时间长一点,能及时禁止请求。
从上面的例子中可知,AbortController
的实例abortController
只是一个类似观察者模式(如上图所示 👆)中的Subject
(即事件派发中心),通过abortController.signal.addEventListener('abort', callback)
注册Observer
。且负责中断 Web 请求的是这些被注册在Subject
上的Observer
。且这个Subject
是一次性的,即只能notify
一次。当abortController.abort
被调用时,作为信号状态的abortController.signal
的aborted
属性(只读值)置为true
,表示该信号状态已被取消。
AbortController
常用于取消Fetch
请求,其取消Fetch
请求的代码逻辑非常简洁明了,如下代码所示:
import { useRef, useState } from "react";
export default function App() {
const [message, setMessage] = useState("");
const controller = useRef(new AbortController());
const [loading, setLoading] = useState(false);
const fetchVideo = () => {
setMessage("下载中");
setLoading(true);
controller.current = new AbortController();
fetch("https://mdn.github.io/dom-examples/abort-api/sintel.mp4", {
// fetch配置中仅需把signal指向AbortController实例的signal即可
signal: controller.current.signal,
})
.then(() => {
setMessage("下载成功");
setLoading(false);
})
.catch((e) => {
setMessage("下载错误:" + e.message);
setLoading(false);
});
};
const abortDownload = () => {
controller.current.abort();
};
return (
<>
<div>
<button disabled={loading} onClick={fetchVideo}>
Download video
</button>
<button disabled={!loading} onClick={abortDownload}>
Abort Download
</button>
</div>
<div>{message}</div>
</>
);
}
交互效果和XHR
例子的一样,这里就不展示了,想体验的读者可以点击此处Code Sandbox。
对于AbortController
的浏览器兼容性如下所示:
可见,如果项目针对的浏览器版本比较旧,那在Axios
上还是乖乖用CancelToken
来取消请求比较好。
Axios
内部是如何运用AbortController
的?
我们直接来看看Axios
源码中是如何使用AbortController
的。首先要知道,在Axios
中负责发出请求的是axios.default.adapter
,而在浏览器环境下axios.default.adapter
取自'lib/adapters/xhr.js'
文件,下面来看看这个文件中涉及到XHR
和AbortController
和CancelToken
(为了方便下面分析CancelToken
)的源码:
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
var onCanceled;
// done函数用于在请求结束后注销回调函数,以免发生内存泄漏
function done() {
// 如果使用CancelToken实例,则会在下面发出请求逻辑之前通过subscribe注册onCanceled函数
if (config.cancelToken) {
config.cancelToken.unsubscribe(onCanceled);
}
// 如果使用AbortController实例,则会在下面发出请求逻辑之前通过signal.addEventListener监听abort事件且注册onCancel作为回调函数
if (config.signal) {
config.signal.removeEventListener("abort", onCanceled);
}
}
var request = new XMLHttpRequest();
var fullPath = buildFullPath(config.baseURL, config.url);
request.open(
config.method.toUpperCase(),
buildURL(fullPath, config.params, config.paramsSerializer),
true
);
function onloadend() {
// 生成response对象
var response = { data, status, statusText, headers, config, request };
// settle函数内部根据response.status或config.validateStatus去调用_resolve或_reject
settle(
function _resolve(value) {
resolve(value);
done();
},
function _reject(err) {
reject(err);
done();
},
response
);
}
if ("onloadend" in request) {
// Use onloadend if available
request.onloadend = onloadend;
} else {
// Listen for ready state to emulate onloadend
request.onreadystatechange = function handleLoad() {
if (!request || request.readyState !== 4) {
return;
}
if (
request.status === 0 &&
!(request.responseURL && request.responseURL.indexOf("file:") === 0)
) {
return;
}
// onreadystatechange事件会先于onerror或ontimeout事件触发
// 因此onloaded需要在下一个事件循环中执行
setTimeout(onloadend);
};
}
// Handle browser request cancellation (as opposed to a manual cancellation)
request.onabort = function handleAbort() {};
// Handle low level network errors
request.onerror = function handleError() {};
// Handle timeout
request.ontimeout = function handleTimeout() {};
// 处理用到CancelToken或AbortController的情况
if (config.cancelToken || config.signal) {
// 取消请求的函数
onCanceled = function (cancel) {
if (!request) {
return;
}
reject(
!cancel || (cancel && cancel.type) ? new CanceledError() : cancel
);
request.abort();
request = null;
};
// 如果是用CancelToken取消请求,则把onCanceled注册到CancelToken实例上,
// CancelToken实例本质上是一个观察者模式中的Subject,有关其源码会在下面的章节中分析
config.cancelToken && config.cancelToken.subscribe(onCanceled);
// 如果是用AbortController,则先从AbortController实例的signal.aborted判断其是否已调用abort,
// 如果已调用,直接执行onCanceled,如果没有则直接在signal上监听其事件,逻辑和开头展示AbortController取消XHR请求的例子一样
// axios.request在调用时,会return一条动态生成的promise链,链上的顺序是:
// Promise.resove(config)->所有请求拦截器(onFulfilled,onRejected)->(dispatchRequest,undefined)->所有响应拦截器(onFulfilled,onRejected)
// dispatchRequest就是调用config.adapter或default.adapter去发出请求,
// 因为存在执行请求拦截器途中,AbortController实例已调用aborted的情况,因此这里要对config.signal.aborted做判断处理
if (config.signal) {
config.signal.aborted
? onCanceled()
: config.signal.addEventListener("abort", onCanceled);
}
}
request.send(requestData);
});
};
关于上面注释中说到的promise链
其实涉及到拦截器的分析,想了解更多的可以看我之前写的文章如何避免 axios 拦截器上的代码过多。
根据上面的源码分析可知,AbortContoller
的调用方式和上一章节中AbortContoller
取消XHR
请求的逻辑是一样的,非常浅显易懂。
上面源码中同样也展示了CancelToken
实例在其中的运行逻辑:
- 在请求发出之前,
CancelToken
实例通过自身方法subscribe
注册onCancel
函数 - 在请求结束后,
CancelToken
实例通过自身方法unsubscribe
注销onCancel
函数
由此可见,CancelToken
实例本质上其实也是一个以观察者模式为原理的事件派发中心。在下面的章节中,我们会顺带学习一下CancelToken
的源码。
Axios
为什么推荐用AbortController
替代CancelToken
?
在分析CancelToken
被替代之前,我们要先阅读CancelToken
源码以学习其内在原理
关于CancelToken
的原理
Axios
提供了以下方式来运用到CancelToken
:
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.post(
"/user/12345",
{
name: "new name",
},
{
cancelToken: source.token,
}
);
source.cancel();
我们来按照axios.post
的执行过程逐步分析CancelToken
对应的源码:
-
首先通过
CancelToken.source
方法生成source
变量:这里首先要知道生成的
source
是什么,我们看下关于CancelToken.source
的源码:CancelToken.source = function source() { var cancel; var token = new CancelToken(function executor(c) { cancel = c; }); return { token: token, cancel: cancel, }; };
CancelToken.source
返回的是token
和cancel
都取值于CancelToken
实例化过程,那我们直接看CancelToken
的构造函数:function CancelToken(executor) { if (typeof executor !== "function") { throw new TypeError("executor must be a function."); } var resolvePromise; this.promise = new Promise(function promiseExecutor(resolve) { resolvePromise = resolve; }); var token = this; // eslint-disable-next-line func-names this.promise.then(function (cancel) { if (!token._listeners) return; var i; var l = token._listeners.length; for (i = 0; i < l; i++) { token._listeners[i](cancel); } token._listeners = null; }); // source.cancel指向此处的cancel函数 executor(function cancel(message) { // token.reason有值代表cancel已被执行,CancelToken是一个一次性的Subject,notify一次后即失效 if (token.reason) { // Cancellation has already been requested return; } token.reason = new CanceledError(message); // resolvePromise执行时,会执行上面this.promise.then中传入的回调函数。从而把listeners全执行 resolvePromise(token.reason); }); }
-
在
axios.post
执行时,调用axios.default.adapter
处理发出请求的环节。其中涉及到CancelToken
的代码在上一章节分析xhrAdapter
源码时已经展示过了,这里就不重复了。下面列出关于CancelToken
实例在整个请求过程中的操作:- 发出请求前:通过
config.cancelToken.subscribe(onCanceled)
把onCanceled
注册到CancelToken
实例里。onCanceled
内部含request.abort()
中断请求操作。 - 在请求完成后:通过
config.cancelToken.unsubscribe(onCanceled)
注销该回调函数。
据此,我们来看看
CancelToken
中关于subscribe
和unsubscribe
的源码:CancelToken.prototype.subscribe = function subscribe(listener) { // 如果CancelToken实例已经执行cancel,直接执行该回调函数 if (this.reason) { listener(this.reason); return; } // 如果CancelToken实例还没执行cancel,则把回调函数放进_listeners里 if (this._listeners) { this._listeners.push(listener); } else { this._listeners = [listener]; } }; // 把回调函数从_listeners中移除 CancelToken.prototype.unsubscribe = function unsubscribe(listener) { if (!this._listeners) { return; } var index = this._listeners.indexOf(listener); if (index !== -1) { this._listeners.splice(index, 1); } };
- 发出请求前:通过
至此CancelToken
的原理分析完,设计逻辑非常简单,其实也是观察者模式的运用。
个人分析
个人分析Axios
官方更推荐使用AbortController
的原因如下:
-
保持与
fetch
一样的调用方式,让开发者更好上手Axios
官方一直保持自身的调用方式与fetch
相似,如下所示:fetch(url,config).then().catch() axios(url,config).then().catch()
而目前
fetch
唯一中断请求的方式就是与AbortController
搭配使用。Axios
通过支持与fetch
一样调用AbortController
实现中断请求的方式,让开发者更方便地从fetch
切换到Axios
。目前就实用性而言,XHR
还是比fetch
要好,例如sentry
在记录面包屑的接口信息方面,XHR
请求可以比fetch
请求记录更多的数据。还有目前fetch
还不支持onprogress
这类上传下载进度事件。 -
旧版本(
v0.22.0
之前)的CancelToken
存在内存泄露隐患,官方想让更多人升级版本从而减少内存泄露风险用
AbortController
来中断请求实在v0.22.0
版本支持的。而且在v0.22.0
之前,CancelToken
的运行过程中出现内存泄露隐患。我们来分析一下为什么存在隐患:拿
v0.21.4
版本的源码来分析,当时CancelToken
不存在CancelToken.prototype.subscribe
和CancelToken.prototype.unsubscribe
以及内部属性_listeners
,且其构造函数如下所示:function CancelToken(executor) { if (typeof executor !== 'function') { throw new TypeError('executor must be a function.'); } var resolvePromise; this.promise = new Promise(function promiseExecutor(resolve) { resolvePromise = resolve; }); /** v0.22.0才新增了这段代码,用_listeners记录回调函数 this.promise.then(function (cancel) { if (!token._listeners) return; var i; var l = token._listeners.length; for (i = 0; i < l; i++) { token._listeners[i](cancel); } token._listeners = null; }); */ var token = this; executor(function cancel(message) { if (token.reason) { // Cancellation has already been requested return; } token.reason = new Cancel(message); resolvePromise(token.reason); }); }
在
xhrAdapter
中只有下面的代码中涉及到CancelToken
:// lib\adapters\xhr.js function xhrAdapter(config) { // .... if (config.cancelToken) { // Handle cancellation config.cancelToken.promise.then(function onCanceled(cancel) { if (!request) { return; } request.abort(); reject(cancel); // Clean up request request = null; }); } // .... }
早期的思路是,当
CancelToken
实例执行cancel
方法时,实例内部属性this.promise
状态置为fulfilled
,从而执行在xhrAdapter
中用then
传入的onCanceled
函数,从而达到取消请求的目的。在
Axios
官方教程对CancelToken
的描述中,注明了可以给多个请求注入同一个CancelToken
,以达到同时取消多个请求的作用,如下所示:Note: you can cancel several requests with the same cancel token.
const CancelToken = axios.CancelToken; const source = CancelToken.source(); axios.get('/user/1', {cancelToken: source.token}) axios.get('/user/2', {cancelToken: source.token}) // 此操作可同时取消上面两个请求 source.cancel()
这种用法使用场景比较多,例如在对大文件时做切片上传的场景,如果需要实现手动中断上传的功能,可以生成一个
CancelToken
实例,注入到每一个上传切片的请求上。当用户点击"中断传输"的按钮时,直接执行CancelToken
实例的cancel
方法即可中断所有请求,代码如下所示:let cancelToken // 上传函数 function upload(){ // 用于存放切片 const chunks = [] // 每个切片的最大容量为5M const SIZE = 5 * 1024 for(let i = 0; i<file.size;i+=SIZE){ chunks.push(file.slice(i,i+size)) } cancelToken = axios.CancelToken.source(); chunks.forEach((chunk)=>{ axios('upload',{ method:'post', cancelToken: cancelToken.token }) }) } // 中断上传函数 function cancel(){ // 执行cancel后会中断上面所有切片的上传 cancelToken.cancel() cancelToken = null }
但正是这种玩法存在内存泄露的隐患。假设上面切片上传过程中没有发生中断或者很久才发生中断,则
cancelToken.promise
会一直存在在内存里,而由于xhrAdapter
中cancelToken.promise
通过.then(function onCancel(){...})
挂载了很多个onCancel
。而我们再来看看onCancel
源码:function xhrAdapter(config) { config.cancelToken.promise.then(function onCanceled(cancel) { if (!request) { return; } request.abort(); reject(cancel); // Clean up request request = null; }); }
会发现
request
并不是onCancel
的局部变量,那么说request
是通过闭包机制访问到的。当一个请求已经结束时,request
因为仍被onCancel
引用,所以没在gc
过程中从内存堆里被清理。而这些request
因为每一个都包含了当前上传数据所以占用相当大,所有这些request
会一直存在内存堆里,直至cancelToken
执行cancel
或者cancelToken
置为null
值时,cancelToken.promise
才会被清除。如果是在上传单个或者数个非常大的文件,则会非常占用内存从而出现泄露的情况。在
axios
的issue
里就有两个是涉及到这种情况的:#1181,#3001后来
v0.22.0
版本中,Axios
官方把CancelToken
做了大改,改成了上一节中分析到的CancelToken
源码的情况。与此同时,v0.22.0
也开始支持AbortController
。因此官方开始推荐AbortController
,想让开发者升级版本到v0.22.0
以上的同时,消除CancelToken
带来的内存泄露隐患。 -
减少代码维护量
经历了
v0.22.0
的大改后,CancelToken
的原理和AbortController
相似。既然有AbortController
这种在功能上完全顶替CancelToken
,且浏览器兼容性好的原生API
。就没必要在继续维护CancelToken
。估计在之后v1.x
或者v2.x
版本里不再存在CancelToken
,也减少代码的维护量。
后记
这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 👏👏👏。