閱讀完本文,下面的問題會迎刃而解,
- Axios 的適配器原理是什么?
- Axios 是如何實現請求和響應攔截的?
- Axios 取消請求的實現原理?
- CSRF 的原理是什么?Axios 是如何防范客戶端 CSRF 攻擊?
- 請求和響應數據轉換是怎么實現的?
我們以特性作為入口,解答上述問題的同時一起感受下 Axios 源碼極簡封裝的藝術。
Features
- 從瀏覽器創建 XMLHttpRequest
- 從 Node.js 創建 HTTP 請求
- 支持 Promise API
- 攔截請求與響應
- 取消請求
- 自動裝換 JSON 數據
- 支持客戶端 XSRF 攻擊
前兩個特性解釋了為什么 Axios 可以同時用于瀏覽器和 Node.js 的原因,簡單來說就是通過判斷是服務器還是瀏覽器環境,來決定使用 XMLHttpRequest
還是 Node.js 的 HTTP 來創建請求,這個兼容的邏輯被叫做適配器,對應的源碼在 lib/defaults.js
中,
// defaults.js
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;
}
以上是適配器的判斷邏輯,通過偵測當前環境的一些全局變量,決定使用哪個 adapter。其中對于 Node 環境的判斷邏輯在我們做 ssr
服務端渲染的時候,也可以復用。接下來我們來看一下 Axios 對于適配器的封裝。
Adapter xhr
定位到源碼文件 lib/adapters/xhr.js
,先來看下整體結構,
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// ...
})
}
導出了一個函數,接受一個配置參數,返回一個 Promise。我們把關鍵的部分提取出來,
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// ...
})
}
是不是感覺很熟悉?沒錯,這就是 XMLHttpRequest
的使用姿勢呀,先創建了一個 xhr 然后 open 啟動請求,監聽 xhr 狀態,然后 send 發送請求。我們來展開看一下 Axios 對于 onreadystatechange 的處理,
request.onreadystatechange = function handleLoad() {
if (!request || request.readyState !== 4) {
return;
}
// The request errored out and we didn't get a response, this will be
// handled by onerror instead
// With one exception: request that using file: protocol, most browsers
// will return status as 0 even though it's a successful request
if (request.status === 0 && !(request.responseURL && request.responseURL.indexOf('file:') === 0)) {
return;
}
// Prepare the response
var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null;
var responseData = !config.responseType || config.responseType === 'text' ? request.responseText : request.response;
var response = {
data: responseData,
status: request.status,
statusText: request.statusText,
headers: responseHeaders,
config: config,
request: request
};
settle(resolve, reject, response);
// Clean up request
request = null;
};
首先對狀態進行過濾,只有當請求完成時(readyState === 4)才往下處理。需要注意的是,如果 XMLHttpRequest 請求出錯,大部分的情況下我們可以通過監聽 onerror
進行處理,但是也有一個例外:當請求使用文件協議(file://
)時,盡管請求成功了但是大部分瀏覽器也會返回 0
的狀態碼。
Axios 針對這個例外情況也做了處理。
請求完成后,就要處理響應了。這里將響應包裝成一個標準格式的對象,作為第三個參數傳遞給了 settle 方法,settle 在 lib/core/settle.js
中定義,
function settle(resolve, reject, response) {
var validateStatus = response.config.validateStatus;
if (!response.status || !validateStatus || validateStatus(response.status)) {
resolve(response);
} else {
reject(createError(
'Request failed with status code ' + response.status,
response.config,
null,
response.request,
response
));
}
};
settle 對 Promise 的回調進行了簡單的封裝,確保調用按一定的格式返回。
以上就是 xhrAdapter 的主要邏輯,剩下的是對請求頭,支持的一些配置項以及超時,出錯,取消請求等回調的簡單處理,其中對于 XSRF 攻擊的防范是通過請求頭實現的。
我們先來簡單回顧下什么是 XSRF (也叫 CSRF,跨站請求偽造)。
CSRF
背景:用戶登錄后,需要存儲登錄憑證保持登錄態,而不用每次請求都發送賬號密碼。
怎么樣保持登錄態呢?
目前比較常見的方式是,服務器在收到 HTTP請求后,在響應頭里添加 Set-Cookie 選項,將憑證存儲在 Cookie 中,瀏覽器接受到響應后會存儲 Cookie,根據瀏覽器的同源策略,下次向服務器發起請求時,會自動攜帶 Cookie 配合服務端驗證從而保持用戶的登錄態。
所以如果我們沒有判斷請求來源的合法性,在登錄后通過其他網站向服務器發送了偽造的請求,這時攜帶登錄憑證的 Cookie 就會隨著偽造請求發送給服務器,導致安全漏洞,這就是我們說的 CSRF,跨站請求偽造。
所以防范偽造請求的關鍵就是檢查請求來源,refferer 字段雖然可以標識當前站點,但是不夠可靠,現在業界比較通用的解決方案還是在每個請求上附帶一個 anti-CSRF token,這個的原理是攻擊者無法拿到 Cookie,所以我們可以通過對 Cookie 進行加密(比如對 sid 進行加密),然后配合服務端做一些簡單的驗證,就可以判斷當前請求是不是偽造的。
Axios 簡單地實現了對特殊 csrf token 的支持,
// Add xsrf header
// This is only done if running in a standard browser environment.
// Specifically not if we're in a web worker, or react-native.
if (utils.isStandardBrowserEnv()) {
// Add xsrf header
var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
cookies.read(config.xsrfCookieName) :
undefined;
if (xsrfValue) {
requestHeaders[config.xsrfHeaderName] = xsrfValue;
}
}
Interceptor
攔截器是 Axios 的一個特色 Feature,我們先簡單回顧下使用方式,
// Add xsrf header
// This is only done if running in a standard browser environment.
// Specifically not if we're in a web worker, or react-native.
if (utils.isStandardBrowserEnv()) {
// Add xsrf header
var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
cookies.read(config.xsrfCookieName) :
undefined;
if (xsrfValue) {
requestHeaders[config.xsrfHeaderName] = xsrfValue;
}
}
那么攔截器是怎么實現的呢?
定位到源碼 lib/core/Axios.js
第 14 行,
function Axios(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
通過 Axios 的構造函數可以看到,攔截器 interceptors 中的 request 和 response 兩者都是一個叫做 InterceptorManager 的實例,這個 InterceptorManager 是什么?
定位到源碼 lib/core/InterceptorManager.js
,
function InterceptorManager() {
this.handlers = [];
}
InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected,
synchronous: options ? options.synchronous : false,
runWhen: options ? options.runWhen : null
});
return this.handlers.length - 1;
};
InterceptorManager.prototype.eject = function eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null;
}
};
InterceptorManager.prototype.forEach = function forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) {
fn(h);
}
});
};
InterceptorManager 是一個簡單的事件管理器,實現了對攔截器的管理,
通過 handlers 存儲攔截器,然后提供了添加,移除,遍歷執行攔截器的實例方法,存儲的每一個攔截器對象都包含了作為 Promise 中 resolve 和 reject 的回調以及兩個配置項。
值得一提的是,移除方法是通過直接將攔截器對象設置為 null 實現的,而不是 splice 剪切數組,遍歷方法中也增加了相應的 null 值處理。這樣做一方面使得每一項ID保持為項的數組索引不變,另一方面也避免了重新剪切拼接數組的性能損失。
攔截器的回調會在請求或響應的 then 或 catch 回調前被調用,這是怎么實現的呢?
回到源碼 lib/core/Axios.js
中第 27 行,Axios 實例對象的 request 方法,
我們提取其中的關鍵邏輯如下,
Axios.prototype.request = function request(config) {
// Get merged config
// Set config.method
// ...
var requestInterceptorChain = [];
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});
var responseInterceptorChain = [];
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
});
var promise;
var chain = [dispatchRequest, undefined];
Array.prototype.unshift.apply(chain, requestInterceptorChain);
chain.concat(responseInterceptorChain);
promise = Promise.resolve(config);
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
};
可以看到,當執行 request 時,實際的請求(dispatchRequest)和攔截器是通過一個叫 chain 的隊列來管理的。整個請求的邏輯如下,
- 首先初始化請求和響應的攔截器隊列,將 resolve,reject 回調依次放入隊頭
- 然后初始化一個 Promise 用來執行回調,chain 用來存儲和管理實際請求和攔截器
- 將請求攔截器放入 chain 隊頭,響應攔截器放入 chain 隊尾
- 隊列不為空時,通過 Promise.then 的鏈式調用,依次將請求攔截器,實際請求,響應攔截器出隊
- 最后返回鏈式調用后的 Promise
這里的實際請求是對適配器的封裝,請求和響應數據的轉換都在這里完成。
那么數據轉換是如何實現的呢?
Transform data
定位到源碼 lib/core/dispatchRequest.js
,
Axios.prototype.request = function request(config) {
// Get merged config
// Set config.method
// ...
var requestInterceptorChain = [];
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});
var responseInterceptorChain = [];
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
});
var promise;
var chain = [dispatchRequest, undefined];
Array.prototype.unshift.apply(chain, requestInterceptorChain);
chain.concat(responseInterceptorChain);
promise = Promise.resolve(config);
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
};
這里的 throwIfCancellationRequested 方法用于取消請求,關于取消請求稍后我們再討論,可以看到發送請求是通過調用適配器實現的,在調用前和調用后會對請求和響應數據進行轉換。
轉換通過 transformData 函數實現,它會遍歷調用設置的轉換函數,轉換函數將 headers 作為第二個參數,所以我們可以根據 headers 中的信息來執行一些不同的轉換操作,
// 源碼 core/transformData.js
function transformData(data, headers, fns) {
utils.forEach(fns, function transform(fn) {
data = fn(data, headers);
});
return data;
};
Axios 也提供了兩個默認的轉換函數,用于對請求和響應數據進行轉換。默認情況下,
Axios 會對請求傳入的 data 做一些處理,比如請求數據如果是對象,會序列化為 JSON 字符串,響應數據如果是 JSON 字符串,會嘗試轉換為 JavaScript 對象,這些都是非常實用的功能,
對應的轉換器源碼可以在 lib/default.js
的第 31 行找到,
var defaults = {
// Line 31
transformRequest: [function transformRequest(data, headers) {
normalizeHeaderName(headers, 'Accept');
normalizeHeaderName(headers, 'Content-Type');
if (utils.isFormData(data) ||
utils.isArrayBuffer(data) ||
utils.isBuffer(data) ||
utils.isStream(data) ||
utils.isFile(data) ||
utils.isBlob(data)
) {
return data;
}
if (utils.isArrayBufferView(data)) {
return data.buffer;
}
if (utils.isURLSearchParams(data)) {
setContentTypeIfUnset(headers, 'application/x-www-form-urlencoded;charset=utf-8');
return data.toString();
}
if (utils.isObject(data)) {
setContentTypeIfUnset(headers, 'application/json;charset=utf-8');
return JSON.stringify(data);
}
return data;
}],
transformResponse: [function transformResponse(data) {
var result = data;
if (utils.isString(result) && result.length) {
try {
result = JSON.parse(result);
} catch (e) { /* Ignore */ }
}
return result;
}],
}
我們說 Axios 是支持取消請求的,怎么個取消法呢?
CancelToken
其實不管是瀏覽器端的 xhr 或 Node.js 里 http 模塊的 request 對象,都提供了 abort 方法用于取消請求,所以我們只需要在合適的時機調用 abort 就可以實現取消請求了。
那么,什么是合適的時機呢?控制權交給用戶就合適了。所以這個合適的時機應該由用戶決定,也就是說我們需要將取消請求的方法暴露出去,Axios 通過 CancelToken 實現取消請求,我們來一起看下它的姿勢。
首先 Axios 提供了兩種方式創建 cancel token,
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
// 方式一,使用 CancelToken 實例提供的靜態屬性 source
axios.post("/user/12345", { name: "monch" }, { cancelToken: source.token });
source.cancel();
// 方式二,使用 CancelToken 構造函數自己實例化
let cancel;
axios.post(
"/user/12345",
{ name: "monch" },
{
cancelToken: new CancelToken(function executor(c) {
cancel = c;
}),
}
);
cancel();
到底什么是 CancelToken?定位到源碼 lib/cancel/CancelToken.js
第 11 行,
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
// 方式一,使用 CancelToken 實例提供的靜態屬性 source
axios.post("/user/12345", { name: "monch" }, { cancelToken: source.token });
source.cancel();
// 方式二,使用 CancelToken 構造函數自己實例化
let cancel;
axios.post(
"/user/12345",
{ name: "monch" },
{
cancelToken: new CancelToken(function executor(c) {
cancel = c;
}),
}
);
cancel();
CancelToken 就是一個由 promise 控制的極簡的狀態機,實例化時會在實例上掛載一個 promise,這個 promise 的 resolve 回調暴露給了外部方法 executor,這樣一來,我們從外部調用這個 executor方法后就會得到一個狀態變為 fulfilled 的 promise,那有了這個 promise 后我們如何取消請求呢?
是不是只要在請求時拿到這個 promise 實例,然后在 then 回調里取消請求就可以了?
定位到適配器的源碼 lib/adapters/xhr.js
第 158 行,
if (config.cancelToken) {
// Handle cancellation
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) {
return;
}
request.abort();
reject(cancel);
// Clean up request
request = null;
});
}
以及源碼 lib/adaptors/http.js
第 291 行,
if (config.cancelToken) {
// Handle cancellation
config.cancelToken.promise.then(function onCanceled(cancel) {
if (req.aborted) return;
req.abort();
reject(cancel);
});
}
果然如此,在適配器里 CancelToken 實例的 promise 的 then 回調里調用了 xhr 或 http.request 的 abort 方法。試想一下,如果我們沒有從外部調用取消 CancelToken 的方法,是不是意味著 resolve 回調不會執行,適配器里的 promise 的 then 回調也不會執行,就不會調用 abort 取消請求了。
小結
Axios 通過適配器的封裝,使得它可以在保持同一套接口規范的前提下,同時用在瀏覽器和 node.js 中。源碼中大量使用 Promise 和閉包等特性,實現了一系列的狀態控制,其中對于攔截器,取消請求的實現體現了其極簡的封裝藝術,值得學習和借鑒。
參考鏈接
- Axios Docs - axios-http.com[1]
- Axios Github Source Code[2]
- 源碼拾遺 Axios —— 極簡封裝的藝術[3]
- Cross Site Request Forgery - Part III. Web Application Security[4]
- tc39/proposal-cancelable-promises[5]