用了这么久的CancelToken,Axios竟推荐用AbortController取而代之

4,851

前言

对于取消请求,Axios官方曾经推出了CancelToken来实现该功能。而在 2021 年 10 月推出的AxiosV0.22.0版本中却把CancelToken打上 👎deprecated 的标记,意味废弃。与此同时,推荐 AbortController 来取而代之,如下所示:

image.png

大家也可以点击axios#cancellation来阅读关于图片上的文档出处。

针对这个新的用法,我对此进行学习且总结出这篇文章,这篇文章主要的内容点如下:

  1. AbortController是什么?
  2. Axios内部是如何运用AbortController的?
  3. 个人分析: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>
    </>
  );
}

交互效果如下所示:

abortcontroller-xhr.gif

大家也可以在这里CodeSandbox体验上面的代码例子。体验的时候最好把网络环境设为“Slow 3G”,这样子接口响应时间长一点,能及时禁止请求。

image.png

从上面的例子中可知,AbortController的实例abortController只是一个类似观察者模式(如上图所示 👆)中的Subject(即事件派发中心),通过abortController.signal.addEventListener('abort', callback)注册Observer。且负责中断 Web 请求的是这些被注册在Subject上的Observer。且这个Subject是一次性的,即只能notify一次。当abortController.abort被调用时,作为信号状态的abortController.signalaborted属性(只读值)置为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的浏览器兼容性如下所示:

image.png

可见,如果项目针对的浏览器版本比较旧,那在Axios上还是乖乖用CancelToken来取消请求比较好。

Axios内部是如何运用AbortController的?

我们直接来看看Axios源码中是如何使用AbortController的。首先要知道,在Axios中负责发出请求的是axios.default.adapter,而在浏览器环境下axios.default.adapter取自'lib/adapters/xhr.js'文件,下面来看看这个文件中涉及到XHRAbortControllerCancelToken(为了方便下面分析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实例在其中的运行逻辑:

  1. 在请求发出之前,CancelToken实例通过自身方法subscribe注册onCancel函数
  2. 在请求结束后,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对应的源码:

  1. 首先通过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返回的是tokencancel都取值于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);
      });
    }
    
  2. axios.post执行时,调用axios.default.adapter处理发出请求的环节。其中涉及到CancelToken的代码在上一章节分析xhrAdapter源码时已经展示过了,这里就不重复了。下面列出关于CancelToken实例在整个请求过程中的操作:

    • 发出请求前:通过config.cancelToken.subscribe(onCanceled)onCanceled注册到CancelToken实例里。onCanceled内部含request.abort()中断请求操作。
    • 在请求完成后:通过config.cancelToken.unsubscribe(onCanceled)注销该回调函数。

    据此,我们来看看CancelToken中关于subscribeunsubscribe的源码:

    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的原因如下:

  1. 保持与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这类上传下载进度事件。

  2. 旧版本(v0.22.0之前)的CancelToken存在内存泄露隐患,官方想让更多人升级版本从而减少内存泄露风险

    AbortController来中断请求实在v0.22.0版本支持的。而且在v0.22.0之前,CancelToken的运行过程中出现内存泄露隐患。我们来分析一下为什么存在隐患:

    v0.21.4版本的源码来分析,当时CancelToken不存在CancelToken.prototype.subscribeCancelToken.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会一直存在在内存里,而由于xhrAdaptercancelToken.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才会被清除。

    image.png

    如果是在上传单个或者数个非常大的文件,则会非常占用内存从而出现泄露的情况。在axiosissue里就有两个是涉及到这种情况的:#1181#3001

    后来v0.22.0版本中,Axios官方把CancelToken做了大改,改成了上一节中分析到的CancelToken源码的情况。与此同时,v0.22.0也开始支持AbortController。因此官方开始推荐AbortController,想让开发者升级版本到v0.22.0以上的同时,消除CancelToken带来的内存泄露隐患。

  3. 减少代码维护量

    经历了v0.22.0的大改后,CancelToken的原理和AbortController相似。既然有AbortController这种在功能上完全顶替CancelToken,且浏览器兼容性好的原生API。就没必要在继续维护CancelToken。估计在之后v1.x或者v2.x版本里不再存在CancelToken,也减少代码的维护量。

后记

这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 👏👏👏。