本篇會藉由設計「取消重複請求機制」來解析 axios 的原始碼,篇幅較長請耐心閱讀。
其實要實踐取消請求的功能並不會很難,官方也有一目瞭然的 教學,不過我自己在實作後一直對於 cancelToken
的原理耿耿於懷,就去研究了一下原始碼,所以在實際撰寫之前,想先分享一下我的理解。
接下來我們會直接看打包過的檔案: axios/dist/axios.js
,所有 axios 的程式碼都在這。你可以一邊看 github 一邊看文章。
cancelToken
可以為我們取消多餘或不必要的 http請求
,雖然在一般情況下可能感覺不到有取消請求的必要,不過在一些特殊情況中沒有好好處理的話,可能會導致一些問題發生。像是...
Pending
時間較久的 API
若短時間內重複請求,會有舊蓋新的情況。post
請求,有可能導致多次的資料操作,例如表單發送兩次。先從最主要的 Axios類別
看起,每一個 axios 應用都會創建一個 Axios類別
,而當中最核心的就是 request
方法,不過我們先暫時跳過。
後面兩段則是在類別上又新增了好幾個方法,讓我們可以發起不同的http請求: axios.get()
、axios.post()
。
不過仔細一看會發現,最終我們呼叫的還是 request
,所以才會說 request
是 axios 的核心。
function Axios(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
Axios.prototype.request = function request(config) {
// ...先跳過
};
// 幫不同的請求方法創建別名,最終都是呼叫request
utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
Axios.prototype[method] = function(url, config) {
return this.request(utils.merge(config || {}, {
method: method,
url: url
}));
};
});
utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
Axios.prototype[method] = function(url, data, config) {
return this.request(utils.merge(config || {}, {
method: method,
url: url,
data: data
}));
};
});
在前面我們有看到,Axios類別
中有個 interceptors
屬性,其值為物件,並且有 request
和 response
的屬性。
這兩個屬性都是 InterceptorManager類別
,而這個類別是用來管理攔截器的,我之也寫過 一篇 在介紹攔截器是什麼,不曉得的人可以去看一下。
而今天我們就是要用Axios的攔截器來達到取消重複請求的功能,所以來看看 InterceptorManager
吧。
function InterceptorManager() {
// 儲存攔截器的方法,未來陣列裡會放入物件,每個物件會有兩個屬性分別對應成功和失敗後的函式
this.handlers = [];
}
// 在攔截器裡新增一組函式,我們在上一篇有用過
InterceptorManager.prototype.use = function use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1;
};
// 註銷攔截器裡的某一組函式
InterceptorManager.prototype.eject = function eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null;
}
};
// 原碼的寫法我覺得很容易看不懂,所以我改寫了一下
// 簡單來說就是拿handlers跑迴圈,把裡面的物件當作參數來給fn執行
InterceptorManager.prototype.forEach = function(fn) {
this.handlers.forEach(obj => {
fn(h);
});
};
基本上這個類別還蠻單純的,主要就是三個操作 handlers
的方法,我們之前就是透過 axios.interceptors.request.use
和 axios.interceptors.response.use
來添加攔截器的。
但現在我們要再更深入了解Axios是怎麼在請求前後透過攔截器處理 request
和 response
的,這時候就要回去看 Axios.prototype.request
了。
可以發現,每當我們發送請求 Axios.prototype.request
會宣告一個陣列以及一個Promise物件。
並且利用 InterceptorManager.prototype.forEach
把我們攔截器中新增的函式一一放進 chain
中。
至於 dispatchRequest
就是Axios主要發送 XMLHttpRequest
的函式,我們等等會提到。
當所有函式都放進 chain
後再兩兩一組拿出來作為 promise.then()
的參數,而且利用Promise的鏈式呼叫來串接。
最後我們的請求就可以依照 request攔截器 -> dispatchRequest -> response攔截器
的順序進行處理。
Axios.prototype.request = function request(config) {
//..省略
var chain = [dispatchRequest, undefined];
// 定義一個狀態是resolve的Promise; config是發出請求時帶的設定
var promise = Promise.resolve(config);
// InterceptorManager.prototype.forEach,把request攔截器的每一組函式「往前」加進chain裡
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
// InterceptorManager.prototype.forEach,把response攔截器的每一組函式「往後」加進chain裡
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
// 全部加進去後,chain會長的像是這樣: [
// request.handlers[0].fulfilled, request.handlers[0].rejected, ...,
// dispatchRequest, undefined,
// response.handlers[0].fulfilled, response.handlers[0].rejected, ...,
// ]
// 只要chain裡還有項目,就繼續執行
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
};
最後把所有的函數串接起來後,promise
會像是下面這樣,並且 Axios.prototype.request
會把這個 promise
返回出來,所以我們才可以在呼叫 axios.get()
之後直接用 then()
。
Promise.resolve(config)
.then(requestFulfilled, requestRejected)
.then(dispatchRequest, undefined)
.then(responseFulfilled, responseRejected)
Promise
已經是 resolve
狀態,所以請求攔截器會拿到 config
來做前置處理。config
,所以 dispatchRequest
才能拿到 config
來發送請求。dispatchRequest
在完成 XMLHttpRequest
後會返回請求的 response
給回應攔截器。response
,所以你最後才可以拿到API資料。現在知道了攔截器是如何串接的了,那 dispatchRequest
是如何發送http請求的呢?
我們只看重點部分,當中 adapter
會根據發送請求的環境對應到不同的適配器(建立請求的函式),而 dispatchRequest
會再以 then()
串接,由http請求的成功或失敗來決定要進入回應攔截器的 fulfilled
函式或 rejected
函式。
module.exports = function dispatchRequest(config) {
// 檢查請求是否被取消的函式
throwIfCancellationRequested(config);
// axios會使用預設的http請求適配器,除非你有特別設定
// 以瀏覽器發送請求會使用xhrAdapter,node環境則使用httpAdapter
var adapter = config.adapter || defaults.adapter;
// 適配器會把http請求包裝成Promise並返回,dispatchRequest再以then()串接
return adapter(config).then(
// 若請求成功dispatchRequest會返回response給回應攔截器的fulfilled函式
function onAdapterResolution(response) {
throwIfCancellationRequested(config);
return response;
},
// 反之則將錯誤拋給回應攔截器的rejected函式
function onAdapterRejection(reason) {
if (!isCancel(reason)) throwIfCancellationRequested(config);
return Promise.reject(reason);
}
);
}
另外可以看到 throwIfCancellationRequested
不斷的出現,這個函式會檢查請求是否已經被「要求」取消,等我們進入到 CancelToken 時會再提到它。
由於我們是以瀏覽器發送請求,所以這邊以 xhrAdapter
適配器為主。(完整程式碼)xhrAdapter
整段很長,但如果只看重點,其實就是在發送 XMLHttpRequest
,並在過程中做一些判斷來決定要 resolve
或 reject
這個 Promise
。
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// 建立一個新的XMLHttpRequest
var request = new XMLHttpRequest();
// 監聽readyState的變化
request.onreadystatechange = function handleLoad() {
// readyState === 4 代表請求完成
if (!request || request.readyState !== 4) return;
// 若請求完成,準備好回應的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(response),反之reject(error)
settle(resolve, reject, response);
request = null;
};
// 發送XMLHttpRequest
request.send(requestData);
});
};
到目前為止我們已經知道 axios 處理請求的流程,接下來就進入本文的重點 - CancelToken。
我把整個架構圖像化,希望對各位有幫助。
在看原始碼前,我們先看看 CancelToken
是怎麼使用的。
這段程式做了什麼可以先不管,我們只要知道,如果要使用 CancelToken
就必須在 request
的 config
中新增一個 cancelToken
屬性。
let cancel
axios.get('/user/12345', {
cancelToken: new axios.CancelToken(c => { cancel = c; })
});
cancel()
再來就該看看我們在 cancelToken
屬性中建構的 CancelToken類別
是什麼。
CancelToken
都會建立一個 Promise
,並且將 resolve
主動權給拿了出來,定義給resolvePromise
。CancelToken
的時候必須傳入一個 function
,它會直接被呼叫並且得到一個名為 cancel
的函式作為參數。當要取消請求就是呼叫 cancel
,而它做了兩件事情: 1. 賦值給屬性 reason
2. 將屬性 promise
給 resolve
function CancelToken(executor) {
// 判斷executor是否為function
if (typeof executor !== 'function') {
throw new TypeError('executor must be a function.');
}
// 建立一個新的Promise物件,並將其resolve函式賦予給變數resolvePromise
// 此時Promise會是pending狀態,還未被resolve
var resolvePromise;
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});
// 執行executor,並以函式「cancel」作為參數帶入
var token = this;
executor(function cancel(message) {
// 確認reason是否存在,若存在代表cancel已被執行過
if (token.reason) return;
// 將reason賦值為一個Cancel類別
token.reason = new Cancel(message);
// resolve Promise
resolvePromise(token.reason);
});
}
// 確認reason是否存在,若存在代表此CancelToken的cancel已被執行過,便拋出錯誤
CancelToken.prototype.throwIfRequested = function throwIfRequested() {
if (this.reason) throw this.reason;
};
所以 axios 只要根據這兩個屬性,就能判斷此次請求是否已經被取消,而 throwIfRequested
就是利用 reason
來判斷是否要拋出錯誤。
還記得我們在 dispatchRequest
裡有看到 throwIfCancellationRequested
不斷的被呼叫嗎?(請看章節 #Function-dispatchRequest)
它的作用就是判斷 config
是否有被加上 cancelToken
屬性,有的話就會呼叫 CancelToken.prototype.throwIfRequested
,以此來判斷請求是否已被取消。
function throwIfCancellationRequested(config) {
if (config.cancelToken) config.cancelToken.throwIfRequested();
}
沒錯,又再次看到了 xhrAdapter
,因為在前面我暫時省略了 xhrAdapter
內部的一個判斷。
當它發現 config.cancelToken
存在,便會為 CancelToken.promise
接上一個 then()
,意味著當 promise
被 resolve
的那一刻,請求就會被 abort
。
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
var request = new XMLHttpRequest();
// ...省略....
if (config.cancelToken) {
// cancelToken.promise要被resolve才會執行then
// onCanceled(cancel)中的cancel會是cancelToken.reason
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) return;
// 取消XMLHttpRequest
request.abort();
reject(cancel);
request = null;
});
}
request.send(requestData);
});
};
首先我們可以知道 CancelToken 的原理就是在 request config
中加上一個 CancelToken類別
,並且利用其類別屬性來判斷 cancel
函式是否被呼叫執行,若已執行代表該請求被「要求」取消。
另外可以發現 axios 在以下三個時機點都有檢查請求的取消與否:
了解整個 axios 架構以及 CancelToken 後,終於可以來實踐取消請求的功能了,先來釐清我們的需求。
每次發送請求要判斷是否已經存在相同的請求,若存在就取消前一次請求,只保留最新的
根據這樣的需求我們歸納出幾個必要的關鍵,然後準備以下程式碼
config.cancelToken
// 暫存:紀錄執行中的請求
const pending = new Map();
const addPending = config => {
// 利用method和url來當作這次請求的key,一樣的請求就會有相同的key
const key = [config.method, config.url].join("&");
// 為config添加cancelToken屬性
config.cancelToken = new axios.CancelToken(cancel => {
// 確認暫存中沒有相同的key後,把這次請求的cancel函式存起來
if (!pending.has(key)) pending.set(key, cancel);
});
};
const removePending = config => {
// 利用method和url來當作這次請求的key,一樣的請求就會有相同的key
const key = [config.method, config.url].join("&");
// 如果暫存中有相同的key,把先前存起來的cancel函式拿出來執行,並且從暫存中移除
if (pending.has(key)) {
const cancel = pending.get(key);
cancel(key);
pending.delete(key);
}
};
準備就緒後,只要在請求攔截與回應攔截器中呼叫它們即可...
// request 攔截器
instance.interceptors.request.use(
config => {
// 先判斷是否有重複的請求要取消
removePending(config);
// 把這次請求加入暫存
addPending(config);
return config;
},
error => {
return Promise.reject(error);
}
);
// response 攔截器
instance.interceptors.response.use(
response => {
// 請求被完成,從暫存中移除
removePending(response);
return response;
},
error => {
return Promise.reject(error);
}
);
從此我們不必再擔心 API 在回應前被重複觸發導致錯誤,因為我們永遠只會保留最新一次的請求。