TypeScript 重構 Axios 經驗分享

EER發表於2018-11-23

拒絕做一個只會用 API 的文件工程師,本文將會讓你從重複造輪子的過程中掌握 web 開發相關的基本知識,特別是 XMLHttpRequest。

又是一篇關於 TypeScript 的分享,年底了,請允許我沉澱一下。上次用 TypeScript 重構 Vconsole 的專案 埋下了對 Axios 原始碼解析的梗。於是,這次分享的主題就是 如何從零用 TypeScript 重構 Axios 以及為什麼我要這麼做

筆者在用 TypeScript 重複造輪子的時候目的還是很明確的,不僅是為了用 TypeScript 養成一種好的開發習慣,更重要的是瞭解工具庫關聯的基礎知識。 只有更多地注重基礎知識,才能早日擺脫文件工程師的困擾。(Ps: 用 TypeScript,也是為了擺脫前端查文件的宿命!)

本次分享包括以下內容:

  • 工程簡介 & 開發技巧
  • API 實現
  • XHR,XHR,XHR
  • HTTP,HTTP,HTTP
  • 單元測試

專案原始碼,分享可能會錯過某些細節實現,需要的可以看原始碼,測試用例基本跑通了。想想,5w star 的庫,就這樣自己實現了一遍。

工程簡介

Axios 是什麼?

Promise based HTTP client for the browser and node.js

axios 是基於 Promise 用於瀏覽器和 nodejs 的 HTTP 客戶端,它本身具有以下特性 ( √ 表示本專案具備該特性 ):

  • √ 從瀏覽器建立 XMLHttpRequest => XHR 實現
  • √ 支援 Promise API => XHR 實現
  • √ 攔截請求和響應 => 請求攔截
  • √ 轉換請求和響應資料 => 對應專案目錄 /src/core/dispatchRequest.ts
  • √ 取消請求 取消請求
  • √ 自動轉換 JSON 資料 => 對應專案目錄 /src/core/dispatchRequest.ts
  • √ 客戶端支援防止 CSRF/XSRF => CSRF
  • × 從 node.js 發出 http 請求

這裡主要講解瀏覽器端的 XHR 實現,限於篇幅不會涉及 node 下的 http 。如果你願意一層一層瞭解它,你會發現實現 axios 還是很簡單的,來一起探索吧!

目錄說明

首先來看下目錄。

TypeScript 重構 Axios 經驗分享

目錄與 Axios 基本保持一致,core 是 Axios 類的核心程式碼。adapters 是 XHR 核心實現,Cancel 是與 取消請求相關的程式碼。helpers 用於放常用的工具函式。Karma.conf.js 及 test 目錄與單元測試相關。.travis.yml 用於配置 線上持續整合,另外可在 github 的 README 檔案配置構建情況。

Parcel 整合

打包工具選用的是 Parcel,目的是零配置編譯 TypeScript 。入口檔案為 src 目錄下的 index.html,只需在 入口檔案裡引入 index.ts 即可完成熱更新,TypeScript 編譯等配置:

<body>
  <script src="index.ts"></script>
</body>
複製程式碼

Parcel 相關:

# 全域性安裝
yarn global add parcel-bundler

# 啟動服務
parcel ./src/index.html

# 打包
parcel build ./src/index.ts
複製程式碼

vscode 除錯

執行完 parcel 命令會啟動一個本地伺服器,可以通過 .vscode 目錄下的 launch.json 配置 Vscode 除錯工具。

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "chrome",
      "request": "launch",
      "name": "Lanzar Chrome contra localhost",
      "url": "http://localhost:1234",
      "webRoot": "${workspaceRoot}",
      "sourceMaps": true,
      "breakOnLoad": true,
      "sourceMapPathOverrides": {
        "../*": "${webRoot}/*"
      }
    }
  ]
}
複製程式碼

配置完成後,可斷點除錯,按 F5 即可開始除錯。

TypeScript 重構 Axios 經驗分享

TypeScript 配置

TypeScript 整體配置和規範檢測參考如下:

強烈建議開啟 tslint ,安裝 vscode tslint 外掛 並在 .vscode 目錄下的 .setting 配置如下格式:

{
  "editor.tabSize": 2,
  "editor.rulers": [120],
  "files.trimTrailingWhitespace": true,
  "files.insertFinalNewline": true,
  "files.exclude": {
    "**/.git": true,
    "**/.DS_Store": true
  },
  "eslint.enable": false,
  "tslint.autoFixOnSave": true,
  "typescript.format.enable": true,
  "typescript.tsdk": "node_modules/typescript/lib"
}
複製程式碼

如果有安裝 Prettier需注意兩者風格衝突,無論格式化程式碼的外掛是什麼,我們的目的只有一個,就是 保證程式碼格式化風格統一。( 最好遵循 lint 規範 )。

ps:.vscode 目錄可隨 git 跟蹤進版本管理,這樣可以讓 clone 倉庫的使用者更友好。

另外可以通過,vscode 的 控制皮膚中的問題 tab 迅速檢視當前專案問題所在。

TypeScript 重構 Axios 經驗分享

TypeScript 程式碼片段測試

我們時常會有想要編輯某段測試程式碼,又不想在專案裡編寫的需求(比如用 TypeScript 寫一個 deepCopy 函式),不想脫離 vscode 編輯器的話,推薦使用 quokka,一款可立即執行指令碼的外掛。

TypeScript 重構 Axios 經驗分享

接著像這樣

({
  plugins: 'jsdom-quokka-plugin',
  jsdom: { html: `<div id="test">Hello</div>` }
});

const testDiv = document.getElementById('test');

console.log(testDiv.innerHTML);
複製程式碼

TypeScript 重構 Axios 經驗分享

API 概覽

重構的思路首先是看文件提供的 API,或者 index.d.ts 宣告檔案。 優秀一點的原始碼可以看它的測試用例,一般會提供 API 相關的測試,如 Axios API 測試用例 ,本次分享實現 API 如下:

TypeScript 重構 Axios 經驗分享

總得下來就是五類 API,比葫蘆娃還少。有信心了吧,我們來一個個"送人頭"。

Axios 類

這些 API 可以統稱為例項方法,有例項,就肯定有類。所以在講 API 實現之前,先讓我們來看一下 Axios 類。

TypeScript 重構 Axios 經驗分享

兩個屬性(defaults,interceptors),一個通用方法( request ,其餘的方法如,get、post、等都是基於 request,只是引數不同 )真的不能再簡單了。

export default class Axios {
  defaults: AxiosRequestConfig;
  interceptors: {
    request: InterceptorManager;
    response: InterceptorManager;
  };
  request(config: AxiosRequestConfig = {}) {
    // 請求相關
  }
  // 由 request 延伸出 get 、post 等
}
複製程式碼

axios 例項

Axios 庫預設匯出的是 Axios 的一個例項 axios,而不是 Axios 類本身。但是,這裡並沒有直接返回 Axios 的例項,而是將 Axios 例項方法 request 的上下文設定為了 Axios。 所以 axios 的型別是 function,不是 object。但由於 function 也是 Object 所以可以設定屬性和方法。於是 axios 既可以表現的像例項,又可以直接函式呼叫 axios(config)。具體實現如下:

const createInstance = (defaultConfig: AxiosRequestConfig) => {
  const context = new Axios(defaultConfig);
  const instance = Axios.prototype.request.bind(context);
  extend(instance, Axios.prototype, context);
  extend(instance, context);
  return instance;
};

axios.create = (instanceConfig: AxiosRequestConfig) => {
  return createInstance(mergeConfig(axios.defaults, instanceConfig));
};

const axios: AxiosExport = createInstance(defaults);

axios.Axios = Axios;

export default axios;
複製程式碼

axios 還提供了一個 Axios 類的屬性,可供別的類繼承。另外暴露了一個工廠函式,接收一個配置項引數,方便使用者建立多個不同配置的請求例項。

Axios 預設配置

如果不看原始碼,我們用一個類,最關心的應該是建構函式,預設設定了什麼屬性,以及我們可以修改哪些屬性。體現在 Axios 就是,請求的預設配置。

下面我們來看下預設配置:

const defaults: AxiosRequestConfig = {
  headers: headers(), // 請求頭
  adapter: getDefaultAdapter(), // XMLHttpRequest 傳送請求的具體實現
  transformRequest: transformRequest(), // 自定義處理請求相關資料,預設有提供一個修改根據請求的 data 修改 content-type 的方法。
  transformResponse: transformResponse(), // 自定義處理響應相關資料,預設提供了一個將 respone 資料轉換為 JSON格式的方法
  timeout: 0,
  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-XSRF-TOKEN',
  validateStatus(status: number) {
    return status >= 200 && status < 300;
  }
};
複製程式碼

也就是說,如果你用 Axios ,你應該知道它有哪些預設設定。

Axios 傳入配置

先來看下 axios 接受的請求引數都有哪些屬性,以下引數屬性均是可選的。使用 TypeScript 事先定義了這些引數的型別,接下來傳參的時候就可以檢驗傳參的型別是否正確。

export interface AxiosRequestConfig {
  url?: string; // 請求連結
  method?: string; // 請求方法
  baseURL?: string; // 請求的基礎連結
  xsrfCookieName?: string; // CSRF 相關
  xsrfHeaderName?: string; // CSRF 相關
  headers?: any; // 請求頭設定
  params?: any; // 請求引數
  data?: any; // 請求體
  timeout?: number; // 超時設定
  withCredentials?: boolean; // CSRF 相關
  responseType?: XMLHttpRequestResponseType; // 響應型別
  paramsSerializer?: (params: any) => string; // url query 引數格式化方法
  onUploadProgress?: (progressEvent: any) => void; // 上傳處理函式
  onDownloadProgress?: (progressEvent: any) => void; // 下載處理函式
  validateStatus?: (status: number) => boolean;
  adapter?: AxiosAdapter;
  auth?: any;
  transformRequest?: AxiosTransformer | AxiosTransformer[];
  transformResponse?: AxiosTransformer | AxiosTransformer[];
  cancelToken?: CancelToken;
}
複製程式碼

請求配置

  • url
  • method
  • baseURL
export interface AxiosRequestConfig {
  url?: string; // 請求連結
  method?: string; // 請求方法
  baseURL?: string; // 請求的基礎連結
}
複製程式碼

先來看下相關知識:

url,method 作為 XMLHttpRequestopen 方法的引數。

open 語法: xhrReq.open(method, url, async, user, password);

url 是一個 DOMString,表示傳送請求的 URL。

注意:將 null | undefined 傳遞給接受 DOMString 的方法或引數時通常會把其 stringifies 為 “null” | “undefined”

用原生的 open 方法傳遞如下引數,實際請求 URL 如下:

let xhr = new XMLHttpRequest();

// 假設當前 window.location.host 為 http://localhost:1234

xhr.open('get', ''); // http://localhost:1234/
xhr.open('get', '/'); // href http://localhost:1234/
xhr.open('get', null); // http://localhost:1234/null
xhr.open('get', undefined); // http://localhost:1234/undefined
複製程式碼

可以看到預設 baseURL 為 window.location.host 類似 http://localhost:1234/undefined 這種 URL 請求成功的情況是存在的。當前端動態傳遞 url 引數時,引數是有可能為 nullundefined ,如果不是通過 response 的狀態碼來響應操作,此時得到的結果就跟預想的不一樣。這讓我想起了,JavaScript 隱式轉換的坑,比比皆是。(此處安利 TypeScript 和 '===' 操作符)

TypeScript 重構 Axios 經驗分享

對於這種情況,使用 TypeScript 可以在開發階段規避這些問題。但如果是動態賦值(比如請求返回的結果作為 url 引數時),需要給值判斷下型別,必要時可丟擲錯誤或轉換為其他想要的值。

接著來看下 axios url 相關,主要提供了 baseURL 的支援,可以通過 axios.defaults.baseURLaxios({baseURL:'...'})

const isAbsoluteURL = (url: string): boolean => {
  // 1、判斷是否為協議形式比如 http://
  return /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url);
};
const combineURLs = (baseURL: string, relativeURL: string): string => {
  return relativeURL
    ? baseURL.replace(/\/+$/, '') + '/' + relativeURL.replace(/^\/+/, '')
    : baseURL;
};
const suportBaseURL = () => {
  // 2、baseURL 處理
  return baseURL && !isAbsoluteURL(url) ? combineURLs(baseURL, url) : url;
};
複製程式碼

params 與 data

在 axios 中 傳送請求時 params 和 data 的區別在於:

  • params 是新增到 url 的請求字串中的,用於 get 請求。

  • data 是新增到請求體(body)中的, 用於 post 請求。

params

axios 對 params 的處理分為賦值和序列化(使用者可自定義 paramsSerializer 函式)

TypeScript 重構 Axios 經驗分享

helpers 目錄下的 buildURL 檔案主要生成完整的 URL 請求地址。

data

XMLHttpRequest 是通過 send 方法把 data 新增到請求體的。

語法如下:

send();
send(ArrayBuffer data);
send(ArrayBufferView data);
send(Blob data);
send(Document data);
send(DOMString? data);
send(FormData data);
複製程式碼

可以看到 data 有這幾種型別:

  • ArrayBuffer
  • ArrayBufferView
  • Blob
  • Document
  • DOMString
  • FormData

希望瞭解 data 有哪些型別的可以看這篇

實際使用:

var xhr = new XMLHttpRequest();
xhr.open('GET', '/server', true);

xhr.onload = function() {
  // 請求結束後,在此處寫處理程式碼
};

xhr.send(null);
// xhr.send('string');
// xhr.send(new Blob());
// xhr.send(new Int8Array());
// xhr.send({ form: 'data' });
// xhr.send(document);
複製程式碼

另外,在傳送請求即呼叫 send()方法之前應該根據 data 型別使用 setRequestHeader() 方法設定 Content-Type 頭部來指定資料流的 MIME 型別。

Axios 在 transformRequest 配置項裡有個預設的方法用於修改請求( 可自定義 )。

const transformRequest = () => {
  return [
    (data: any, headers: any) => {
      // ...根據 data 型別修改對應 headers
    }
  ];
};
複製程式碼

HTTP 相關

HTTP 請求方法

axios 提供配置 HTTP 請求的方法:

export interface AxiosRequestConfig {
  method?: string;
}
複製程式碼

可選配置如下:

  • GET:請求一個指定資源的表示形式. 使用 GET 的請求應該只被用於獲取資料.
  • HEAD:HEAD 方法請求一個與 GET 請求的響應相同的響應,但沒有響應體.
  • POST:用於將實體(data)提交到指定的資源,通常導致狀態或伺服器上的副作用的更改.
  • PUT:用請求有效載荷替換目標資源的所有當前表示。
  • DELETE:刪除指定的資源。
  • OPTIONS:用於描述目標資源的通訊選項。
  • PATCH:用於對資源應用部分修改。

接著瞭解下 HTTP 請求

HTTP 定義了一組請求方法, 以表明要對給定資源執行的操作。指示針對給定資源要執行的期望動作. 雖然他們也可以是名詞, 但這些請求方法有時被稱為 HTTP 動詞. 每一個請求方法都實現了不同的語義, 但一些共同的特徵由一組共享:: 例如一個請求方法可以是 safe, idempotent, 或 cacheable.

  • safe:說一個 HTTP 方法是安全的,是說這是個不會修改伺服器的資料的方法。也就是說,這是一個對伺服器只讀操作的方法。這些方法是安全的:GET,HEAD 和 OPTIONS。有些不安全的方法如 PUT 和 DELETE 則不是。

  • idempotent:一個 HTTP 方法是冪等的,指的是同樣的請求被執行一次與連續執行多次的效果是一樣的,伺服器的狀態也是一樣的。換句話說就是,冪等方法不應該具有副作用(統計用途除外)。在正確實現的條件下,GET,HEAD,PUT 和 DELETE 等方法都是冪等的,而 POST 方法不是。所有的 safe 方法也都是冪等的。

  • cacheable:可快取的,響應是可被快取的 HTTP 響應,它被儲存以供稍後檢索和使用,從而將新的請求儲存在伺服器。

篇幅有限,看 MDN

HTTP 請求頭

axios 提供配置 HTTP 請求頭的方法:

export interface AxiosRequestConfig {
  headers?: any;
}
複製程式碼

一個請求頭由名稱(不區分大小寫)後跟一個冒號“:”,冒號後跟具體的值(不帶換行符)組成。該值前面的引導空白會被忽略。

TypeScript 重構 Axios 經驗分享

請求頭可以被定義為:被用於 http 請求中並且和請求主體無關的那一類 HTTP header。某些請求頭如 Accept, Accept-*, If-*``允許執行條件請求。某些請求頭如:Cookie, User-AgentReferer 描述了請求本身以確保服務端能返回正確的響應。

並非所有出現在請求中的 http 首部都屬於請求頭,例如在 POST 請求中經常出現的 Content-Length 實際上是一個代表請求主體大小的 entity header,雖然你也可以把它叫做請求頭。

訊息頭列表

axios 根據請求方法 設定了不同的 Content-TypeAccpect 請求頭。

設定請求頭

XMLHttpRequest 物件提供的 XMLHttpRequest物件提供的.setRequestHeader() 方法為開發者提供了一個操作這兩種頭部資訊的方法,並允許開發者自定義請求頭的頭部資訊。

XMLHttpRequest.setRequestHeader() 是設定 HTTP 請求頭部的方法。此方法必須在 open() 方法和 send() 之間呼叫。如果多次對同一個請求頭賦值,只會生成一個合併了多個值的請求頭。

如果沒有設定 Accept 屬性,則此傳送出 send() 的值為此屬性的預設值/ 。**

安全起見,有些請求頭的值只能由 user agent 設定:forbidden header names 和 forbidden response header names.

預設情況下,當傳送 AJAX 請求時,會附帶以下頭部資訊:

axios 設定程式碼如下:

// 在 adapters 目錄下的 xhr.ts 檔案中:
if ('setRequestHeader' in requestHeaders) {
  // 通過 XHR 的 setRequestHeader 方法設定請求頭資訊
  for (const key in requestHeaders) {
    if (requestHeaders.hasOwnProperty(key)) {
      const val = requestHeaders[key];
      if (
        typeof requestData === 'undefined' &&
        key.toLowerCase() === 'content-type'
      ) {
        delete requestHeaders[key];
      } else {
        request.setRequestHeader(key, val);
      }
    }
  }
}
複製程式碼

至於能不能修改 http header,我的建議是當然不能隨便修改任何欄位。

  • 有一些欄位是絕對不能修改的,比如最重要的 host 欄位,如果沒有 host 值,http1.1 協議會認為這是一個不規範的請求從而直接丟棄。同樣的如果隨便修改這個值,那目的網站也返回不了正確的內容

  • user-agent 也不建議隨便修改,有很多網站是根據這個欄位做內容適配的,比如 PC 和手機肯定是不一樣的內容。

  • 有一些欄位能夠修改,比如 connectioncache-control等。不會影響你的正常訪問,但有可能會慢一點。

  • 還有一些欄位可以刪除,比如你不希望網站記錄你的訪問行為或者歷史資訊,你可以刪除 cookie,referfer 等欄位。

  • 當然你也可以自定義構造任意你想要的欄位,一般沒什麼影響,除非 header 太長導致內容截斷。通常自定義的欄位都建議 X-開頭。比如 X-test: lance。

HTTP 小結

只要是使用者主動輸入網址訪問時傳送的 http 請求,那這些頭部欄位都是瀏覽器自動生成的,比如 host,cookie,user-agent, Accept-Encoding 等。JS 能夠控制瀏覽器發起請求,也能在這裡增加一些 header,但是考慮到安全和效能的原因,對 JS 控制 header 的能力做了一些限制,比如 host 和 cookie, user-agent 等這些欄位,JS 是無法干預的禁止修改的訊息首部。關於 HTTP 的知識實在多,這裡簡單談到相關聯的知識。這裡埋下伏筆,後續若有更適合講 HTTP 的例子,再延伸。

接下來的 CSRF,就會修改 headers。

CSRF

與 CSRF 相關的配置屬性有這三個:

export interface AxiosRequestConfig {
  xsrfCookieName?: string
  xsrfHeaderName?: string
  withCredentials?: boolean;
}

// 預設配置為
{
  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-XSRF-TOKEN',
  withCredentials: false
}
複製程式碼

那麼,先來簡單瞭解 CSRF

跨站請求偽造(英語:Cross-site request forgery),也被稱為 one-click attack 或者 session riding,通常縮寫為 CSRF 或者 XSRF, 是一種挾制使用者在當前已登入的 Web 應用程式上執行非本意的操作的攻擊方法。跟跨網站指令碼(XSS)相比,XSS 利用的是使用者對指定網站的信任,CSRF 利用的是網站對使用者網頁瀏覽器的信任。

什麼是 CSRF 攻擊?

你這可以這麼理解 CSRF 攻擊:攻擊者盜用了你的身份,以你的名義傳送惡意請求。CSRF 能夠做的事情包括:以你名義傳送郵件,發訊息,盜取你的賬號,甚至於購買商品,虛擬貨幣轉賬。造成的問題包括:個人隱私洩露以及財產安全。

CSRF 原理

在他們的釣魚站點,攻擊者可以通過建立一個 AJAX 按鈕或者表單來針對你的網站建立一個請求:

<form action="https://my.site.com/me/something-destructive" method="POST">
  <button type="submit">Click here for free money!</button>
</form>
複製程式碼

要完成一次 CSRF 攻擊,受害者必須依次完成兩個步驟:

1.登入受信任網站 A,並在本地生成 Cookie。

2.在不登出 A 的情況下,訪問危險網站 B。

如果減輕 CSRF 攻擊?

只使用 JSON api

使用 JavaScript 發起 AJAX 請求是限制跨域的。 不能通過一個簡單的 <form> 來傳送 JSON, 所以,通過只接收 JSON,你可以降低發生上面那種情況的可能性。

禁用 CORS

第一種減輕 CSRF 攻擊的方法是禁用 cross-origin requests(跨域請求)。如果你希望允許跨域請求,那麼請只允許 OPTIONS, HEAD, GET 方法,因為他們沒有副作用。不幸的是,這不會阻止上面的請求由於它沒有使用 JavaScript(因此 CORS 不適用)。

檢查 Referer 欄位

HTTP 頭中有一個 Referer 欄位,這個欄位用以標明請求來源於哪個地址。在處理敏感資料請求時,通常來說,Referer 欄位應和請求的地址位於同一域名下。這種辦法簡單易行,工作量低,僅需要在關鍵訪問處增加一步校驗。但這種辦法也有其侷限性,因其完全依賴瀏覽器傳送正確的 Referer 欄位。雖然 http 協議對此欄位的內容有明確的規定,但並無法保證來訪的瀏覽器的具體實現,亦無法保證瀏覽器沒有安全漏洞影響到此欄位。並且也存在攻擊者攻擊某些瀏覽器,篡改其 Referer 欄位的可能。(PS:可見遵循 web 標準多麼重要)

CSRF Tokens

最終的解決辦法是使用 CSRF tokens。 CSRF tokens 是如何工作的呢?

  1. 伺服器傳送給客戶端一個 token。
  2. 客戶端提交的表單中帶著這個 token。
  3. 如果這個 token 不合法,那麼伺服器拒絕這個請求。

攻擊者需要通過某種手段獲取你站點的 CSRF token, 他們只能使用 JavaScript 來做。 所以,如果你的站點不支援 CORS, 那麼他們就沒有辦法來獲取 CSRF token, 降低了威脅。

確保 CSRF token 不能通過 AJAX 訪問到!

不要建立一個/CSRF路由來獲取一個 token, 尤其不要在這個路由上支援 CORS!

token 需要是不容易被猜到的, 讓它很難被攻擊者嘗試幾次得到。 它不需要是密碼安全的。 攻擊來自從一個未知的使用者的一次或者兩次的點選, 而不是來自一臺伺服器的暴力攻擊。

axios 中的 CSRF Tokens

這裡有個 withCredentials ,先來了解下。

XMLHttpRequest.withCredentials 屬性是一個 Boolean 型別,它指示了是否該使用類似 cookies,authorization headers(頭部授權)或者 TLS 客戶端證照這一類資格證照來建立一個跨站點訪問控制(cross-site Access-Control)請求。在同一個站點下使用 withCredentials 屬性是無效的。

如果在傳送來自其他域的 XMLHttpRequest 請求之前,未設定 withCredentials 為 true,那麼就不能為它自己的域設定 cookie 值。而通過設定 withCredentials 為 true 獲得的第三方 cookies,將會依舊享受同源策略,因此不能被通過 document.cookie 或者從頭部相應請求的指令碼等訪問。

// 在標準瀏覽器環境下 (非 web worker 或者 react-native) 則新增 xsrf 頭
if (isStandardBrowserEnv()) {
  // 必須在 withCredentials 或 同源的情況,才設定 xsrfHeader 頭
  const xsrfValue =
    (withCredentials || isURLSameOrigin(url)) && xsrfCookieName
      ? cookies.read(xsrfCookieName)
      : undefined;
  if (xsrfValue && xsrfHeaderName) {
    requestHeaders[xsrfHeaderName] = xsrfValue;
  }
}
複製程式碼

CSRF 小結

對於 CSRF,需要讓後端同學,敏感的請求不要使用類似 get 這種冪等的,但是由於 Form 表單發起的 POST 請求並不受 CORS 的限制,因此可以任意地使用其他域的 Cookie 向其他域傳送 POST 請求,形成 CSRF 攻擊。

這時,如果有涉及敏感資訊的請求,需要跟後端同學配合,進行 XSRF-Token 認證。此時,我們用 axios 請求的時候,就可以通過設定 XMLHttpRequest.withCredentials=true 以及設定 axios({xsrfCookieName:'',xsrfHeaderName:''}),不使用則會用預設的 XSRF-TOKENX-XSRF-TOKEN(拿這個跟後端配合即可)。

所以,axios 特性中,客戶端支援防止 CSRF/XSRF。只是方便設定 CORF-TOKEN ,關鍵還是要後端同學的介面支援。(PS:前後端相親相愛多重要,所以作為前端的我們還是儘可能多瞭解這方面的知識

XHR 實現

axios 通過介面卡模式,提供了支援 node.js 的 http 以及客戶端的 XMLHttpRequest 的兩張實現,本文主要講解 XHR 實現。

大概的實現邏輯如下:

const xhrAdapter = (config: AxiosRequestConfig): AxiosPromise => {
  return new Promise((resolve, reject) => {
    let request: XMLHttpRequest | null = new XMLHttpRequest();
    setHeaders();
    openXHR();
    setXHR();
    sendXHR();
  });
};
複製程式碼

如果逐行講解,不如錄個教程視訊,建議大家直接看 adapters 目錄下的 xhr.ts ,在關鍵地方都有註釋!

  1. xhrAdapter 接受 config 引數 ( 由預設引數和使用者例項化時傳入引數的合併值,axios 對合並值由做特殊處理。 )
  2. 設定請求頭,比如根據傳入的引數 dataauth,xsrfHeaderName 設定對應的 headers
  3. setXHR 主要是在 request.readyState === 4 的時候對響應資料作處理以及錯誤處理
  4. 最後執行 XMLHttpRequest.send 方法

返回的是一個 Promise 物件,所以支援 Promise 的所有特性。

請求攔截

請求攔截在 axios 應該算是一個比較騷的操作,實現非常簡單。有點像一系列按順序執行的 Promise。

直接看程式碼實現:

  // interceptors 分為 request 和 response。

  interface interceptors {
    request: InterceptorManager;
    response: InterceptorManager;
  }

  request (config: AxiosRequestConfig = {}) {
    const { method } = config
    const newConfig: AxiosRequestConfig = {
      ...this.defaults,
      ...config,
      method: method ? method.toLowerCase() : 'get'
    }

    // 攔截器原理:[請求攔截器,傳送請求,響應攔截器] 順序執行

    // 1、建立一個存放 [ resolve , reject ] 的陣列,
    // 這裡如果沒有攔截器,則執行傳送請求的操作。
    // 由於之後都是 resolve 和 reject 的組合,所以這裡預設 undefined。真是騷操作!

    const chain = [ dispatchRequest, undefined ]

    // 2、Promise 成功後會往下傳遞引數,於是這裡先傳入合併後的引數,供之後的攔截器使用 (如果有的話)。
    let promise: any = Promise.resolve(newConfig)

    // 3、又是一波騷操作,完美的運用了陣列的方法。咋不用 reduce 實現 promise 順序執行呢 ?
    // request 請求攔截器肯定需要 `dispatchRequest` 在前面,於是 [interceptor.fulfilled, interceptor.rejected, dispatchRequest, undefined]
    this.interceptors.request.forEach((interceptor: Interceptor) => {
      chain.unshift(interceptor.fulfilled, interceptor.rejected)
    })
    // response 響應攔截器肯定需要在 `dispatchRequest` 後面,於是 [dispatchRequest, undefined,interceptor.fulfilled, interceptor.rejected]
    this.interceptors.response.forEach((interceptor: Interceptor) => {
      chain.push(interceptor.fulfilled, interceptor.rejected)
    })

    // 4、依次執行 Promise( fulfilled,rejected )
    while (chain.length) {
      promise = promise.then(chain.shift(), chain.shift())
    }

    return promise
  }
複製程式碼

又是對基礎知識的完美運用,無論是 Promise 還是陣列的變異方法都算巧妙運用。

當然,Promise 的順序執行還可以這樣:

function sequenceTasks(tasks) {
  function recordValue(results, value) {
    results.push(value);
    return results;
  }
  var pushValue = recordValue.bind(null, []);
  return tasks.reduce(function(promise, task) {
    return promise.then(task).then(pushValue);
  }, Promise.resolve());
}
複製程式碼

取消請求

如果不知道 XMLHttpRequest 有 absort 方法,肯定會覺得取消請求這種秀操作的怎麼可能呢!( PS:基礎知識多重要 )

const { cancelToken } = config;
const request = new XMLHttpRequest();

if (cancelToken) {
  cancelToken.promise
    .then(cancel => {
      if (!request) {
        return;
      }
      request.abort();
      reject(cancel);
      request = null;
    })
    .catch(err => {
      console.error(err);
    });
}
複製程式碼

至於 CancelToken 就不講了,好奇怪的實現。沒有感悟到原作者的設計真諦!

單元測試

最後到了單元測試的環節,先來看下相關依賴。

TypeScript 重構 Axios 經驗分享

用的是 karma,配置如下:

TypeScript 重構 Axios 經驗分享

執行命令:

yarn test
複製程式碼

TypeScript 重構 Axios 經驗分享

本專案是基於 jasmine 來寫測試用例,還是比較簡單的。

karma 會跑 test 目錄下的所有測試用例,感覺測試用例用 TypeScript 來寫,有點難受。因為測試本來就是要讓引數多樣化,然而 TypeScript 事先規定了資料型別。雖然可以使用泛型來解決,但是總覺得有點變扭。

不過,整個測試用例跑下來,程式碼強壯了很多。對於這種庫來說,還是很有必要的。如果需要二次重構,基於 TypeScript 和 有覆蓋大部分函式的單元測試支援,應該會容易很多。

總結

感謝能看到這裡的朋友,想必也是 TypeScript 或 Axios 的粉絲,不妨相互認識一下。

還是那句話,TypeScript 確實好用。短時間內就能將 Axios 大致重構了一遍,感興趣的可以跟著一起。老規矩,在分享中不會具體講庫怎麼用 (想必,如果自己擼完這麼一個專案,應該不用去看 API 了吧。) ,更多的是從廣度擴充大家的知識點。如果對某個關鍵詞比較陌生,這就是進步的時候了。比如筆者接下來要去深入涉略 HTTP 了。雖然,感覺目前 TypeScript 的熱度好像好不是很高。好東西,總是那些不容易變的。哈,別到時候打臉了。

我變強了嗎? 不扯了,聽楊宗緯的 "我變了,我沒變" 了。

切記,沒有什麼是看原始碼解決不了的 bug。

參考

相關文章