ÿÿ深入淺出 axios(三):axios 內部 Promise å°Žè‡´è«‹æ±‚å»¶é² | Alex Liu

深入淺出 axios(三):axios 內部 Promise 導致請求延é²

• 13 min read

axios 在 GitHub ä¸Šæœ‰è¶…éŽ 88.9k çš„æ˜Ÿæ˜Ÿï¼Œæ“æœ‰éžå¸¸å¤§é‡çš„使用社群,也有無數的開æºè²¢ç»è€…,但 axios å°±å› æ­¤ç„¡æ‡ˆå¯æ“Šäº†å—Žï¼Ÿæœ¬ç¯‡å°‡åˆ†äº«åœ¨ä»Šå¹´å¹´åˆä¸€å€‹ç­†è€…éžå¸¸é—œæ³¨çš„çš„ PR 內容,而者個 PR ä¹Ÿè¢«æ­£å¼æŽ¡ç´ä¸¦ç™¼å¸ƒåœ¨ axios v0.21.2 當中,就讓我們一起看下去å§ï¼

å‰è¨€

本篇的 axios 版本為 v0.21.2ï¼Œä½†æˆªè‡³ç›®å‰æœ€æ–°ç‰ˆæœ¬ç‚º v0.24.0

閱讀本篇å‰å»ºè­°å¯ä»¥å…ˆçœ‹éŽä¸‹é¢é€™å…©ç¯‡

在éŽåŽ»ç ”ç©¶ axios 原始碼時翻閱了許多的 PRs 與 issues,經由這些紀錄當中的討論了解了 axios 一步一步演變ã€ç´¯ç©è‡³ä»Šçš„原因與éŽç¨‹ã€‚

讀 issues çš„éŽç¨‹ä¸­ç™¼ç¾äº†ä¸€å€‹ä»¤æˆ‘相當感興趣的一個標題:「Requests unexpectedly delayed due to an axios internal promiseã€ã€‚大æ„就是本文作者發ç¾ä»–é€éŽ axios 發出的請求總是會比使用原生的 XMLHttpRequest é‚„è¦æ™šè¢«é€å‡ºï¼Œå¹³å‡æœƒè¢«å»¶é² 200 - 300 毫秒。

這尿–¼ SPA ç¶²é å¯èƒ½æœƒæœ‰ä¸å°çš„影響。請求延é²é€å‡ºå¯èƒ½å°Žè‡´ç•«é¢è¼ƒæ™šè¢«æ¸²æŸ“出來,造æˆè¼ƒå·®çš„使用者體驗。但到底發生了什麼事情呢?我們先來看看 axios åšäº†ä»€éº¼ã€‚

axios åšäº†ä»€éº¼

我們來簡單回顧一下之å‰çœ‹éŽçš„ axios 的原始碼:

XMLHttpRequset 請求的核心

// 以下內容簡略超多æ±è¥¿

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    var requestData = config.data;
    var requestHeaders = config.headers;

    var request = new XMLHttpRequest();

    request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);

    // Listen for ready state
    request.onreadystatechange = function handleLoad() {
      if (!request || request.readyState !== 4) {
        return;
      }

      // é€™è£¡æœƒè™•ç† resolve 或是 reject
      settle(resolve, reject, response);

      // Clean up request
      request = null;
    };

    // Send the request
    request.send(requestData);
  });
};

串連攔截器(Interceptors)並執行請求

// 以下內容簡略超多æ±è¥¿

Axios.prototype.request = function request(config) {
  // Hook up interceptors middleware
  var chain = [dispatchRequest, undefined];
  var promise = Promise.resolve(config);

  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    chain.push(interceptor.fulfilled, interceptor.rejected);
  });

  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }

  return promise;
};

é€éŽä¸Šé¢å…©æ®µæˆ‘們å¯ä»¥çŸ¥é“,在éŽåŽ»çš„ axios 實作上有兩個地方會使用到éžåŒæ­¥ï¼Œå…¶ä¸€æ˜¯ XMLHttpRequest,å¦å¤–一個是將攔截器(Interceptors)與核心請求串連起來的執行。

åˆ†æž axios 造æˆè«‹æ±‚å»¶é²çš„原因

為什麼é€éŽ axios 發出請求會導致延é²å‘¢ï¼Ÿæˆ‘們å¯ä»¥çœ‹åˆ°ä¸²é€£æ””截器的部分,在第一篇 有æåˆ°ï¼Œä¸²é€£å®Œæ””截器的 axios 請求後如下:

Promise.resolve(config)
  /**
   * 請求攔截器
   * 發出請求å‰ä¸€å€‹ä¸€å€‹åŸ·è¡Œ
   */
  .then(requestFulfilled, requestRejected)
  /**
   * 發出請求
   */
  .then(dispatchRequest, undefined)
  /**
   * 回應攔截器
   * 接收到回應後一個一個執行 
   */
  .then(responseFulfilled, responseFulfilled)

我們試著把上é¢çš„程å¼ç¢¼å†å¯«æˆä»¥ä¸‹ï¼Œä¸¦æ”¾ä¸Šäº†å¹¾å€‹ log 來輔助ç†è§£

function axios () {
  return Promise.resolve("res")
 .then((res) => {
    // request 攔截器
  console.log("then 1")
  return res + " => then"
 })
 .then((res) => {
    // 發出請求
    console.log("then 2")
  return new Promise((resolve) => resolve(res + " => Promise"))
 })
 .then((res) => {
    // response 攔截器
    console.log("then 3")
  return res + " => then"
 });
}

axios()
console.log("run")

上é¢çš„程å¼ç¢¼æ¯«ç„¡ç–‘啿œ€å…ˆè¢«å°å‡ºä¾†çš„æœƒæ˜¯ runã€‚æŽ¥è‘—æ‰æœƒæ˜¯ then 1ã€then 2 等。這是因為傳入 Promise.prototype.then() çš„ callback 會被丟掉工作佇列(Task Queue)中,JavaScript 會先把執行堆疊(Call Stackï¼‰å…¨éƒ¨åŸ·è¡Œå®Œæ‰æŠŠè¢«ä¸Ÿåˆ°å·¥ä½œä½‡åˆ—ï¼ˆTask Queueï¼‰çš„ç¨‹å¼æŠ“å‡ºä¾†åŸ·è¡Œã€‚å¦‚æžœåŸ·è¡Œå †ç–Šï¼ˆCall Stack)阻塞,阻塞了 200 毫秒,那工作佇列(Task Queue)就會等到這 200 毫秒éŽäº†æ‰æœ‰æ©ŸæœƒåŸ·è¡Œåˆ°ã€‚

æ‰€ä»¥æ‰¾åˆ°åŽŸå› äº†ï¼æ˜¯ Promise.prototype.then() 導致請求的延é²ã€‚

解決 axios å»¶é²è«‹æ±‚çš„å•題

在原本的設計下,æ¯ä¸€å€‹æ””截器都å¯ä»¥åšéžåŒæ­¥çš„請求,並修改傳入的 config å†å‚³çµ¦ä¸‹ä¸€å€‹æ””截器處ç†ï¼Œæœ€å¾Œäº¤çµ¦ dispatchRequest,收到回應後å†ç¶“由攔截器傳出響應。

其實我們並ä¸é »ç¹åœ¨æ””截器中呼å«ä¸¦ç­‰å¾…éžåŒæ­¥ï¼Œç”šè‡³å¤§å¤šæ•¸æ™‚候䏿œƒï¼Œå› æ­¤åœ¨ä½¿ç”¨è€…æ²’æœ‰ä½¿ç”¨è«‹æ±‚æ””æˆªå™¨ï¼Œæˆ–æ˜¯è«‹æ±‚æ””æˆªå™¨åŸ·è¡Œçš„éƒ½æ˜¯åŒæ­¥ç¨‹å¼æ™‚ï¼Œæ‡‰è©²æœ‰æ›´å¥½çš„é¸æ“‡ï¼Œæå‡ºé€™å€‹å•題的開發者想到了下列方法解決:

首先調整了攔截器類型設計。

/**
 * Add a new interceptor to the stack
 *
 * @param {Function} fulfilled The function to handle `then` for a `Promise`
 * @param {Function} rejected The function to handle `reject` for a `Promise`
 *
 * @return {Number} An ID used to remove interceptor later
 */
InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected,
    synchronous: options ? options.synchronous : false,
  });
  return this.handlers.length - 1;
};

å°ç…§ä¸€ä¸‹åŽŸæœ¬æ–°å¢žæ””æˆªå™¨è¦å‚³å…¥çš„åƒæ•¸ï¼Œå¤šäº†ä¸€å€‹å¯é¸çš„ options 物件, 其中當物件的屬性 synchronous 為 true å‰‡æ””æˆªå™¨è¢«è¦–ç‚ºåŒæ­¥ function,若為 false 則以éžåŒæ­¥è™•ç†ï¼Œé è¨­ç‚º false。

é è¨­ç‚º false 是為了å‘下兼容,在ç¾è¡Œæ•¸ä»¥åƒè¨ˆã€è¬è¨ˆçš„專案中,已有超多專案已經在使用é è¨­ç‚ºéžåŒæ­¥è™•ç†çš„æ””截器,如果這裡這裡的 synchronous é¸ç”¨é è¨­ç‚º true,這樣這些ç¾è¡Œå°ˆæ¡ˆä¸€ä½†é€²è¡Œå‡ç´šå°±å¾ˆå®¹æ˜“發生æ„外。

有了上é¢çš„設定,在 Axios.prototype.request() 在執行攔截器串接時,就å¯ä»¥ä¾ç…§æ˜¯å¦æœ‰éœ€è¦åŸ·è¡ŒéžåŒæ­¥æ””æˆªå™¨ï¼Œä¸‹é¢æ˜¯æ–°çš„請求與響應攔截器的收集,我們åªçœ‹è«‹æ±‚攔截器的部分。

Axios.prototype.request = function request(config) {
  // ç•¥

  var requestInterceptorChain = [];
  var synchronousRequestInterceptors = true;
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous;

    requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

  var responseInterceptorChain = [];
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
  });

  // 先略
};

首先,我們一樣把請求攔截器抓出來一個一個確èªï¼Œç¢ºèªé€™äº›æ””æˆªå™¨ä¸­æ˜¯å¦æœ‰ä»»ä½•一個需è¦è¢«ç•¶éžåŒæ­¥è™•ç†ã€‚如果有的話,synchronousRequestInterceptors 就設定為 false,å之則為 true。確èªå®Œå¾Œæˆ‘們就知é“還需ä¸éœ€è¦è™•ç†éžåŒæ­¥çš„請求攔截,也å¯ä»¥æ›´å¥½çš„æŒ‘é¸ä¸²é€£æ””截器的策略。

  • éžåŒæ­¥è™•ç†è«‹æ±‚攔截器
    Axios.prototype.request = function request(config) {
      // 全略
    
      var promise;
    
      if (!synchronousRequestInterceptors) {
        var chain = [dispatchRequest, undefined];
    
        Array.prototype.unshift.apply(chain, requestInterceptorChain);
        chain = chain.concat(responseInterceptorChain);
    
        promise = Promise.resolve(config);
        while (chain.length) {
          promise = promise.then(chain.shift(), chain.shift());
        }
    
        return promise;
      }
    };
    
  • åŒæ­¥åŸ·è¡Œè«‹æ±‚攔截器
    Axios.prototype.request = function request(config) {
      // 全略
    
      var promise;
    
      var newConfig = config;
      while (requestInterceptorChain.length) {
        var onFulfilled = requestInterceptorChain.shift();
        var onRejected = requestInterceptorChain.shift();
        try {
          newConfig = onFulfilled(newConfig);
        } catch (error) {
          onRejected(error);
          break;
        }
      }
    
      try {
        promise = dispatchRequest(newConfig);
      } catch (error) {
        return Promise.reject(error);
      }
    
      while (responseInterceptorChain.length) {
        promise = promise.then(responseInterceptorChain.shift(), responseInterceptorChain.shift());
      }
    
      return promise;
    };
    

如果è¦åŸ·è¡ŒéžåŒæ­¥ï¼Œé‚£æ‰éœ€è¦è·ŸåŽŸæœ¬ä¸€æ¨£ä½¿ç”¨ Promise.prototype.then() 將所有攔截器與請求串連起來,ä¸ç„¶è«‹æ±‚攔截器得部分則å¯ä»¥æ”¹ç‚ºåŒæ­¥çš„æ–¹æ˜¯è™•ç†ã€‚

在這樣處ç†çš„æ©Ÿåˆ¶ä¸‹ï¼Œå¦‚果沒有使用攔截器,或是在使用攔截器時都告訴 axios è¦ä½¿ç”¨åŒæ­¥è™•ç†ï¼Œå°±ä¸æœƒåœ¨ä¸€é–‹å§‹å°±æŠŠè«‹æ±‚丟到工作佇列(Task Queueï¼‰ä¸­ï¼Œä¹Ÿä¸æœƒè¢«å¾Œé¢è€—時的執行給å¡ä½äº†ã€‚

axios releases v0.21.2

弄清楚了 axios 如何解決éŽåŽ»å› è¨­è¨ˆé€ æˆçš„å»¶é²çš„å•題,我們來看看新的攔截器的用法å§ï¼ä¸€æ¨£é€éŽ TypeScript 定義來èªè­˜æ–°çš„ API

interface AxiosInterceptorOptions {
  runWhen?(config: AxiosRequestConfig): boolean;
  synchronous?: boolean;
}

export interface AxiosInterceptorManager<V> {
  use<T = V>(
    onFulfilled?: (value: V) => T | Promise<T>,
    onRejected?: (error: any) => any,
    options?: AxiosInterceptorOptions
  ): number;
}

在原本的 axios.interceptors.request.use() 除了原本的 onFulfilled 與 onRejected 外,多了第三個å¯é¸çš„åƒæ•¸ options。

第三個 options 除了本篇èšç„¦æåˆ°çš„ synchronous 外,還有一個å¯é¸çš„ runWhen。這個 runWhen 接收一個 function ç”¨æ–¼éŽæ¿¾é€™å€‹æ””截器這次請求是å¦éœ€è¦è¢«æ”¶é›†èµ·ä¾†åŸ·è¡Œã€‚

實作的原始碼如下:

InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected,
    synchronous: options ? options.synchronous : false,
    runWhen: options ? options.runWhen : null
  });
  return this.handlers.length - 1;
};
Axios.prototype.request = function request(config) {
  // ç•¥

  // filter out skipped interceptors
  var requestInterceptorChain = [];
  var synchronousRequestInterceptors = true;
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) {
      return;
    }

    synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous;

    requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

  var responseInterceptorChain = [];
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    responseInterceptorChain.push(interceptor.fulfilled, interce„ {ßptor.rejected);
  });

  // ç•¥
};

å¯ä»¥æ€Žéº¼ä½¿ç”¨ runWhen 呢?例如:Refresh Token。

我們å¯ä»¥è¨­è¨ˆä¸€å€‹è«‹æ±‚攔截器,負責使用 Refresh Token 刷新 Access Token,我們å¯èƒ½ä¸æœƒéœ€è¦æ¯æ¬¡è«‹æ±‚都刷新 Access Token,所以å¯ä»¥å‚³çµ¦ runWhen ä¸€å€‹ç¢ºèªæ˜¯å¦éœ€è¦åˆ·æ–° Access Token çš„ function,如果檢查這次請求需刷新,就回傳 true,å之則回傳 false 來讓這次請求略éŽé€™å€‹æ””截器。

çµèªž

看完這篇,還ä¸è¶•ç·Šæª¢æŸ¥æ‰‹ä¸Šå°ˆæ¡ˆæ˜¯ä¸æ˜¯ä¹Ÿé‡åˆ°äº†è«‹æ±‚被延é²çš„å•題。如果有發ç¾ï¼Œåªè¦å‡ç´š axios 的版本,就å¯ä»¥æœ‰æ•ˆè§£æ±ºç•«é¢è¢«å¡ä½çš„å•題⋯真的是這樣嗎?在一開始æåˆ°çš„ issue 中說到,axios 發出的請求比直接使用 XMLHttpRequest é‚„æ…¢ 200-300 毫秒,而原因是因為執行堆疊(Call Stack)阻塞å¡ä½äº†è¢«æ”¾é€²å·¥ä½œä½‡åˆ—(Task Queue)的請求。ç¾åœ¨è§£æ±ºçš„å•題是é¿å…請求被延é²ï¼Œå°‡è«‹æ±‚æå‰è‡³åŸ·è¡Œå †ç–Šï¼ˆCall Stack)被阻塞å‰åŸ·è¡Œï¼Œä½†åŸ·è¡Œå †ç–Šé˜»å¡žå•題åªè¦ä¸è¢«æŽ’除,響應回來的資料ä¾èˆŠç„¡æ³•被處ç†ï¼Œä¾èˆŠå¾—ç­‰åŸ·è¡Œå †ç–Šè¢«æ¸…ç©ºæ‰æœ‰æ©ŸæœƒåŸ·è¡Œè³‡æ–™éŸ¿æ‡‰å¾Œçš„å·¥ä½œã€‚å› æ­¤å¦‚æžœçœŸçš„ç™¼ç¾æœ‰ç›¸é—œå•題,筆者èªç‚ºé¦–è¦è¢«è§£æ±ºçš„還是檢查是什麼因素造æˆåŸ·è¡Œå †ç–ŠåŸ·è¡Œäº†é€™éº¼é•·çš„æ™‚間,是什麼地方需è¦åŸ·è¡Œ 200-300 毫秒。

總çµé€™æ¬¡çš„內容,我們把原本 axios 為什麼會造æˆå»¶é²è«‹æ±‚的原因找了出來,也看到了 v0.21.2 版怎麼解決這個å•題。除此之外還 axios ä¹Ÿæ–°å¢žäº†ç•¥éŽæ””截器的é¸é …。這些改動都讓使用 axios 變得更加彈性ã€å¥½ç”¨ã€‚

ä¸éŽå…¶å¯¦ axios 也還有ä¸å°‘值得改善ã€è¨Žè«–的空間,例如,runWhen 為什麼åªåšç”¨åœ¨è«‹æ±‚攔截器裡é¢ï¼Ÿåœ¨ issue #4228 就有人å•到了「Why runWhen is only supported in request interceptors options?ã€ã€‚而 axios.interceptors.request.use() çš„ç¬¬ä¸‰å€‹åƒæ•¸ç›®å‰ä¹Ÿé‚„沒有 TypeScript 的型別支æ´ï¼ˆç€è¦½äº†ä¸€ä¸‹æœ‰è‡³å°‘ 3 個 PR 試圖åšé€™æ¨£çš„è²¢ç»ï¼‰ã€‚

axios 活用了éžåŒæ­¥èˆ‡åŒæ­¥çš„處ç†ï¼Œæ˜¯å€‹éžå¸¸å€¼å¾—深入èªè­˜çš„好用工具,更多 axios 的動態也å¯ä»¥åˆ° GitHub 上追蹤最新的討論喔ï¼

åƒè€ƒè³‡æ–™

è«‹æˆ‘å–æ¯å’–å•¡

如果這裡的內容有幫助到你的話,一æ¯å’–å•¡å°±æ˜¯å°æˆ‘最大的鼓勵。

è«‹æˆ‘å–æ¯å’–å•¡
ÿÿÿÿ