JS axios cancelToken 是如何实现取消请求?稍有啰嗦但超有耐心的 axios 源码分析

壹 ❀ 引

axios,一个基于promise且对ajax进行了二次封装的http库,在提供了与promise类似的API便捷写法同时,它还有一大特点,便是支持取消http请求。当然取消请求并不是axios独有特性,它也只是对于XMLHttpRequest.abort()进行了内部封装。

我在如何做好一个基础的搜索功能?记一个因客户大数据量而导致的后发先至Bug一文中,对于文章问题给出的解决方案就有涉及到取消请求。虽然原生做法也能达到目的,但实际开发中我们不会用那么麻烦的写法,自然会借助三方http库,比如axios,而在这个库中,我们若需要取消请求,就得使用它的cancelToken,那么这个cancelToken到底是怎么做的呢?为什么一个简单的source.cancel调用请求就不会发起了?出于好奇,我简单阅读了axios对于cancelToken实现的相关源码,那么在这篇文章做个简单记录。

贰 ❀ 一个取消请求的例子

了解事情原貌最简单的做法就是写一个请求取消的例子,然后断点到axios中去阅读源码,因此我在本地项目准备了一个这样的例子,而本地项目请求接口容易跨域,为了解决这个问题,才有了React axios 使用 http-proxy-middleware 解决跨域问题小记这篇文章。所以这里我们就不多赘述,直接结合axios实现请求取消:

// 引用CancelToken
const CancelToken = axios.CancelToken;
// 调用CancelToken.source得到一个source实例,此实例包含token和cancel两个属性
const source = CancelToken.source();
// 请求接口时附带cancelToken:source.token,get与post有所区别,具体查看官方文档
axios.get('api/request', { cancelToken: source.token })
  .catch(function (thrown) {
    if (axios.isCancel(thrown)) {
      alert(`Request canceled.${thrown.message}`);
    }
  });
// 通过source.cancel取消请求
source.cancel('Operation canceled by the user.');

上述代码中我们实现了一个简单接口取消请求,运行项目并打开控制台,发现并没发起request请求,且alert正常弹出。

而实现取消接口请求也比较简单,通过CancelToken.source()创建一个resoure实例:

// 单纯创建一个实例,不用于请求,让我们查看它
const source = CancelToken.source();
console.dir(source)

如图,此实例包含一个名为cancel的方法,接受一个message字段,也就你要取消请求时需要传递的理由,当然也可以不传。另外是一个token对象,它包含一个状态为pendingPromise对象(这个东西作用超级大,下文会解释这个Promise从哪来有什么用)。

取消请求的目的虽然达到了,可这几个方法像个黑盒,它里面到底发生了什么?没关系,让我们顺着上面请求取消的例子来一探究竟!

叁 ❀ cancelToken源码浅析

取消接口第一步创建resouce实例,它的源码在node_modules/axios/lib/cancel/CancelToken.js中可查看,代码如下:

/**
 * Returns an object that contains a new `CancelToken` and a function that, when called,
 * cancels the `CancelToken`.
 */
CancelToken.source = function source() {
  var cancel;
  var token = new CancelToken(function executor(c) {
    cancel = c;
  });
  return {
    token: token,
    cancel: cancel
  };
};

当我们执行CancelToken.source()触发的就是上述代码,首先,我们可以看到此方法确实返回一个包含canceltoken两个属性的对象,让我们来看看执行过程。

首先,此方法创建了一个calcel变量,紧接着,又创建了一个token,而token的赋值结果是调用构造函数CancelToken得到,注意,此时我们在调用构造函数时,传递了一个function如下:

function executor(c) {
    cancel = c;
}

你可以先不用思考它有什么用,就把这个函数理解成调用构造函数传递了一个实参,紧接着返回了一个包含上述两个属性的对象,对于source方法,它做的事情就这么简单,让我们紧接着看看构造函数CancelToken,代码如下:

/**
 * A `CancelToken` is an object that can be used to request cancellation of an operation.
 *
 * @class
 * @param {Function} executor The executor function.
 */
function CancelToken(executor) {
  if (typeof executor !== 'function') {
    throw new TypeError('executor must be a function.');
  }
	// 其实就外层保存resolve方法
  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      return;
    }

    token.reason = new Cancel(message);
    // 在这里调用resolve方法,用于改变Promise状态
    resolvePromise(token.reason);
  });
}

OK,对于构造函数CancelToken,它接受一个参数executor,而这个参数其实就是上面提到的executor方法。接着,函数内声明了一个名为resolvePromise的变量。然后就是构造器属性this.promise,既然是构造器属性,那么这里可以预先知道,source方法中得到的token上一定有这个promise属性(上面截图已经展示了token中有个Promise),而这个属性又由一个Promise构造器创建。

new Promise方法做的事情很简单,在内部将resolve赋值给外部变量resolvePromise,我们都知道Promise接受一个callback,而callback内部可以使用resolvereject两个方法,而上述所做的事仅仅是将Promise内部的resolve方法暴露出去,以达到在外层改变Promise状态的目的,比如:

// 外层定义一个变量用于保存promise内部的方法
let resolvePromise;
const p = new Promise((resolve, reject) => {
  resolvePromise = resolve;
})
p.then((res) => {
  console.log(res);// 我在外层改变promise状态
});
setTimeout(() => {
  // 外层调用promise的resolve方法以达到改变promise状态目的
  resolvePromise('我在外层改变promise状态')
}, 3000)

所以简单来说,通过这种做法我们将promise内部方法暴露出来,想在哪用就在哪用,大概如此。

让我们继续回到CancelToken方法,接着我们将this赋予给变量token,不要诧异,我们知道当new一个构造函数时,其实就是在隐式的给this赋值,然后返回这个this,而为了让这种绑定与返回更为可视化,常常有如下的写法:

function Fn(name){
  const that = this;
  that.name = name;
  return that;
}

所以上面算是一种可视化做法,毕竟new CancelToken本身就是返回一个token并赋值给token,也相当于更好理解。

接着,我们执行了executor方法,它的参数又是一个函数,如下:

function cancel(message) {
  if (token.reason) {
    // Cancellation has already been requested
    return;
  }
  token.reason = new Cancel(message);
  // 在这里调用resolve方法,用于改变Promise状态
  resolvePromise(token.reason);
}

同学们,还记得这个executor从哪来吗?它不就是new CancelToken时传递的函数吗:

function executor(c) {
    cancel = c;
}

所以这个c其实就是上面传递给executorcancel方法,而cancel = c这一句,目的跟上面resolve赋值给外层一样,也是将定义在CancelToken内部的cancel暴露出去。

你可能有点混乱了,其实这里一共做了三次方法暴露,第一次我们将resolve暴露出去,目的是让cancel中可以通过resolvePromise来改变Promise状态;而这个cancel也不是在定义的地方使用,这里又做了第二次暴露,将cancel抛出去赋予给了source方法,紧接着source做了第三次方法暴露,当我们开发者调用source将得到canceltoken两个属性。大致流程如下图:

那么到这里我们解释了source方法是如何产生的这两个属性,以及这两个属性分别有什么用。

所以当我们调用接口时,传递了一个source.token其实就是给这次请求绑定一个状态是pendingPromise,它更像是一个开发,一旦我们调用source.cancel就会启动这个开发,告知axios要取消这个请求,怎么取消的呢?

让我们找到node_modules/axios/lib/adapters/xhr.js这个文件,文章开头就说了axios是对于ajax的封装,而ajax又是对于XMLHttpRequest那一套的封装,所以回归本质,在xhr.js中我们可以看到发起请求所有的准备代码都在这里,这里大致贴一点代码并补了注释,我删掉了部分对于本身意义不大的代码:

return new Promise(function dispatchXhrRequest(resolve, reject) {
  // 准备请求附带数据
  var requestData = config.data;
  // 准备请求头
  var requestHeaders = config.headers;
  // 创建xhr对象
  var request = new XMLHttpRequest();

  // 授权相关
  if (config.auth) {
    var username = config.auth.username || '';
    var password = config.auth.password ? unescape(encodeURIComponent(config.auth.password)) : '';
    requestHeaders.Authorization = 'Basic ' + btoa(username + ':' + password);
  }
  // 获取请求地址
  var fullPath = buildFullPath(config.baseURL, config.url);
  // 调用open,发起请求
  request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);

  // 设置超时时间,你期望多久没反应就提示超时那就设置多少
  request.timeout = config.timeout;

  // OK,监听state状态,
  request.onreadystatechange = function handleLoad() {
    if (!request || request.readyState !== 4) {
      return;
    }
    // 准备response数据体
    var responseData = !config.responseType || config.responseType === 'text' ? request.responseText : request.response;
    var response = {
      data: responseData,
      status: request.status,
      statusText: request.statusText,
      headers: responseHeaders,
      config: config,
      request: request
    };

    settle(resolve, reject, response);
    // 一旦请求结束,清空请求
    request = null;
  };

  // 监听abort的处理
  request.onabort = function handleAbort() {
  };

  // 监听error的处理
  request.onerror = function handleError() {
  };

  // 监听超时后的处理
  request.ontimeout = function handleTimeout() {
  };

  // 这里就是看我们有没有给请求附带cancelToken,如果带了就会走这里
  if (config.cancelToken) {
    // 还记得这里的promise知道是哪创建的吗?
    config.cancelToken.promise.then(function onCanceled(cancel) {
      // 两种情况,要么此时没请求,要么请求结束被清空为null,这里就不做处理,直接返回
      if (!request) {
        return;
      }
      // 这里调用了XMLHttpRequest.abort()
      request.abort();
      // 这里修改的是请求体自身promise的状态,而不是我们上文提到的那个promise
      reject(cancel);
      // 清空request,请求对象都不要了
      request = null;
    });
  }
});

大家可以大致看看上述代码,这里我们抽离出最重要的一段:

// 这里就是看我们有没有给请求附带cancelToken,如果带了就会走这里
if (config.cancelToken) {
  // 还记得这里的promise知道是哪创建的吗?
  config.cancelToken.promise.then(function onCanceled(cancel) {
    // 两种情况,要么此时没请求,要么请求结束被清空为null,这里就不做处理,直接返回
    if (!request) {
      return;
    }
    // 这里调用了XMLHttpRequest.abort()
    request.abort();
    // 这里修改的是请求体自身promise的状态,而不是我们上文提到的那个promise
    reject(cancel);
    // 清空request,请求对象都不要了
    request = null;
  });
}

config不用多说,其实就是我们使用axios发起请求时的配置,上文的例子也展示了,你要想取消请求,你就得给请求配置中加一个{ cancelToken: source.token },而这个token中其实就是一个状态为pendingPromise,目的是什么我们也很清楚了。这个Promise就为了是跟当前请求进行绑定,它就像一颗遥控炸弹,而炸弹的开关就是 source.cancel,当我们调用cancel时,就会执行下面这个方法:

function cancel(message) {
  if (token.reason) {
    // Cancellation has already been requested
    return;
  }
  token.reason = new Cancel(message);
  // 启动炸弹
  resolvePromise(token.reason);
}

方法里就干一件事,将跟请求绑定在一起的Promise的状态给改成resolve,一旦修改完成,那是不是就得跑下面这段代码:

config.cancelToken.promise.then(function onCanceled(cancel) {
  // 两种情况,要么此时没请求,要么请求结束被清空为null,这里就不做处理,直接返回
  if (!request) {
    return;
  }
  // 这里调用了XMLHttpRequest.abort()
  request.abort();
  // 这里修改的是请求体自身promise的状态,而不是我们上文提到的那个promise
  reject(cancel);
  // 清空request,请求对象都不要了
  request = null;
});

毕竟你Promise状态变了,那我then就得执行,执行了干什么?request.abort()也就是XMLHttpRequest.abort()取消请求,成功把这个请求给引爆炸掉了!!!

abort干了什么呢?很遗憾,这个就彻彻底底是个黑盒了,是浏览器在帮我们处理,断点跟不进去,但通过MDN我们可以的值,当调用此方法时,如果请求已发出,那么该方法将中止请求。当一个请求被中止时,它 readyState被更改为 XMLHttpRequest.UNSENT(0) 并且请求的 status代码被设置为 0。

肆 ❀ 总

那么到这里,我们完整跟着请求代码把axios执行过程给讲了一遍,你会发现axios在内部其实就是创建一个名为token 的 Promise与一个改变Promise状态的方法cancel,当你需要取消一个请求时,那就把这个Promise与请求绑在一起,然后便可通过cancel改变请求上的Promise状态,从而达到取消请求的目的,不得不说这个三方库的作者那是真的牛妈妈给牛宝宝开门,牛到家了!!

另外,abort原生做法我测试发现若请求发出,是会取消请求,所以可以看到chrome控制台有红色的被取消的请求,而axios直接没请求,猜测应该是axios内部做了特殊处理,或者是我请求被取消的太快,导致根本没发出,至于这点我就没再去研究了。

var xhr = new XMLHttpRequest(),
    method = "GET",
    url = "https://developer.mozilla.org/";
xhr.open(method, url, true);

xhr.send();

if (true) {
  xhr.abort();
}

结合前面文章,我们就可以在简历中写通过axios.cancelToken解决XXX问题,若面试问你这个取消是怎么实现的。那真是不好意思,你算是撞到咱们的枪口上了,又可以愉快的跟面试官battle一番。OK,那么到这里,本文正式结束!!2点了!!睡觉!!

原文地址:https://www.cnblogs.com/echolun/p/15204279.html