axios CancelToken 取消頻繁傳送請求的用法和原始碼解析

李一楓發表於2020-02-04

前言

做一個Vue的專案時,遇到頻繁切換標籤的問題。由於不同標籤請求的ajax的結果所需時間不同,點選不同標籤時,響應時間最慢的資料會覆蓋之前響應的資料,顯示資料跟所點選標籤不對應。當時為了處理這個問題,沒想到好方法,只好控制在點選下一個標籤前,必須等前一個標籤的結果回來之後進行。

後來做API的統一管理時,看到前人寫的axios的interceptor裡有CancelToken這樣一個東西,查了查資料,發現這個可以取消請求,踏破鐵鞋無覓處,剛好可以用來處理之前遇到的頻繁切換標籤的問題。今作一記錄,也好更好的理解這個功能。

述求

點選標籤時,取消之前正在執行的請求,使得切換標籤時,頁面得到的是最後請求的結果,而不是響應最慢的結果。

用法

官方案例

  1. 使用 CancelToken.source 工廠方法建立 cancel token,像這樣:
    // CancelToken是一個建構函式,用於建立一個cancelToken例項物件
    // cancelToken例項物件包含了一個promise屬性,值為可以觸發取消請求的一個promise
    const CancelToken = axios.CancelToken;
    // 執行source()得到的是一個包含了cancelToken物件和一個取消函式cancel()的物件
    // 即 source = {token: cancelToken物件, cancel: 取消函式}
    const source = CancelToken.source();
    
    // 在請求的配置中配置cancelToken,那麼這個請求就有了可以取消請求的功能
    axios.get('/user/12345', {
      cancelToken: source.token
    }).catch(function(thrown) {
      if (axios.isCancel(thrown)) {
        console.log('Request canceled', thrown.message);
      } else {
         // 處理錯誤
      }
    });
    
    axios.post('/user/12345', {
      name: 'new name'
    }, {
      cancelToken: source.token
    })
    
    // 執行取消請求(message 引數是可選的)
    source.cancel('Operation canceled by the user.');
    複製程式碼
  2. 通過傳遞一個 executor 函式到 CancelToken 的建構函式來建立 cancel token
    const CancelToken = axios.CancelToken;
    let cancel;
    
    axios.get('/user/12345', {
      cancelToken: new CancelToken(function executor(c) {
        // executor 函式接收一個 cancel 函式作為引數
        // 把cancel函式傳遞給外面,使得外面能控制執行取消請求
        cancel = c;
      })
    });
    
    // cancel the request
    cancel();
    複製程式碼

看起來有些暈,畢竟不知道里面是怎麼運作的,稍後通過原始碼解析。這裡簡單解釋就是,在請求中配置cancelToken這個屬性,是為了使得請求具有可以取消的功能;cancelToken屬性的值是一個CancelToken例項物件,在它的executor函式中提取出cancel函式,執行這個cancel函式來取消請求。

我的例項

點選標籤,執行getCourse函式。點選某個標籤時,先取消之前的請求(如果之前的請求已完成,取消請求不會有任何操作)。效果是,頁面顯示的總是最後點選的標籤對應的結果。

分兩步,第一步,在get請求中配置cancelToken屬性,開啟取消請求的功能,且在其屬性值中將cancel函式賦給cancelRequest,使得外部可以呼叫cancel函式來取消請求;第二步,在執行請求前,先取消前一次的請求。

import axios from 'axios'
export default {
    data() {
        return {
            cancelRequest: null // 初始時沒有請求需要取消,設為null
        }
    },
    methods: {
        // 點選標籤後傳送請求的函式
        getCourse() {
            const that = this
            // 2. 準備執行新的請求前,先將前一個請求取消
            // 如果前一個請求執行完了,執行取消請求不會有其他操作
            if (typeof that.cancelRequest === 'function') {
                that.cancelRequest()
            }
           // 這裡配置請求的引數,略
           let params = {}
           // 傳送請求
           axios.get('/api/app/course',{
               params: params,
               cancelToken: new CancelToken(function executor(c) {
                    // 1. cancel函式賦值給cancelRequest屬性
                    // 從而可以通過cancelRequest執行取消請求的操作
                    that.cancelRequest = c
              }) 
           })
        }
    }
}

複製程式碼

一般API都會統一封裝,所以,可以將請求封裝起來

API

// /api/modules/course.js
// _this為vue元件例項物件
export function getCourseReq(params, _this) {
    return axios.get('/api/app/course',{
           params: params,
           cancelToken: new CancelToken(function executor(c) {
                // 1. cancel函式賦值給cancelRequest屬性
                // 從而可以通過cancelRequest執行取消請求的操作
                _this.cancelRequest = c
          }) 
       })
       .then(res => {})
       .catch(err => {})
}
複製程式碼

元件

import { getCourseReq } from '@/apis/modules/course'

methods: {
    getCourse() {
        // 2. 準備執行新的請求前,先將前一個請求取消
        // 如果前一個請求執行完了,執行取消請求不會有其他操作
        if (typeof this.cancelRequest === 'function') {
            this.cancelRequest()
        }
       // 這裡配置請求的引數,略
       let params = {}
       // 傳送請求
       getCourseReq(params, this)
           .then(res => {})
           .catch(err => {})
    }
}
複製程式碼

遇到的坑

一開始按照上述方法寫好,但請求死活沒有取消。一遍遍核對了變數名,除錯輸出資訊,啥都對,就是沒法取消。折騰半天,想起前人配置的axios的interceptor裡對每個請求配置了cancelToken,目的是為了去掉重複的請求,但取消重複請求的程式碼段被註釋掉了。把cancelToken註釋掉之後,終於雨過天晴。

axios.interceptors.request.use(
  config => {
    const request =
      JSON.stringify(config.url) +
      JSON.stringify(config.method) +
      JSON.stringify(config.data || '')
      // 這裡配置了cancelToken屬性,覆蓋了原請求中的cancelToken
     config.cancelToken = new CancelToken(cancel => {
       sources[request] = cancel
     })
    // if (requestList.includes(request)) {
    //   sources[request]('取消重複請求')
    // } else {
    requestList.push(request)
    return config
  },
  error => {
    return Promise.reject(error)
  }
)
複製程式碼

由於interceptor會在請求傳送前做一些配置處理,這裡把原請求中的cancelToken覆蓋了,那麼即使原請求中執行原cancelTokencancel函式,由於cancelToken物件不同了,取消操作也就無效了。後面看了原始碼可以更明白為什麼無效。

原始碼解析

根據前面的步驟,依次來看看各個原始碼是怎樣。

首先,我們為請求配置cancelToken屬性,目的是使得請求具有能取消請求的功能,它的值是CancelToken例項物件,那麼CancelToken是什麼呢?

// axios/lib/cancel/CancelToken.js

'use strict';

var Cancel = require('./Cancel');

function CancelToken(executor) {
    if (typeof executor !== 'function') {
        throw new TypeError('executor must be a function.');
    }
    /**
    * 定義一個將來能執行取消請求的promise物件,當這個promise的狀態為完成時(fullfilled),
    * 就會觸發取消請求的操作(執行then函式)。而執行resolve就能將promise的狀態置為完成狀態。
    * 這裡把resolve賦值給resolvePromise,就是為了在這個promise外能執行resolve而改變這個promise的狀態
    * 注意這個promise物件被賦值給CancelToken例項的屬性promise,將來定義then函式就是通過這個屬性得到promise
    */
    var resolvePromise;
    this.promise = new Promise(function promiseExecutor(resolve) {
        resolvePromise = resolve;
    });
    /**
    * 將CancelToken例項賦值給token
    * 執行executor函式,將cancel方法傳入executor,
    * cancel方法可呼叫resolvePromise方法,即觸發取消請求的操作
    */
    var token = this;
    executor(function cancel(message) {
        if (token.reason) {
            // 取消已響應 返回
            return;
        }
        token.reason = new Cancel(message);
        // 這裡執行的就是promise的resolve方法,改變狀態
        resolvePromise(token.reason);
  });
}

CancelToken.prototype.throwIfRequested = function throwIfRequested() {
    if (this.reason) {
        throw this.reason;
    }
};

// 這裡可以看清楚source函式的真面目
CancelToken.source = function source() {
    var cancel;
    var token = new CancelToken(function executor(c) {
        // c 就是CancelToken中給executor傳入的cancel方法
        cancel = c;
    });
    return {
        token: token,
        cancel: cancel
    };
};

module.exports = CancelToken;
複製程式碼

CancelToken

CancelToken是一個建構函式,通過new CancelToken()得到的是一個例項物件,它只有一個屬性promise, 它的值是一個能觸發取消請求的Promise物件。

token = new CancelToken(executor function) ===> { promise: Promise物件 }

執行CancelToken函式做了兩件事:

  1. 建立一個Promise物件,且將這個物件賦值給promise屬性,其resolve引數被暴露出來以備cancel函式執行。
  2. 執行executor函式,將內部定義的cancel函式作為引數傳遞給executor
    // 原始碼相當於:
    var token = this;
    var cancel = function (message) {
        if (token.reason) {
            // 取消動作已響應 返回
            return;
        }
        token.reason = new Cancel(message);
        // 這裡執行的就是promise的resolve方法,改變狀態
        resolvePromise(token.reason);
    }
    executor(cancel);
    複製程式碼

所以執行

let cancel
token = new CancelToken(function executor(c) {
    cancel = c;
});
複製程式碼

得到結果是:

  • token值為{promise: Promise物件}
  • executor函式被執行,即cancel = c執行,因此變數cancel被賦值了,值就是CanelToken內部的那個cancel函式。

題外話,發現Promise其實也是傳入executor,跟執行new Promise(executor)是一樣的

CancelToken.source

CancelToken.source是一個函式,通過原始碼可以看到,執行const source = CancelToken.source(),得到的是一個物件:

return {
    token: token,
    cancel: cancel
};
複製程式碼

包含一個token物件,即CancelToken例項物件,和一個cancel函式。因此CancelToken.source()函式的作用是生成token物件和取得cancel函式。token物件是用於配置給axios請求中的cancelToken屬性,它包含了promise物件,cancel函式是將來觸發取消請求的函式。

token和cancel的關係是,token裡存放了取消請求的promise,cancel裡存放了將promise的狀態改變的resolve函式,一個是執行體,一個是開關。開關觸動,執行體的狀態就改變,其相應的then函式就執行。他們就是通過promise的狀態特性進行關聯的。

如何執行取消請求的

萬事俱備,就差如何執行取消請求了。這也是我一開始最疑惑的地方,一起來看看吧。下面是開始發起請求的原始碼:

// axios/lib/adapters/xhr.js

// 建立XHR物件
var request = new XMLHttpRequest()
// 模擬當前ajax請求
request.open(config.method.toUpperCase(), buildURL(config.url, config.params, config.paramsSerializer), true)

// 定義取消請求promise物件的then方法
if (config.cancelToken) { // 如果配置了cancelToken屬性
    // 當promise為完成態時,這個then函式執行,即執行取消請求
    config.cancelToken.promise.then(function onCanceled(cancel) {
        if (!request) {
            return;
        }
        // 取消ajax請求
        request.abort();
        reject(cancel);
        // Clean up request
        request = null;
    });
}
複製程式碼

這是最終執行ajax請求的地方。

我們前面配置了cancelToken = new CancelToken(executor),得到這個物件

cancelToken = {promise: 取消請求的Promise物件}
複製程式碼

因此上面cancelToken.promise就是這個promise

我們知道,Promisethen函式是在Promise物件處於fullfilled時執行,這就是暗中的關聯。執行cancel函式,會將Promise的狀態置為fullfilled,這裡定義的then函式就會執行,從而取消請求。

攔截器問題

之前遇到的問題,攔截器中配置的cancelToken覆蓋了請求中的cancelToken,導致請求失效。原因是,攔截器重新定義後,cancelToken屬性值變成了新的token,cancelToken.promise也就變成了新的promisethen函式是在攔截器之後、真正傳送請求之前定義的,因此這時定義的then函式,是對應這個新的Promise物件的,於是原來token的promise並沒有then函式,那麼執行原cancel函式把原Promise物件置為了fullfilled狀態,但沒有相應的then函式執行,有人發訊號,沒人執行,取消請求無效。

axios流程:定義請求 -> 請求攔截器 -> 傳送請求 -> 響應攔截器 -> 接收響應

後語

一入原始碼深似海,看完真是受益匪淺,這裡面的一些思想、做法,多可以學習借鑑。

相關文章