Axios 功能擴展之Axios-Retry 源碼閱讀筆記
前兩天分析了 Axios 的源碼設計🔗,其中的攔截器(interceptor)為擴展 Axios 留下了入口,在工作中我們也時常會擴展 Axios,例如:取消重復請求、權限驗證、失敗重試等。
那么如何設計實現一個好的攔截器來擴展 Axios?
通過對 axios-retry 這一周下載量 100w+ 的三方庫來學習下其功能設計,工具庫項目的發包策略,并借此拋磚引玉,以提升我們的編碼設計能力!
- Github: https://github.com/softonic/axios-retry
- NPM: https://www.npmjs.com/package/axios-retry
一、工具庫的 package.json 寫法
看一個模塊的源碼,首先先看 README.md 和 package.json 文件。
參考如上,未來我們也應該在開發工具庫的時候需要關注以下字段:
- files:在發包的時候發布將 es、lib 兩文件夾,以及 index.js 和 index.d.ts 文件。
- typings:TypeScript 類型定義文件,用于在 TypeScript 編碼環境下智能類型提示,該字段亦可寫作 types。
- main:主要入口文件,表明在項目中引入當前庫時候,默認指向的文件是 index.js
- module:并非官方字段,打包工具約定的如果有該字段,則在例如 Rollup 和 Webpack 打包時,處理指定導入我們庫的 ESM 版本的文件路徑。
- exports:提供了一種方法來為不同的環境和 JavaScript 風格顯示聲明如何引入模塊,同時限制對其內部部分的訪問,該字段提案來自:Bare Module Specifier Resolution in node.js[1]
通過依賴字段以及 scripts 字段:
開發依賴和使用依賴
可以得知,當前項目直接使用 Babel 作為打包編譯工具,通過執行 npm run release 發包,并結合 npm scripts 的 pre 和 post 執行生命周期依次執行完成如下任務:
npm run release 執行的任務流程(原文鏈接可查看大圖)
更多關于 package.json 字段的功能/作用描述,可參考 package.json - NPM[2]
二、源碼分析
根據 package.json 文件中關于“發包”命令相關解讀之后,可以得知 ./es/ 文件夾下的 index.mjs 為功能實現文件。
2.1 為什么是 .mjs 文件名后綴
Node.js 原本的模塊系統是 CommonJs (使用 require 和 module.exports 語法)。
自 Node.js 創建后, ECMAScript 模塊系統 (使用 import 和 export 語法) 已經變成一種標準,并且 Node.js 已經加入并實現支持 ES 模塊系統。
Node.js 將 *.cjs 文件當作 CommonJS 模塊, *.mjs 文件當作 ECMAScript 模塊。它會將 .js 文件視為項目的默認模塊系統,除非 package.json 聲明 "type": "module",否則就是 CommonJS。
2.2 axios-retry 的用法
axios-retry 對外導出 axiosRetry() 方法:
注入攔截器
通過對 axios 單例添加“攔截器”,來擴展實現自動重試網絡請求功能。
axios-retry 主要接受兩個參數,第一個是 axios 實例,第二個是 axios-retry 的配置 defaultOptions:
- defaultOptions: {
- retries?: number; // 自動重試次數
- shouldResetTimeout?: boolean; // 是否重置“超時時間”
- retryCondition?: Function; // 重試的條件,可傳入自定義判斷函數
- retryDelay?: Function; // 重試請求的間隔時間的函數
- }
功能配置看起來挺完善的,難怪那么受歡迎。
2.3 請求攔截器設計&實現
在請求攔截器中會做狀態初始化,更新請求次數:
- axios.interceptors.request.use((config) => {
- const currentState = getCurrentState(config);
- // 設置上次請求的時間
- // 思考🤔:為什么不放到 getCurrentState() 函數內一起設置?
- currentState.lastRequestTime = Date.now();
- return config;
- });
- /**
- * 初始化并返回給定“請求”和“配置”的重試狀態
- * @param {AxiosRequestConfig} config
- * @return {Object}
- */
- function getCurrentState(config) {
- // 從 config 獲取狀態
- const currentState = config[namespace] || {};
- // 記錄當前請求的次數
- currentState.retryCount = currentState.retryCount || 0;
- // 更新/寫入 config 中當前請求狀態
- config[namespace] = currentState;
- return currentState;
- }
通過對 axios config 注入 axios-retry 字段作為存儲請求狀態的字段,在 axios 的請求執行鏈中,可隨時從 axios config 中拿到當前請求狀態。
另外,我們看到請求攔截器中并沒有設置 reject 的函數,或許這里可以添加針對 reject 響應函數,用于在發生請求異常后,可直接不需要重試請求,因為錯誤的請求配置必然是無意義的網絡請求,重試請求也是無意義的,直接中斷退出請求執行鏈。
關于退出 Promise 執行鏈,提供幾個參考的討論:
- 從如何停掉 Promise 鏈說起[3]
- Promise 的鏈式調用與中止[4]
2.4 響應攔截器設計&實現
在攔截器中,只響應 reject 函數,也就是只在 axios 響應階段發生錯誤(拋出異常)的時候,才會執行當前攔截器。
- axios.interceptors.response.use(null, async (error) => {
- const { config } = error;
- // 讀取不到 config,則退出,可能是一些其他異常情況
- // 例如:主動取消請求,是直接拋出的錯誤
- if (!config) {
- return Promise.reject(error);
- }
- // 從 defaultOptions 讀取并設置默認值
- const {
- retries = 3, // 默認自動重試 3 次
- retryCondition = isNetworkOrIdempotentRequestError,
- retryDelay = noDelay,
- shouldResetTimeout = false
- } = getRequestOptions(config, defaultOptions);
- const currentState = getCurrentState(config);
- // 判斷是否需要重試
- if (await shouldRetry(retries, retryCondition, currentState, error)) {
- // 需要的話,則 currentState 需要更新重試次數
- currentState.retryCount += 1;
- const delay = retryDelay(currentState.retryCount, error);
- // Axios 合并默認配置失敗,因為循環結構
- // 參考 issue: https://github.com/mzabriskie/axios/issues/370
- fixConfig(axios, config);
- // shouldResetTimeout 默認為 false
- // 根據實際請求的時間,并比較 config.timeout,選最大值來設置的超時時間
- if (!shouldResetTimeout && config.timeout && currentState.lastRequestTime) {
- const lastRequestDuration = Date.now() - currentState.lastRequestTime;
- // Minimum 1ms timeout (passing 0 or less to XHR means no timeout)
- // 設置超時時間最小 1ms(認為 <= 0 的 XHR 請求不算超時)
- config.timeout = Math.max(config.timeout - lastRequestDuration - delay, 1);
- }
- config.transformRequest = [(data) => data];
- // 常見的 Promise 延時的寫法(sleep)
- // 重新發起請求,調用 axios(config)
- // 因為無論何種類型請求,都會被標準化為 axios(config)
- // 在應用層 axios.prototye.request 做了兼容轉換
- return new Promise((resolve) => setTimeout(() => resolve(axios(config)), delay));
- }
- return Promise.reject(error);
- });
總結
這是針對 axios 源碼分析文章的一個補充,作為常見對于 axios 的功能擴展,失敗重試 axios-retry 算是一個比較好的例子,可以作為之后擴展 axios 功能的一個模板。
另外,axios-retry 中通過 Babel 直接打包,以及其借助 NPM scripts 的生命周期,將測試、更新版本,打包構建、發布、Git push串聯起來,也是值得借鑒之處。
在文中有提到,在請求攔截器中可以,添加針對“發起網絡請求”前的錯誤處理,如果發生錯誤,直接中斷重試過程,避免錯誤的請求多次發起,節省計算資源,可以動手嘗試實現一下。
當然,是否需要重試請求,在響應攔截器中通過 shouldRetry() 函數來保證了,但在 axios 請求執行鏈上,響應攔截器始終是需要通過發起網絡請求(dispachRequest() 事件)后才會執行,所以這個嘗試還是可以研究研究🧐,對于搞懂 Promise 執行鏈大有裨益。
參考資料
[1]Bare Module Specifier Resolution in node.js:
https://github.com/jkrems/proposal-pkg-exports/
[2]package.json - NPM:
https://docs.npmjs.com/cli/v8/configuring-npm/package-json
[3]從如何停掉 Promise 鏈說起:
https://github.com/xieranmaya/blog/issues/5
[4]Promise 的鏈式調用與中止:
https://cnodejs.org/topic/58385d4927d001d606ac197d