透过 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) {
  // ...先跳过
};

// 帮不同的请求方法创建别名,最终都是呼叫request
utils.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。

我把整个架构图像化,希望对各位有帮助。
https://ithelp.ithome.com.tw/upload/images/20210504/20125431NYKji5FcRx.png


CancelToken

# 基本用法

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

let cancel

axios.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 後,终於可以来实践取消请求的功能了,先来厘清我们的需求。

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

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

  1. 为了要能取消请求,必须设定 config.cancelToken
  2. 为了要判断重复的请求,要把每次请求记录在暂存中
  3. 在请求完成或被取消时从暂存中移除
// 暂存:纪录执行中的请求
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 在回应前被重复触发导致错误,因为我们永远只会保留最新一次的请求。


<<:  安全框架和成熟度模型(Security Frameworks and Maturity Models)

>>:  HTTP Token 使用方式: Basic Token v.s Bearer Token

Node.js介绍

Vue.js是一个前端框架,前端是由HTML、CSS和JavaScript三大元素所组成,所以我们前...

iOS APP 开发 OC 第二十天,自动释放池

tags: OC 30 day 自动释放池的原理 存入到自动释放池中的对象,在自动释放池被销毁的时候...

[Day22] JavaScript - Fetch API

第18篇有提到过Fetch的用法(连结),这篇要实际使用Fetch来做简单的Api串接。 首先介绍一...

Day45 ( 电子元件 ) 水果钢琴 ( 类比讯号 )

水果钢琴 ( 类比讯号 ) 教学原文参考:水果钢琴 ( 类比讯号 ) 这篇文章会介绍如何使用「引脚设...

Day 9 任务的形式

今天想发ARM的文章时,居然一直遇到这个画面: 虽然不确定是不是被攻击了,但後来还好可以连上主页了,...