透过 CancelToken 解析 Axios 原始码

本篇会藉由设计「取消重複请求机制」来解析 axios 的原始码,篇幅较长请耐心阅读。

其实要实践取消请求的功能并不会很难,官方也有一目了然的 教学,不过我自己在实作后一直对于 cancelToken 的原理耿耿于怀,就去研究了一下原始码,所以在实际撰写之前,想先分享一下我的理解。

接下来我们会直接看打包过的档案: axios/dist/axios.js,所有 axios 的程式码都在这。你可以一边看 github 一边看文章。


为什么需要取消请求

cancelToken 可以为我们取消多余或不必要的 http请求,虽然在一般情况下可能感觉不到有取消请求的必要,不过在一些特殊情况中没有好好处理的话,可能会导致一些问题发生。像是...

SPA 在快速的切路由时,使得上个页面的请求在新页面完成。Pending 时间较久的 API 若短时间内重複请求,会有旧盖新的情况。重复的 post 请求,有可能导致多次的资料操作,例如表单发送两次。

发送请求与拦截器

# Class Axios

先从最主要的 Axios类别 看起,每一个 axios 应用都会创建一个 Axios类别,而当中最核心的就是 request 方法,不过我们先暂时跳过。
后面两段则是在类别上又新增了好几个方法,让我们可以发起不同的http请求: axios.get()axios.post()
不过仔细一看会发现,最终我们呼叫的还是 request,所以才会说 request 是 axios 的核心。

function Axios(instanceConfig) {  this.defaults = instanceConfig;  this.interceptors = {    request: new InterceptorManager(),    response: new InterceptorManager()  };}Axios.prototype.request = function request(config) {  // ...先跳过};// 帮不同的请求方法创建别名,最终都是呼叫requestutils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {  Axios.prototype[method] = function(url, config) {    return this.request(utils.merge(config || {}, {      method: method,      url: url    }));  };});utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {  Axios.prototype[method] = function(url, data, config) {    return this.request(utils.merge(config || {}, {      method: method,      url: url,      data: data    }));  };});

# Class InterceptorManager

在前面我们有看到,Axios类别 中有个 interceptors 属性,其值为物件,并且有 requestresponse 的属性。
这两个属性都是 InterceptorManager类别,而这个类别是用来管理拦截器的,我之也写过 一篇 在介绍拦截器是什么,不晓得的人可以去看一下。

而今天我们就是要用Axios的拦截器来达到取消重複请求的功能,所以来看看 InterceptorManager 吧。

function InterceptorManager() {  // 储存拦截器的方法,未来阵列里会放入物件,每个物件会有两个属性分别对应成功和失败后的函式  this.handlers = [];}// 在拦截器里新增一组函式,我们在上一篇有用过InterceptorManager.prototype.use = function use(fulfilled, rejected) {  this.handlers.push({    fulfilled: fulfilled,    rejected: rejected  });  return this.handlers.length - 1;};// 注销拦截器里的某一组函式InterceptorManager.prototype.eject = function eject(id) {  if (this.handlers[id]) {    this.handlers[id] = null;  }};// 原码的写法我觉得很容易看不懂,所以我改写了一下// 简单来说就是拿handlers跑迴圈,把里面的物件当作参数来给fn执行InterceptorManager.prototype.forEach = function(fn) {  this.handlers.forEach(obj => {    fn(h);  });};

基本上这个类别还蛮单纯的,主要就是三个操作 handlers 的方法,我们之前就是透过 axios.interceptors.request.useaxios.interceptors.response.use 来添加拦截器的。

但现在我们要再更深入了解Axios是怎么在请求前后透过拦截器处理 requestresponse 的,这时候就要回去看 Axios.prototype.request 了。

# Axios.prototype.request

可以发现,每当我们发送请求 Axios.prototype.request 会宣告一个阵列以及一个Promise物件。
并且利用 InterceptorManager.prototype.forEach 把我们拦截器中新增的函式一一放进 chain 中。
至于 dispatchRequest 就是Axios主要发送 XMLHttpRequest 的函式,我们等等会提到。

当所有函式都放进 chain 后再两两一组拿出来作为 promise.then() 的参数,而且利用Promise的链式呼叫来串接。
最后我们的请求就可以依照 request拦截器 -> dispatchRequest -> response拦截器 的顺序进行处理。

Axios.prototype.request = function request(config) {  //..省略  var chain = [dispatchRequest, undefined];  // 定义一个状态是resolve的Promise; config是发出请求时带的设定  var promise = Promise.resolve(config);  // InterceptorManager.prototype.forEach,把request拦截器的每一组函式「往前」加进chain里  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {    chain.unshift(interceptor.fulfilled, interceptor.rejected);  });    // InterceptorManager.prototype.forEach,把response拦截器的每一组函式「往后」加进chain里  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {    chain.push(interceptor.fulfilled, interceptor.rejected);  });  // 全部加进去后,chain会长的像是这样: [  //   request.handlers[0].fulfilled, request.handlers[0].rejected, ...,   //   dispatchRequest, undefined,  //   response.handlers[0].fulfilled, response.handlers[0].rejected, ...,  // ]  // 只要chain里还有项目,就继续执行  while (chain.length) {    promise = promise.then(chain.shift(), chain.shift());  }  return promise;};

最后把所有的函数串接起来后,promise 会像是下面这样,并且 Axios.prototype.request 会把这个 promise 返回出来,所以我们才可以在呼叫 axios.get() 之后直接用 then()

Promise.resolve(config)  .then(requestFulfilled, requestRejected)  .then(dispatchRequest, undefined)  .then(responseFulfilled, responseRejected)
这个 Promise 已经是 resolve 状态,所以请求拦截器会拿到 config 来做前置处理。官方文件有规定,添加请求拦截器的时候,fulfilled函式最后要返回 config,所以 dispatchRequest 才能拿到 config 来发送请求。dispatchRequest 在完成 XMLHttpRequest 后会返回请求的 response 给回应拦截器。官方文件一样有规定回应拦截器的fulfilled函式最后要返回 response,所以你最后才可以拿到API资料。

# Function dispatchRequest

现在知道了拦截器是如何串接的了,那 dispatchRequest 是如何发送http请求的呢?
我们只看重点部分,当中 adapter 会根据发送请求的环境对应到不同的适配器(建立请求的函式),而 dispatchRequest 会再以 then() 串接,由http请求的成功或失败来决定要进入回应拦截器的 fulfilled 函式或 rejected 函式。

module.exports = function dispatchRequest(config) {  // 检查请求是否被取消的函式  throwIfCancellationRequested(config);  // axios会使用预设的http请求适配器,除非你有特别设定  // 以浏览器发送请求会使用xhrAdapter,node环境则使用httpAdapter  var adapter = config.adapter || defaults.adapter;  // 适配器会把http请求包装成Promise并返回,dispatchRequest再以then()串接  return adapter(config).then(    // 若请求成功dispatchRequest会返回response给回应拦截器的fulfilled函式    function onAdapterResolution(response) {      throwIfCancellationRequested(config);      return response;    },    // 反之则将错误抛给回应拦截器的rejected函式    function onAdapterRejection(reason) {      if (!isCancel(reason)) throwIfCancellationRequested(config);      return Promise.reject(reason);    }  );}

另外可以看到 throwIfCancellationRequested 不断的出现,这个函式会检查请求是否已经被「要求」取消,等我们进入到 CancelToken 时会再提到它。

# Function xhrAdapter

由于我们是以浏览器发送请求,所以这边以 xhrAdapter 适配器为主。(完整程式码)
xhrAdapter 整段很长,但如果只看重点,其实就是在发送 XMLHttpRequest,并在过程中做一些判断来决定要 resolvereject 这个 Promise

module.exports = function xhrAdapter(config) {  return new Promise(function dispatchXhrRequest(resolve, reject) {    // 建立一个新的XMLHttpRequest    var request = new XMLHttpRequest();    // 监听readyState的变化    request.onreadystatechange = function handleLoad() {      // readyState === 4 代表请求完成      if (!request || request.readyState !== 4) return;      // 若请求完成,準备好回应的response      var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null;      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(response),反之reject(error)      settle(resolve, reject, response);      request = null;    };    // 发送XMLHttpRequest    request.send(requestData);  });};

到目前为止我们已经知道 axios 处理请求的流程,接下来就进入本文的重点 - CancelToken。

我把整个架构图像化,希望对各位有帮助。
http://img2.58codes.com/2024/20125431NYKji5FcRx.png


CancelToken

# 基本用法

在看原始码前,我们先看看 CancelToken 是怎么使用的。
这段程式做了什么可以先不管,我们只要知道,如果要使用 CancelToken 就必须在 requestconfig 中新增一个 cancelToken 属性。

let cancelaxios.get('/user/12345', {  cancelToken: new axios.CancelToken(c => { cancel = c; })});cancel()

# Class CancelToken

再来就该看看我们在 cancelToken 属性中建构的 CancelToken类别 是什么。

首先,每一个 CancelToken 都会建立一个 Promise,并且将 resolve 主动权给拿了出来,定义给resolvePromise。再者,当我们要建构一个 CancelToken 的时候必须传入一个 function,它会直接被呼叫并且得到一个名为 cancel 的函式作为参数。

当要取消请求就是呼叫 cancel,而它做了两件事情: 1. 赋值给属性 reason 2. 将属性 promiseresolve

function CancelToken(executor) {  // 判断executor是否为function  if (typeof executor !== 'function') {    throw new TypeError('executor must be a function.');  }  // 建立一个新的Promise物件,并将其resolve函式赋予给变数resolvePromise  // 此时Promise会是pending状态,还未被resolve  var resolvePromise;  this.promise = new Promise(function promiseExecutor(resolve) {    resolvePromise = resolve;  });  // 执行executor,并以函式「cancel」作为参数带入  var token = this;  executor(function cancel(message) {    // 确认reason是否存在,若存在代表cancel已被执行过    if (token.reason) return;        // 将reason赋值为一个Cancel类别    token.reason = new Cancel(message);    // resolve Promise    resolvePromise(token.reason);  });}// 确认reason是否存在,若存在代表此CancelToken的cancel已被执行过,便抛出错误CancelToken.prototype.throwIfRequested = function throwIfRequested() {  if (this.reason) throw this.reason;};

所以 axios 只要根据这两个属性,就能判断此次请求是否已经被取消,而 throwIfRequested 就是利用 reason 来判断是否要抛出错误。

# throwIfCancellationRequested

还记得我们在 dispatchRequest 里有看到 throwIfCancellationRequested 不断的被呼叫吗?(请看章节 #Function-dispatchRequest)
它的作用就是判断 config 是否有被加上 cancelToken 属性,有的话就会呼叫 CancelToken.prototype.throwIfRequested,以此来判断请求是否已被取消。

function throwIfCancellationRequested(config) {  if (config.cancelToken) config.cancelToken.throwIfRequested();}

# Function xhrAdapter

没错,又再次看到了 xhrAdapter,因为在前面我暂时省略了 xhrAdapter 内部的一个判断。
当它发现 config.cancelToken 存在,便会为 CancelToken.promise 接上一个 then(),意味着当 promiseresolve 的那一刻,请求就会被 abort

module.exports = function xhrAdapter(config) {  return new Promise(function dispatchXhrRequest(resolve, reject) {    var request = new XMLHttpRequest();    // ...省略....    if (config.cancelToken) {      // cancelToken.promise要被resolve才会执行then      // onCanceled(cancel)中的cancel会是cancelToken.reason      config.cancelToken.promise.then(function onCanceled(cancel) {        if (!request) return;        // 取消XMLHttpRequest        request.abort();        reject(cancel);        request = null;      });    }    request.send(requestData);  });};

# 重点整理

首先我们可以知道 CancelToken 的原理就是在 request config 中加上一个 CancelToken类别,并且利用其类别属性来判断 cancel 函式是否被呼叫执行,若已执行代表该请求被「要求」取消。

另外可以发现 axios 在以下三个时机点都有检查请求的取消与否:

请求发送前 - [dispatchRequest开头]请求发送中 - [xhrAdapterq]请求发送后 - [dispatchRequest.then]

实际运用

了解整个 axios 架构以及 CancelToken 后,终于可以来实践取消请求的功能了,先来釐清我们的需求。

每次发送请求要判断是否已经存在相同的请求,若存在就取消前一次请求,只保留最新的

根据这样的需求我们归纳出几个必要的关键,然后準备以下程式码

为了要能取消请求,必须设定 config.cancelToken为了要判断重複的请求,要把每次请求记录在暂存中在请求完成或被取消时从暂存中移除
// 暂存:纪录执行中的请求const pending = new Map();const addPending = config => {  // 利用method和url来当作这次请求的key,一样的请求就会有相同的key  const key = [config.method, config.url].join("&");  // 为config添加cancelToken属性  config.cancelToken = new axios.CancelToken(cancel => {    // 确认暂存中没有相同的key后,把这次请求的cancel函式存起来    if (!pending.has(key)) pending.set(key, cancel);  });};const removePending = config => {  // 利用method和url来当作这次请求的key,一样的请求就会有相同的key  const key = [config.method, config.url].join("&");  // 如果暂存中有相同的key,把先前存起来的cancel函式拿出来执行,并且从暂存中移除  if (pending.has(key)) {    const cancel = pending.get(key);    cancel(key);    pending.delete(key);  }};

準备就绪后,只要在请求拦截与回应拦截器中呼叫它们即可...

// request 拦截器instance.interceptors.request.use(  config => {    // 先判断是否有重複的请求要取消    removePending(config);    // 把这次请求加入暂存    addPending(config);    return config;  },  error => {    return Promise.reject(error);  });// response 拦截器instance.interceptors.response.use(  response => {    // 请求被完成,从暂存中移除    removePending(response);    return response;  },  error => {    return Promise.reject(error);  });

从此我们不必再担心 API 在回应前被重複触发导致错误,因为我们永远只会保留最新一次的请求。


关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章