1. 程式人生 > 其它 >78.1K 的 Axios 專案有哪些值得借鑑的地方

78.1K 的 Axios 專案有哪些值得借鑑的地方

78.1K 的 Axios 專案有哪些值得借鑑的地方

阿寶哥釋出於 2020-10-23

Axios 是一個基於 Promise 的 HTTP 客戶端,同時支援瀏覽器和 Node.js 環境。它是一個優秀的 HTTP 客戶端,被廣泛地應用在大量的 Web 專案中。

由上圖可知,Axios 專案的 Star 數為 77.9K,Fork 數也高達 7.3K,是一個很優秀的開源專案,所以接下來阿寶哥將帶大家一起來分析 Axios 專案中一些值得借鑑的地方。閱讀完本文,你將瞭解以下內容:

  • HTTP 攔截器的設計與實現;
  • HTTP 介面卡的設計與實現;
  • 如何防禦 CSRF 攻擊。

下面我們從簡單的開始,先來了解一下 Axios。

一、Axios 簡介

Axios 是一個基於 Promise 的 HTTP 客戶端,擁有以下特性:

  • 支援 Promise API;
  • 能夠攔截請求和響應;
  • 能夠轉換請求和響應資料;
  • 客戶端支援防禦 CSRF 攻擊;
  • 同時支援瀏覽器和 Node.js 環境;
  • 能夠取消請求及自動轉換 JSON 資料。

在瀏覽器端 Axios 支援大多數主流的瀏覽器,比如 Chrome、Firefox、Safari 和 IE 11。此外,Axios 還擁有自己的生態:

(資料來源 —— https://github.com/axios/axio...

簡單介紹完 Axios,我們來分析一下它提供的一個核心功能 —— 攔截器。

二、HTTP 攔截器的設計與實現

2.1 攔截器簡介

對於大多數 SPA 應用程式來說, 通常會使用 token 進行使用者的身份認證。這就要求在認證通過後,我們需要在每個請求上都攜帶認證資訊。針對這個需求,為了避免為每個請求單獨處理,我們可以通過封裝統一的 request 函式來為每個請求統一新增 token 資訊。

但後期如果需要為某些 GET 請求設定快取時間或者控制某些請求的呼叫頻率的話,我們就需要不斷修改 request 函式來擴充套件對應的功能。此時,如果在考慮對響應進行統一處理的話,我們的 request 函式將變得越來越龐大,也越來越難維護。那麼對於這個問題,該如何解決呢?Axios 為我們提供瞭解決方案 —— 攔截器。

Axios 是一個基於 Promise 的 HTTP 客戶端,而 HTTP 協議是基於請求和響應:

所以 Axios 提供了請求攔截器和響應攔截器來分別處理請求和響應,它們的作用如下:

  • 請求攔截器:該類攔截器的作用是在請求傳送前統一執行某些操作,比如在請求頭中新增 token 欄位。
  • 響應攔截器:該類攔截器的作用是在接收到伺服器響應後統一執行某些操作,比如發現響應狀態碼為 401 時,自動跳轉到登入頁。

在 Axios 中設定攔截器很簡單,通過 axios.interceptors.request 和 axios.interceptors.response 物件提供的 use 方法,就可以分別設定請求攔截器和響應攔截器:

// 新增請求攔截器
axios.interceptors.request.use(function (config) {
  config.headers.token = 'added by interceptor';
  return config;
});

// 新增響應攔截器
axios.interceptors.response.use(function (data) {
  data.data = data.data + ' - modified by interceptor';
  return data;
});

那麼攔截器是如何工作的呢?在看具體的程式碼之前,我們先來分析一下它的設計思路。Axios 的作用是用於傳送 HTTP 請求,而請求攔截器和響應攔截器的本質都是一個實現特定功能的函式。

我們可以按照功能把傳送 HTTP 請求拆解成不同型別的子任務,比如有用於處理請求配置物件的子任務,用於傳送 HTTP 請求的子任務和用於處理響應物件的子任務。當我們按照指定的順序來執行這些子任務時,就可以完成一次完整的 HTTP 請求。

瞭解完這些,接下來我們將從 任務註冊、任務編排和任務排程 三個方面來分析 Axios 攔截器的實現。

2.2 任務註冊

通過前面攔截器的使用示例,我們已經知道如何註冊請求攔截器和響應攔截器,其中請求攔截器用於處理請求配置物件的子任務,而響應攔截器用於處理響應物件的子任務。要搞清楚任務是如何註冊的,就需要了解 axios 和 axios.interceptors 物件。

// lib/axios.js
function createInstance(defaultConfig) {
  var context = new Axios(defaultConfig);
  var instance = bind(Axios.prototype.request, context);

  // Copy axios.prototype to instance
  utils.extend(instance, Axios.prototype, context);
  // Copy context to instance
  utils.extend(instance, context);
  return instance;
}

// Create the default instance to be exported
var axios = createInstance(defaults);

在 Axios 的原始碼中,我們找到了 axios 物件的定義,很明顯預設的 axios 例項是通過 createInstance 方法建立的,該方法最終返回的是 Axios.prototype.request 函式物件。同時,我們發現了 Axios 的建構函式:

// lib/core/Axios.js
function Axios(instanceConfig) {
  this.defaults = instanceConfig;
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

在建構函式中,我們找到了 axios.interceptors 物件的定義,也知道了 interceptors.request 和 interceptors.response 物件都是 InterceptorManager 類的例項。因此接下來,進一步分析 InterceptorManager 建構函式及相關的 use 方法就可以知道任務是如何註冊的:

// lib/core/InterceptorManager.js
function InterceptorManager() {
  this.handlers = [];
}

InterceptorManager.prototype.use = function use(fulfilled, rejected) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected
  });
  // 返回當前的索引,用於移除已註冊的攔截器
  return this.handlers.length - 1;
};

通過觀察 use 方法,我們可知註冊的攔截器都會被儲存到 InterceptorManager 物件的 handlers 屬性中。下面我們用一張圖來總結一下 Axios 物件與 InterceptorManager 物件的內部結構與關係:

2.3 任務編排

現在我們已經知道如何註冊攔截器任務,但僅僅註冊任務是不夠,我們還需要對已註冊的任務進行編排,這樣才能確保任務的執行順序。這裡我們把完成一次完整的 HTTP 請求分為處理請求配置物件、發起 HTTP 請求和處理響應物件 3 個階段。

接下來我們來看一下 Axios 如何發請求的:

axios({
  url: '/hello',
  method: 'get',
}).then(res =>{
  console.log('axios res: ', res)
  console.log('axios res.data: ', res.data)
})

通過前面的分析,我們已經知道 axios 物件對應的是 Axios.prototype.request 函式物件,該函式的具體實現如下:

// lib/core/Axios.js
Axios.prototype.request = function request(config) {
  config = mergeConfig(this.defaults, config);

  // 省略部分程式碼
  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;
};

任務編排的程式碼比較簡單,我們來看一下任務編排前和任務編排後的對比圖:

2.4 任務排程

任務編排完成後,要發起 HTTP 請求,我們還需要按編排後的順序執行任務排程。在 Axios 中具體的排程方式很簡單,具體如下所示:

 // lib/core/Axios.js
Axios.prototype.request = function request(config) {
  // 省略部分程式碼
  var promise = Promise.resolve(config);
  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }
}

因為 chain 是陣列,所以通過 while 語句我們就可以不斷地取出設定的任務,然後組裝成 Promise 呼叫鏈從而實現任務排程,對應的處理流程如下圖所示:

下面我們來回顧一下 Axios 攔截器完整的使用流程:

// 新增請求攔截器 —— 處理請求配置物件
axios.interceptors.request.use(function (config) {
  config.headers.token = 'added by interceptor';
  return config;
});

// 新增響應攔截器 —— 處理響應物件
axios.interceptors.response.use(function (data) {
  data.data = data.data + ' - modified by interceptor';
  return data;
});

axios({
  url: '/hello',
  method: 'get',
}).then(res =>{
  console.log('axios res.data: ', res.data)
})

介紹完 Axios 的攔截器,我們來總結一下它的優點。Axios 通過提供攔截器機制,讓開發者可以很容易在請求的生命週期中自定義不同的處理邏輯。此外,也可以通過攔截器機制來靈活地擴充套件 Axios 的功能,比如 Axios 生態中列舉的 axios-response-logger 和 axios-debug-log 這兩個庫。

參考 Axios 攔截器的設計模型,我們就可以抽出以下通用的任務處理模型:

三、HTTP 介面卡的設計與實現

3.1 預設 HTTP 介面卡

Axios 同時支援瀏覽器和 Node.js 環境,對於瀏覽器環境來說,我們可以通過 XMLHttpRequest 或 fetch API 來發送 HTTP 請求,而對於 Node.js 環境來說,我們可以通過 Node.js 內建的 http 或 https 模組來發送 HTTP 請求。

為了支援不同的環境,Axios 引入了介面卡。在 HTTP 攔截器設計部分,我們看到了一個 dispatchRequest 方法,該方法用於傳送 HTTP 請求,它的具體實現如下所示:

// lib/core/dispatchRequest.js
module.exports = function dispatchRequest(config) {
  // 省略部分程式碼
  var adapter = config.adapter || defaults.adapter;
  
  return adapter(config).then(function onAdapterResolution(response) {
    // 省略部分程式碼
    return response;
  }, function onAdapterRejection(reason) {
    // 省略部分程式碼
    return Promise.reject(reason);
  });
};

通過檢視以上的 dispatchRequest 方法,我們可知 Axios 支援自定義介面卡,同時也提供了預設的介面卡。對於大多數場景,我們並不需要自定義介面卡,而是直接使用預設的介面卡。因此,預設的介面卡就會包含瀏覽器和 Node.js 環境的適配程式碼,其具體的適配邏輯如下所示:

// lib/defaults.js
var defaults = {
  adapter: getDefaultAdapter(),
  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-XSRF-TOKEN',
  //...
}

function getDefaultAdapter() {
  var adapter;
  if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  } else if (typeof process !== 'undefined' && 
    Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  }
  return adapter;
}

在 getDefaultAdapter 方法中,首先通過平臺中特定的物件來區分不同的平臺,然後再匯入不同的介面卡,具體的程式碼比較簡單,這裡就不展開介紹。

3.2 自定義介面卡

其實除了預設的介面卡外,我們還可以自定義介面卡。那麼如何自定義介面卡呢?這裡我們可以參考 Axios 提供的示例:

var settle = require('./../core/settle');
module.exports = function myAdapter(config) {
  // 當前時機點:
  //  - config配置物件已經與預設的請求配置合併
  //  - 請求轉換器已經執行
  //  - 請求攔截器已經執行
  
  // 使用提供的config配置物件發起請求
  // 根據響應物件處理Promise的狀態
  return new Promise(function(resolve, reject) {
    var response = {
      data: responseData,
      status: request.status,
      statusText: request.statusText,
      headers: responseHeaders,
      config: config,
      request: request
    };

    settle(resolve, reject, response);

    // 此後:
    //  - 響應轉換器將會執行
    //  - 響應攔截器將會執行
  });
}

在以上示例中,我們主要關注轉換器、攔截器的執行時機點和介面卡的基本要求。比如當呼叫自定義介面卡之後,需要返回 Promise 物件。這是因為 Axios 內部是通過 Promise 鏈式呼叫來完成請求排程,不清楚的小夥伴可以重新閱讀 “攔截器的設計與實現” 部分的內容。

現在我們已經知道如何自定義介面卡了,那麼自定義介面卡有什麼用呢?在 Axios 生態中,阿寶哥發現了 axios-mock-adapter 這個庫,該庫通過自定義介面卡,讓開發者可以輕鬆地模擬請求。對應的使用示例如下所示:

var axios = require("axios");
var MockAdapter = require("axios-mock-adapter");

// 在預設的Axios例項上設定mock介面卡
var mock = new MockAdapter(axios);

// 模擬 GET /users 請求
mock.onGet("/users").reply(200, {
  users: [{ id: 1, name: "John Smith" }],
});

axios.get("/users").then(function (response) {
  console.log(response.data);
});

對 MockAdapter 感興趣的小夥伴,可以自行了解一下 axios-mock-adapter 這個庫。到這裡我們已經介紹了 Axios 的攔截器與介面卡,下面阿寶哥用一張圖來總結一下 Axios 使用請求攔截器和響應攔截器後,請求的處理流程:

四、CSRF 防禦

4.1 CSRF 簡介

跨站請求偽造(Cross-site request forgery),通常縮寫為 CSRF 或者 XSRF, 是一種挾制使用者在當前已登入的 Web 應用程式上執行非本意的操作的攻擊方法。

跨站請求攻擊,簡單地說,是攻擊者通過一些技術手段欺騙使用者的瀏覽器去訪問一個自己曾經認證過的網站並執行一些操作(如發郵件,發訊息,甚至財產操作如轉賬和購買商品)。由於瀏覽器曾經認證過,所以被訪問的網站會認為是真正的使用者操作而去執行。

為了讓小夥伴更好地理解上述的內容,阿寶哥畫了一張跨站請求攻擊示例圖:

在上圖中攻擊者利用了 Web 中使用者身份驗證的一個漏洞:簡單的身份驗證只能保證請求發自某個使用者的瀏覽器,卻不能保證請求本身是使用者自願發出的。既然存在以上的漏洞,那麼我們應該怎麼進行防禦呢?接下來我們來介紹一些常見的 CSRF 防禦措施。

4.2 CSRF 防禦措施

4.2.1 檢查 Referer 欄位

HTTP 頭中有一個 Referer 欄位,這個欄位用以標明請求來源於哪個地址。在處理敏感資料請求時,通常來說,Referer 欄位應和請求的地址位於同一域名下。

以示例中商城操作為例,Referer 欄位地址通常應該是商城所在的網頁地址,應該也位於 www.semlinker.com 之下。而如果是 CSRF 攻擊傳來的請求,Referer 欄位會是包含惡意網址的地址,不會位於 www.semlinker.com 之下,這時候伺服器就能識別出惡意的訪問。

這種辦法簡單易行,僅需要在關鍵訪問處增加一步校驗。但這種辦法也有其侷限性,因其完全依賴瀏覽器傳送正確的 Referer 欄位。雖然 HTTP 協議對此欄位的內容有明確的規定,但並無法保證來訪的瀏覽器的具體實現,亦無法保證瀏覽器沒有安全漏洞影響到此欄位。並且也存在攻擊者攻擊某些瀏覽器,篡改其 Referer 欄位的可能。

4.2.2 同步表單 CSRF 校驗

CSRF 攻擊之所以能夠成功,是因為伺服器無法區分正常請求和攻擊請求。針對這個問題我們可以要求所有的使用者請求都攜帶一個 CSRF 攻擊者無法獲取到的 token。對於 CSRF 示例圖中的表單攻擊,我們可以使用 同步表單 CSRF 校驗 的防禦措施。

同步表單 CSRF 校驗 就是在返回頁面時將 token 渲染到頁面上,在 form 表單提交的時候通過隱藏域或者作為查詢引數把 CSRF token 提交到伺服器。比如,在同步渲染頁面時,在表單請求中增加一個 _csrf 的查詢引數,這樣當用戶在提交這個表單的時候就會將 CSRF token 提交上來:

<form method="POST" action="/upload?_csrf={{由服務端生成}}" enctype="multipart/form-data">
  使用者名稱: <input name="name" />
  選擇頭像: <input name="file" type="file" />
  <button type="submit">提交</button>
</form>
4.2.3 雙重 Cookie 防禦

雙重 Cookie 防禦 就是將 token 設定在 Cookie 中,在提交(POST、PUT、PATCH、DELETE)等請求時提交 Cookie,並通過請求頭或請求體帶上 Cookie 中已設定的 token,服務端接收到請求後,再進行對比校驗。

下面我們以 jQuery 為例,來看一下如何設定 CSRF token:

let csrfToken = Cookies.get('csrfToken');

function csrfSafeMethod(method) {
  // 以下HTTP方法不需要進行CSRF防護
  return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}

$.ajaxSetup({
  beforeSend: function(xhr, settings) {
    if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
      xhr.setRequestHeader('x-csrf-token', csrfToken);
    }
  },
});

介紹完 CSRF 攻擊的方式和防禦手段,最後我們來看一下 Axios 是如何防禦 CSRF 攻擊的。

4.3 Axios CSRF 防禦

Axios 提供了 xsrfCookieName 和 xsrfHeaderName 兩個屬性來分別設定 CSRF 的 Cookie 名稱和 HTTP 請求頭的名稱,它們的預設值如下所示:

// lib/defaults.js
var defaults = {
  adapter: getDefaultAdapter(),

  // 省略部分程式碼
  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-XSRF-TOKEN',
};

前面我們已經知道在不同的平臺中,Axios 使用不同的介面卡來發送 HTTP 請求,這裡我們以瀏覽器平臺為例,來看一下 Axios 如何防禦 CSRF 攻擊:

// lib/adapters/xhr.js
module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    var requestHeaders = config.headers;
    
    var request = new XMLHttpRequest();
    // 省略部分程式碼
    
    // 新增xsrf頭部
    if (utils.isStandardBrowserEnv()) {
      var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
        cookies.read(config.xsrfCookieName) :
        undefined;

      if (xsrfValue) {
        requestHeaders[config.xsrfHeaderName] = xsrfValue;
      }
    }

    request.send(requestData);
  });
};

看完以上的程式碼,相信小夥伴們就已經知道答案了,原來 Axios 內部是使用 雙重 Cookie 防禦 的方案來防禦 CSRF 攻擊。好的,到這裡本文的主要內容都已經介紹完了,其實 Axios 專案還有一些值得我們借鑑的地方,比如 CancelToken 的設計、異常處理機制等,感興趣的小夥伴可以自行學習一下。

五、參考資源

六、推薦閱讀

前端前端設計架構設計 閱讀 3.4k釋出於 2020-10-23   本作品系原創,採用《署名-非商業性使用-禁止演繹 4.0 國際》許可協議