拒絕做一個只會用 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 還是很簡單的,來一起探索吧!
目錄說明
首先來看下目錄。
目錄與 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 配置
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 程式碼片段測試
我們時常會有想要編輯某段測試程式碼,又不想在專案裡編寫的需求(比如用 TypeScript 寫一個 deepCopy 函式),不想脫離 vscode 編輯器的話,推薦使用 quokka,一款可立即執行指令碼的外掛。
- 如果需要匯入其他庫可參考quokka 配置
- 希望引入瀏覽器環境,可在 quokkajs 專案目錄全域性安裝jsdom-quokka-plugin外掛
接著像這樣
({
plugins: 'jsdom-quokka-plugin',
jsdom: { html: `<div id="test">Hello</div>` }
});
const testDiv = document.getElementById('test');
console.log(testDiv.innerHTML);
複製程式碼
API 概覽
重構的思路首先是看文件提供的 API,或者 index.d.ts
宣告檔案。 優秀一點的原始碼可以看它的測試用例,一般會提供 API 相關的測試,如 Axios API 測試用例 ,本次分享實現 API 如下:
總得下來就是五類 API,比葫蘆娃還少。有信心了吧,我們來一個個"送人頭"。
Axios 類
這些 API 可以統稱為例項方法,有例項,就肯定有類。所以在講 API 實現之前,先讓我們來看一下 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 作為 XMLHttpRequest 中 open 方法的引數。
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 引數時,引數是有可能為 null
或 undefined
,如果不是通過 response 的狀態碼來響應操作,此時得到的結果就跟預想的不一樣。這讓我想起了,JavaScript 隱式轉換的坑,比比皆是。(此處安利 TypeScript 和 '===' 操作符)
對於這種情況,使用 TypeScript 可以在開發階段規避這些問題。但如果是動態賦值(比如請求返回的結果作為 url 引數時),需要給值判斷下型別,必要時可丟擲錯誤或轉換為其他想要的值。
接著來看下 axios url 相關,主要提供了 baseURL 的支援,可以通過 axios.defaults.baseURL
或 axios({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 函式)
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;
}
複製程式碼
一個請求頭由名稱(不區分大小寫)後跟一個冒號“:”,冒號後跟具體的值(不帶換行符)組成。該值前面的引導空白會被忽略。
請求頭可以被定義為:被用於 http 請求中並且和請求主體無關的那一類 HTTP header。某些請求頭如
Accept
,Accept-*
,If-*``允許執行條件請求。某些請求頭如:Cookie
,User-Agent
和Referer
描述了請求本身以確保服務端能返回正確的響應。
並非所有出現在請求中的 http 首部都屬於請求頭,例如在 POST 請求中經常出現的 Content-Length
實際上是一個代表請求主體大小的 entity header,雖然你也可以把它叫做請求頭。
axios 根據請求方法 設定了不同的 Content-Type
和 Accpect
請求頭。
設定請求頭
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 和手機肯定是不一樣的內容。
-
有一些欄位能夠修改,比如
connection
,cache-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 是如何工作的呢?
- 伺服器傳送給客戶端一個 token。
- 客戶端提交的表單中帶著這個 token。
- 如果這個 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-TOKEN
和 X-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
,在關鍵地方都有註釋!
- xhrAdapter 接受 config 引數 ( 由預設引數和使用者例項化時傳入引數的合併值,axios 對合並值由做特殊處理。 )
- 設定請求頭,比如根據傳入的引數
data
,auth
,xsrfHeaderName
設定對應的 headers setXHR
主要是在request.readyState === 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
就不講了,好奇怪的實現。沒有感悟到原作者的設計真諦!
單元測試
最後到了單元測試的環節,先來看下相關依賴。
用的是 karma,配置如下:
執行命令:
yarn test
複製程式碼
本專案是基於 jasmine
來寫測試用例,還是比較簡單的。
karma 會跑 test 目錄下的所有測試用例,感覺測試用例用 TypeScript 來寫,有點難受。因為測試本來就是要讓引數多樣化,然而 TypeScript 事先規定了資料型別。雖然可以使用泛型來解決,但是總覺得有點變扭。
不過,整個測試用例跑下來,程式碼強壯了很多。對於這種庫來說,還是很有必要的。如果需要二次重構,基於 TypeScript 和 有覆蓋大部分函式的單元測試支援,應該會容易很多。
總結
感謝能看到這裡的朋友,想必也是 TypeScript 或 Axios 的粉絲,不妨相互認識一下。
還是那句話,TypeScript 確實好用。短時間內就能將 Axios 大致重構了一遍,感興趣的可以跟著一起。老規矩,在分享中不會具體講庫怎麼用 (想必,如果自己擼完這麼一個專案,應該不用去看 API 了吧。) ,更多的是從廣度擴充大家的知識點。如果對某個關鍵詞比較陌生,這就是進步的時候了。比如筆者接下來要去深入涉略 HTTP 了。雖然,感覺目前 TypeScript 的熱度好像好不是很高。好東西,總是那些不容易變的。哈,別到時候打臉了。
我變強了嗎? 不扯了,聽楊宗緯的 "我變了,我沒變" 了。
切記,沒有什麼是看原始碼解決不了的 bug。