分析axios設計,學習前端請求庫

七月Alvin發表於2020-04-05

axios原始碼探索,主要是學習原始碼實現原理和揣摩設計思路,不會深究每個開源庫具體的函式實現。

#0. 導讀

本文學習的版本是 v0.19.2(截止至目前20200331),拉取的axios開源庫master分支,最新一次commitCommits on Mar 28, 2020 c120f44d3d29c8e822a92e1d6879b8b77be6b9dc Fixing 'progressEvent' type

在多個專案都是用到了axios之後,再次閱讀axios原始碼,心中不禁會留下一些疑問:

  1. 為什麼axios能夠當成axios(config)函式呼叫,也可以當成物件axios.get呼叫呢?
  2. axios預設返回200~300之間才算是請求成功,如果我想要修改這個判斷邏輯能做到嗎?
  3. 據說axios是使用XMLHttpRequest實現介面請求的,它的實現原理是什麼樣的呢?
  4. umi-requesttua-api用到了koa-compose的洋蔥模型,KOAJAX基於Koa式的中介軟體呼叫棧,axios具體的實現是什麼樣的呢?
  5. axios的攔截器是什麼樣的呢?它的原理是什麼呢?
  6. axios為什麼支援在瀏覽器和node兩種不同的環境下傳送請求,那麼它能夠藉助微信提供的原生請求 wx.request API傳送請求嗎?
  7. 以前的幾個專案使用axios進行資料請求時,mock的時候非常不便,那麼,71.3K開發者關注的請求庫有考慮過如何為開發和測試提供方便嗎?
#1. axios初始化

檢視package.json檔案的main主入口檔案為index.js,再追查發現實際主入口檔案為**./lib/axios**。

axios.js檔案程式碼行數比較少,主要的功能是生成混合物件axios,對外提供axios.Axiosaxios.create工廠方法和相關擴充套件實現,比如取消請求相關、all和spred等方法

#1.1 構造axios混合物件

在專案當中常常使用到的是axios({config}),axios[method](url[, config]), axios.create([config])

/**
 * Create an instance of Axios
 *
 * @param {Object} defaultConfig The default config for the instance
 * @return {Axios} A new instance of Axios
 *
 * instance 是函式 Axios.prototype.request 且執行上下文繫結到 context。
 * instance 裡面有 Axios.prototype 上面的所有方法,並且這些方法的執行上下文也繫結到 context。
 * instance 裡面還有 context 上的方法。
 */
function createInstance(defaultConfig) {
  // 例項化Axios物件
  var context = new Axios(defaultConfig);

  // 把request函式的this指向context
  // 將Axios.prototype.request 的上下文繫結到context
  // bind工具方法實際執行為:Axios.prototype.request.apply(context, arguments)
  var instance = bind(Axios.prototype.request, context);

  // Copy axios.prototype to instance
  // 將 Axios.prototype 上的所有方法的執行上下文繫結到 context , 並且繼承給 instance
  utils.extend(instance, Axios.prototype, context);

  // Copy context to instance
  // 複製 context 到 intance 例項
  utils.extend(instance, context);

  // 返回混合物件axios
  return instance;
}

// Create the default instance to be exported
// 傳入一個預設的配置
var axios = createInstance(defaults);


// 下面將會給axios例項化的物件增加不同的方法
// Expose Axios class to allow class inheritance
axios.Axios = Axios;

// Factory for creating new instances
// 工廠模式 建立新的例項 使用者可以自定義一些引數
axios.create = function create(instanceConfig) {
  return createInstance(mergeConfig(axios.defaults, instanceConfig));
};
複製程式碼
  1. bind(Axios.prototype.request, context) 將Axios例項和request函式進行繫結,使得axios(config)實際呼叫的是Axios.prototype.request進行資料請求。

  2. utils.extend(instance, Axios.prototype, context) 將 Axios.prototype 上的所有方法的執行上下文繫結到 context , 並且繼承給 instance,使得我們在實際使用axios[method](url[, config])進行介面請求時,實際執行Axios.prototype上對應的方法。

  3. utils.extend(instance, context) 將context物件直接賦值給instance,這一操作使得我們能夠使用預設配置axios.defaults和攔截器axios.interceptors,其背後實際使用的是(new Axios()).defaults和(new Axios()).interceptors

上述三步操作完美的實現了axios既能當成**axios({config})函式呼叫,也可以當成物件axios[method](url[, config])**呼叫,基於上述三步之後,我繪製了axios的混合物件關係圖

分析axios設計,學習前端請求庫

#1.2 程式碼執行詳情

瞭解完如何構造axios混合物件之後,接下來看看在一般的專案接入過程當中,axios庫的核心執行流程是怎麼樣的。

import axios from "axios";

// 新增請求攔截器
axios.interceptors.request.use(function (config) {
    // 在傳送請求之前對request config 進行處理
    return config;
  }, function (error) {
    // 在傳送請求之前發生error
    return Promise.reject(error);
  });

// 新增響應攔截器
axios.interceptors.response.use(function (response) {
    // 預設情況下,請求httpStatus為 200 ~ 300,會在這裡進行處理【正常】響應物件response
    return response;
  }, function (error) {
    // 預設情況下,請求httpStatus不在 200 ~ 300,會在這裡進行處理【異常】響應物件response
    return Promise.reject(error);
  });

// 開始請求資料
axios({
  method: 'get',
  url: 'https://gateway.cn/user/alvin',
  responseType: 'json'
}).then(function response(res) {
  console.log("print::: ", res)
})
複製程式碼

分析axios設計,學習前端請求庫

① 通過axios提供的axios.interceptors物件新增請求和響應攔截器,最終會通過use方法InterceptorManager.prototype.use = function use(fulfilled, rejected)將攔截器新增到私有變數handlers當中,等待後續請求時發生作用。【如圖中第0步】

② 使用axios(config)的方法開始傳送請求,其本質是通過function createInstance(defaultConfig)工廠方法完成建立axios物件,並且在這個方法內完成axios混合物件的構建。【如圖中第1步】

③ 通過上面分析axios混合物件之後會發現,axios(config)本質上是在呼叫Axios.prototype.request方法,並在這個方法內完成每次請求時的Promise請求呼叫鏈chain生成。【如圖中第2步】

通過request執行之後,返回的請求呼叫鏈如下:

const chain = [ 請求攔截器的成功方法,請求攔截器的失敗方法 [,... ],dispatchRequest, undefined, 響應攔截器的成功方法,響應攔截器的失敗方法 [,... ]]

  • axios.interceptors.request.use 註冊的請求攔截器會Array.unshift進chain陣列

  • axios.interceptors.response.use註冊的請求攔截器會Array.push進chain陣列

④ 在構造整個請求鏈的過程中,發現chain的初始值為[dispatchRequest, undefined],其中dispatchRequest表示請求庫的核心adapter,在瀏覽器模式為XMLHttpRequest,在node模式下為http或者https模組,並且如果自定義了adapter物件,會優先使用。

//   core/dispatchRequest.js 檔案
module.exports = function dispatchRequest(config) {

  // ...

  // 獲取請求介面卡,如果自定義介面卡則優先使用
  var adapter = config.adapter || defaults.adapter;
    return adapter(config).then(
        function onAdapterResolution(response) {  }
        function onAdapterRejection(reason) {  }
    )
}

// defaults.js 檔案
function getDefaultAdapter() {
  var adapter;
  
  // 瀏覽器模式
  if (typeof XMLHttpRequest !== 'undefined') {
    adapter = require('./adapters/xhr');
  }
  // node模式
  else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    adapter = require('./adapters/http');
  }
  return adapter;
}
複製程式碼

至此,整個axios請求核心流程基本分析完成,接下來揣摩axios幾個重要模組設計思路

#2 axios重要模組 —— XMLHttpRequest物件

在前面我們通過梳理axios執行流程之後發現,axios在瀏覽器端的核心adapter為XMLHttpRequest,下面稍微瞭解下這個物件。

下面的資訊全部通過typescript型別檔案lib.es6.dom.d.ts進行獲取

分析axios設計,學習前端請求庫

// responseType型別
type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "json" | "text";

// XMLHttpRequest 父類物件
interface XMLHttpRequestEventTarget {
    // 當請求失敗時呼叫該方法,接受 abort 物件作為引數
    onabort: ((this: XMLHttpRequest, ev: Event) => any) | null;
  	// 當請求發生錯誤時呼叫該方法,接受 error 物件作為引數。
    onerror: ((this: XMLHttpRequest, ev: ErrorEvent) => any) | null;
  	// 當一個 HTTP 請求正確載入出內容後返回時呼叫,接受 load 物件作為引數。
    onload: ((this: XMLHttpRequest, ev: Event) => any) | null;
  	// 當內容載入完成,不管失敗與否,都會呼叫該方法,接受 loadend 物件作為引數。
    onloadend: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null;
  	// 當一個 HTTP 請求開始載入資料時呼叫,接受 loadstart 物件作為引數。
    onloadstart: ((this: XMLHttpRequest, ev: Event) => any) | null;
  	// 間歇呼叫該方法用來獲取請求過程中的資訊,接受 progress 物件作為引數。
    onprogress: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null;
  	// 當超時時呼叫,接受 timeout 物件作為引數;只有設定了 XMLHttpRequest 物件的 timeout 屬性時,才可能發生超時事件。
    ontimeout: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null;
    // ...
}


interface XMLHttpRequest extends XMLHttpRequestEventTarget {

  	// 當 readyState 發生改變時回撥處理函式
    onreadystatechange: ((this: XMLHttpRequest, ev: Event) => any) | null;

		// 返回 一個無符號短整型(unsigned short)數字,代表請求的狀態碼
		// 0	UNSENT	代理被建立,但尚未呼叫 open() 方法。
		// 1	OPENED	open() 方法已經被呼叫。
		// 2	HEADERS_RECEIVED	send() 方法已經被呼叫,並且頭部和狀態已經可獲得。
		// 3	LOADING	下載中; responseText 屬性已經包含部分資料。
		// 4	DONE	下載操作已完成。
    readonly readyState: number;
  
		// 請求返回的響應結果
    readonly response: any;
		// 此請求的響應為文字,或者當請求未成功或還是未傳送時未 null
    readonly responseText: string;

		// 返回響應資料的列舉型別。它允許我們手動的設定返回資料的型別。
		// 如果我們將它設定為一個空字串,它將使用預設的"text"型別。
  	responseType: XMLHttpRequestResponseType;
		
		// 返回響應的序列化(serialized)URL,如果該 URL 為空,則返回空字串
    readonly responseURL: string;
		// 返回一個無符號短整型(unsigned short)數字,代表請求的響應狀態。
    readonly status: number;
		// 包含 HTTP 伺服器返回響應狀態的字串。與 XMLHTTPRequest.status 不同的是,它包含完整的響應狀態文字
    readonly statusText: string;

		// 用來指定跨域 Access-Control 請求是否應帶有授權資訊,如 cookie 或授權 header 頭。
    withCredentials: boolean;

		// 表示一個請求在被自動終止前所消耗的毫秒數。預設值為 0,意味著沒有超時時間。
		// 超時並不能應用在同步請求中,否則會丟擲一個 InvalidAccessError 異常。
		// 當發生超時時,timeout 事件將會被觸發。
  	timeout: number;

		// 返回所有響應頭資訊(響應頭名和值),如果響應頭還沒有接收,則返回 null。
		// 注意:使用該方法獲取的 response headers 與在開發者工具 Network 皮膚中看到的響應頭不一致
    getAllResponseHeaders(): string;
		// 返回指定響應頭的值,如果響應頭還沒有被接收,或該響應頭不存在,則返回 null。
    getResponseHeader(name: string): string | null;
    
		// 設定 HTTP 請求頭資訊。
		// 注意:在這之前,你必須確認已經呼叫了 open() 方法開啟了一個 url
    setRequestHeader(name: string, value: string): void;
  
  	// upload方法返回的是一個XMLHttpRequestUpload物件,通過監聽progress事件的回撥完成上傳和下載能力
    readonly upload: XMLHttpRequestUpload;

		// 如果請求已經被髮送,則立刻中止請求
    abort(): void;
    
    // 初始化一個請求。該方法只能在 JavaScript 程式碼中使用,若要在 native code 中初始化請求,請使用 openRequest()。
    // 引數簽名:
    //     method - 請求所使用的 HTTP 方法,如 GET、POST、PUT、DELETE
    //     url - 請求的 URL 地址
    //     async - 一個可選的布林值引數,預設值為 true,表示執行非同步操作。
    //           - 如果值為 false,則 send() 方法不會返回任何東西,直到接收到了伺服器的返回資料
    //     user - 使用者名稱,可選引數,用於授權。預設引數為空字串
    //     password - 密碼,可選引數,用於授權。預設引數為空字串
    open(method: string, url: string): void;
    open(method: string, url: string, async: boolean, username?: string | null, password?: string | null): void;
    
    // 引數詳情可檢視 XMLHttpRequestUpload ,監聽函式具有相同的實現
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;

    // ...
}

interface XMLHttpRequestUpload extends XMLHttpRequestEventTarget {
  	// 監聽事件的type型別為:
  	//     onloadstart	獲取開始
  	//     onprogress	資料傳輸進行中
  	//     onabort	獲取操作終止
  	//     onerror	獲取失敗
  	//     onload	獲取成功
  	//     ontimeout	獲取操作在使用者規定的時間內未完成
  	//     onloadend	獲取完成(不論成功與否)
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}


複製程式碼

① 通過透傳請求時的config.onDownloadProgress函式來回撥監聽下載進度。request.addEventListener('progress', config.onDownloadProgress)

② 通過透傳請求時的config.onDownloadProgress函式來回撥監聽下載進度。request.addEventListener('progress', config.onDownloadProgress)

③ 在超時和請求異常的功能上,藉助了父類的onerrorontimeout方式來完成。具體程式碼如下:

var request = new XMLHttpRequest();

// 請求異常處理
request.onerror = function handleError() { // ... }
// 請求超時處理
request.ontimeout = function handleTimeout() { // ... }
複製程式碼

思考

如果說請求超時和請求異常會由XMLHttpRequest物件在底層實現上自動回撥對應的註冊函式handleError或handleTimeout,那麼,取消請求呢?

取消請求在觸發的主體上應該為呼叫者,XMLHttpRequest能做的就是在接受到取消請求命令的時候配合處理而已。

#3 axios 重要模組 —— 請求取消

在某些場景下,我們希望能夠主動取消已經傳送的請求,比如請求響應結果存在互相覆蓋,因為每一個請求響應的時長不固定時,實際執行的結果可能會有點不是我們所希望發生的,這時候我們希望能夠有一個取消請求的能力去處理掉請求響應順序帶來的問題。觀察發現axios實現了兩種取消請求的實現:

① 給axios新增一個CancelToken物件,能夠提供一個source方法返回一個source物件,source.token為每次請求時傳給配置物件的canceToken屬性,在請求傳送之後,能夠通過source.cancel方法來取消請求

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/alvin', {
  cancelToken: source.token
}).catch(function (e) {
  if (axios.isCancel(e)) {
    console.log('Request canceled', e.message);
  } else {
    // 處理錯誤
  }
});

// 取消請求 (請求原因是可選的)
source.cancel('Operation canceled by the user.');
複製程式碼

② axios.CancelToken是一個類,直接把其例項化物件傳送給請求配置中的cancelToken屬性,CancelToken 的建構函式引數支援傳入一個 executor 方法,該方法的一個引數為取消請求函式cancelFn,通過將cancelFn賦值給原本作用域cancel,通過呼叫cancel方法來取消請求。

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/alvin', {
  cancelToken: new CancelToken(function executor(cancelFn) {
    cancel = cancelFn;
  })
});

// 取消請求
cancel();
複製程式碼
  • 揣摩實現思路

通過程式碼分析發現,上述兩種呼叫的實現方式,其核心是一致的。每次傳送請求的非同步過程中,通過config.cancelToken欄位註冊進去的是一個 pending 狀態的 Promise。然後在這個Promise fulfilled的呼叫鏈當中呼叫XMLHttpRequest物件的abort()方法完成取消請求的處理。具體實現如下:

// CancelToken.js 取消請求的核心檔案

function CancelToken(executor) {
	// ...
  
  var resolvePromise;
  // 構造一個pending狀態的Promsie
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
		// ...
    
    // token.reason是取消的請求的原因,由外部config傳入
    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

CancelToken.source = function source() {
  var cancel;
  // 這個實現和觀察到的第二種方式相同
  var token = new CancelToken(function executor(c) {
    cancel = c;
  });
  return {
    token: token,
    cancel: cancel
  };
};
複製程式碼

xhr.js檔案中,每次請求都會存在一個promise等待著後續的resolve處理

if (config.cancelToken) {
  // 處理取消請求
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (!request) {
      return;
    }

    request.abort();
    reject(cancel);
    // 清空請求物件
    request = null;
  });
}
複製程式碼

至此,取消請求的核心實現也已經完成。

#4 axios功能地圖

分析axios設計,學習前端請求庫

#4 對比實現,跳出資料請求範圍

在學習axios請求庫的同時,也在微信公眾號、掘金、github、google中搜羅下來發現很多請求庫,通過學習瞭解之後大致可以分成兩種型別:

① 第一種:基於程式碼執行環境提供的資料請求能力(例如: window.fetch/XMLHttpRequest/http/https)進行包裝,在不同的請求庫當中實現包裝的方式各有不同,有的基於Promise請求鏈,有的基於koa-compose洋蔥模型,最終實現為程式碼開發提效的能力。代表有:axios umi-request KOAXAJ tua-api

②第二種:跳出fetch請求方法,轉而和ui相關流行庫進行結合,比如通過Hooks可以觸達UI這一特性,在React框架的基礎上封裝出了SWR

#寫在最後
  1. 在閱讀原始碼的過程中,遇到了不少的問題,其中印象最深的是關於axios混合物件構造,剛開始非常不能理解為什麼要使用extend和bind,後來是看到了若川大佬的axios的文章才漸漸梳理出了自己的理解

  2. 我最近閱讀原始碼用的最順手的方法,結合最簡單的demo和測試用例,再輔以思維導圖和流程圖,基本上cover能讓我在官方庫的設計思想和實現細節上不斷來回切換,非常好用。

  3. 思考一:在請求資料的方式上,是否還存在某些特殊場景下的好工具呢?

  4. 思考4二:在現有的請求庫當中,是否還有其他更優雅的實現方式呢?

#參考內容

1.MDN XMLHttpRequest文件

2.學習 axios 原始碼整體架構,打造屬於自己的請求庫

附: 文章相關圖片資源:github.com/Aastasia/ma…

分析axios設計,學習前端請求庫

相關文章