如何防止重復(fù)發(fā)送ajax請求
作者 | 周浪
背景
先來說說重復(fù)發(fā)送ajax請求帶來的問題
場景一:用戶快速點(diǎn)擊按鈕,多次相同的請求打到服務(wù)器,給服務(wù)器造成壓力。如果碰到提交表單操作,而且恰好后端沒有做兼容處理,那么可能會造成數(shù)據(jù)庫中插入兩條及以上的相同數(shù)據(jù) 場景二:用戶頻繁切換下拉篩選條件,第一次篩選數(shù)據(jù)量較多,花費(fèi)的時間較長,第二次篩選數(shù)據(jù)量較少,請求后發(fā)先至,內(nèi)容先顯示在界面上。但是等到第一次的數(shù)據(jù)回來之后,就會覆蓋掉第二次的顯示的數(shù)據(jù)。篩選結(jié)果和查詢條件不一致,用戶體驗(yàn)很不好
常用解決方案
為了解決上述問題,通常會采用以下幾種解決方案
狀態(tài)變量
發(fā)送ajax請求前,btnDisable置為true,禁止按鈕點(diǎn)擊,等到ajax請求結(jié)束解除限制,這是我們最常用的一種方案
但該方案也存在以下弊端:與業(yè)務(wù)代碼耦合度高 無法解決上述場景二存在的問題 函數(shù)節(jié)流和函數(shù)防抖
固定的一段時間內(nèi),只允許執(zhí)行一次函數(shù),如果有重復(fù)的函數(shù)調(diào)用,可以選擇使用函數(shù)節(jié)流忽略后面的函數(shù)調(diào)用,以此來解決場景一存在的問題

也可以選擇使用函數(shù)防抖忽略前面的函數(shù)調(diào)用,以此來解決場景二存在的問題
該方案能覆蓋場景一和場景二,不過也存在一個大問題:wait time是一個固定時間,而ajax請求的響應(yīng)時間不固定,wait time設(shè)置小于ajax響應(yīng)時間,兩個ajax請求依舊會存在重疊部分,wait time設(shè)置大于ajax響應(yīng)時間,影響用戶體驗(yàn)。總之就是wait time的時間設(shè)定是個難題
請求攔截和請求取消
作為一個成熟的ajax應(yīng)用,它應(yīng)該能自己在pending過程中選擇請求攔截和請求取消
請求攔截
用一個數(shù)組存儲目前處于pending狀態(tài)的請求。發(fā)送請求前先判斷這個api請求之前是否已經(jīng)有還在pending的同類,即是否存在上述數(shù)組中,如果存在,則不發(fā)送請求,不存在就正常發(fā)送并且將該api添加到數(shù)組中。等請求完結(jié)后刪除數(shù)組中的這個api。
請求取消
用一個數(shù)組存儲目前處于pending狀態(tài)的請求。發(fā)送請求時判斷這個api請求之前是否已經(jīng)有還在pending的同類,即是否存在上述數(shù)組中,如果存在,則找到數(shù)組中pending狀態(tài)的請求并取消,不存在就將該api添加到數(shù)組中。然后發(fā)送請求,等請求完結(jié)后刪除數(shù)組中的這個api
實(shí)現(xiàn)
接下來介紹一下本文的主角 axios 的 cancel token(查看詳情)。通過axios 的 cancel token,我們可以輕松做到請求攔截和請求取消
const CancelToken = axios.CancelToken;const source = CancelToken.source();axios.get('/user/12345', {cancelToken: source.token}).catch(function (thrown) {if (axios.isCancel(thrown)) {console.log('Request canceled', thrown.message);} else {// handle error}});axios.post('/user/12345', {name: 'new name'}, {cancelToken: source.token})// cancel the request (the message parameter is optional)source.cancel('Operation canceled by the user.');
官網(wǎng)示例中,先定義了一個 const CancelToken = axios.CancelToken,定義可以在axios源碼axios/lib/axios.js目錄下找到
// Expose Cancel & CancelTokenaxios.Cancel = require('./cancel/Cancel');axios.CancelToken = require('./cancel/CancelToken');axios.isCancel = require('./cancel/isCancel');
示例中調(diào)用了axios.CancelToken的source方法,所以接下來我們再去axios/lib/cancel/CancelToken.js目錄下看看source方法
/*** Returns an object that contains a new `CancelToken` and a function that, when called,* cancels the `CancelToken`.*/CancelToken.source = function source() {var cancel;var token = new CancelToken(function executor(c) {cancel = c;});return {token: token,cancel: cancel};};
source方法返回一個具有token和cancel屬性的對象,這兩個屬性都和CancelToken構(gòu)造函數(shù)有關(guān)聯(lián),所以接下來我們再看看CancelToken構(gòu)造函數(shù)
/*** A `CancelToken` is an object that can be used to request cancellation of an operation.** @class* @param {Function} executor The executor function.*/function CancelToken(executor) {if (typeof executor !== 'function') {throw new TypeError('executor must be a function.');}var resolvePromise;this.promise = new Promise(function promiseExecutor(resolve) {resolvePromise = resolve;});var token = this;executor(function cancel(message) {if (token.reason) {// Cancellation has already been requestedreturn;}token.reason = new Cancel(message);resolvePromise(token.reason);});}
所以souce.token是一個CancelToken的實(shí)例,而source.cancel是一個函數(shù),調(diào)用它會在CancelToken的實(shí)例上添加一個reason屬性,并且將實(shí)例上的promise狀態(tài)resolve掉
官網(wǎng)另一個示例
const CancelToken = axios.CancelToken;let cancel;axios.get('/user/12345', {cancelToken: new CancelToken(function executor(c) {// An executor function receives a cancel function as a parametercancel = c;})});// cancel the requestcancel();
它與第一個示例的區(qū)別就在于每個請求都會創(chuàng)建一個CancelToken實(shí)例,從而它擁有多個cancel函數(shù)來執(zhí)行取消操作
我們執(zhí)行axios.get,最后其實(shí)是執(zhí)行axios實(shí)例上的request方法,方法定義在axios\lib\core\Axios.js
Axios.prototype.request = function request(config) {...// Hook up interceptors middlewarevar 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;};
request方法返回一個鏈?zhǔn)秸{(diào)用的promise,等同于
Promise.resolve(config).then('request攔截器中的resolve方法', 'request攔截器中的rejected方法').then(dispatchRequest, undefined).then('response攔截器中的resolve方法', 'response攔截器中的rejected方法')在閱讀源碼的過程中,這些編程小技巧都是非常值得學(xué)習(xí)的
接下來看看axios\lib\core\dispatchRequest.js中的dispatchRequest方法
function throwIfCancellationRequested(config) {if (config.cancelToken) {config.cancelToken.throwIfRequested();}}module.exports = function dispatchRequest(config) {throwIfCancellationRequested(config);...var adapter = config.adapter || defaults.adapter;return adapter(config).then()};
如果是cancel方法立即執(zhí)行,創(chuàng)建了CancelToken實(shí)例上的reason屬性,那么就會拋出異常,從而被response攔截器中的rejected方法捕獲,并不會發(fā)送請求,這個可以用來做請求攔截
CancelToken.prototype.throwIfRequested = function throwIfRequested() {if (this.reason) {throw this.reason;}};
如果cancel方法延遲執(zhí)行,那么我們接著去找axios\lib\defaults.js中的defaults.adapter
function getDefaultAdapter() {var adapter;if (typeof XMLHttpRequest !== 'undefined') {// For browsers use XHR adapteradapter = require('./adapters/xhr');} else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {// For node use HTTP adapteradapter = require('./adapters/http');}return adapter;}var defaults = {adapter: getDefaultAdapter()}
終于找到axios\lib\adapters\xhr.js中的xhrAdapter
module.exports = function xhrAdapter(config) {return new Promise(function dispatchXhrRequest(resolve, reject) {...var request = new XMLHttpRequest();if (config.cancelToken) {// Handle cancellationconfig.cancelToken.promise.then(function onCanceled(cancel) {if (!request) {return;}request.abort();reject(cancel);// Clean up requestrequest = null;});}// Send the requestrequest.send(requestData);})}
可以看到xhrAdapter創(chuàng)建了XMLHttpRequest對象,發(fā)送ajax請求,在這之后如果執(zhí)行cancel函數(shù)將cancelToken.promise狀態(tài)resolve掉,就會調(diào)用request.abort(),可以用來請求取消
解耦
剩下要做的就是將cancelToken從業(yè)務(wù)代碼中剝離出來。我們在項(xiàng)目中,大多都會對axios庫再做一層封裝來處理一些公共邏輯,最常見的就是在response攔截器里統(tǒng)一處理返回code。那么我們當(dāng)然也可以將cancelToken的配置放在request攔截器。可參考demo
let pendingAjax = []const fastClickMsg = '數(shù)據(jù)請求中,請稍后'const CancelToken = axios.CancelTokenconst removePendingAjax = (url, type) => {const index = pendingAjax.findIndex(i => i.url === url)if (index > -1) {type === 'req' && pendingAjax[index].c(fastClickMsg)pendingAjax.splice(index, 1)}}// Add a request interceptoraxios.interceptors.request.use(function (config) {// Do something before request is sentconst url = config.urlremovePendingAjax(url, 'req')config.cancelToken = new CancelToken(c => {pendingAjax.push({url,c})})return config},function (error) {// Do something with request errorreturn Promise.reject(error)})// Add a response interceptoraxios.interceptors.response.use(function (response) {// Any status code that lie within the range of 2xx cause this function to trigger// Do something with response dataremovePendingAjax(response.config.url, 'resp')return new Promise((resolve, reject) => {if (+response.data.code !== 0) {reject(new Error('network error:' + response.data.msg))} else {resolve(response)}})},function (error) {// Any status codes that falls outside the range of 2xx cause this function to trigger// Do something with response errorMessage.error(error)return Promise.reject(error)})
每次執(zhí)行request攔截器,判斷pendingAjax數(shù)組中是否還存在同樣的url。如果存在,則刪除數(shù)組中的這個api并且執(zhí)行數(shù)組中在pending的ajax請求的cancel函數(shù)進(jìn)行請求取消,然后就正常發(fā)送第二次的ajax請求并且將該api添加到數(shù)組中。等請求完結(jié)后刪除數(shù)組中的這個api
let pendingAjax = []const fastClickMsg = '數(shù)據(jù)請求中,請稍后'const CancelToken = axios.CancelTokenconst removePendingAjax = (config, c) => {const url = config.urlconst index = pendingAjax.findIndex(i => i === url)if (index > -1) {c ? c(fastClickMsg) : pendingAjax.splice(index, 1)} else {c && pendingAjax.push(url)}}// Add a request interceptoraxios.interceptors.request.use(function (config) {// Do something before request is sentconfig.cancelToken = new CancelToken(c => {removePendingAjax(config, c)})return config},function (error) {// Do something with request errorreturn Promise.reject(error)})// Add a response interceptoraxios.interceptors.response.use(function (response) {// Any status code that lie within the range of 2xx cause this function to trigger// Do something with response dataremovePendingAjax(response.config)return new Promise((resolve, reject) => {if (+response.data.code !== 0) {reject(new Error('network error:' + response.data.msg))} else {resolve(response)}})},function (error) {// Any status codes that falls outside the range of 2xx cause this function to trigger// Do something with response errorMessage.error(error)return Promise.reject(error)})
每次執(zhí)行request攔截器,判斷pendingAjax數(shù)組中是否還存在同樣的url。如果存在,則執(zhí)行自身的cancel函數(shù)進(jìn)行請求攔截,不重復(fù)發(fā)送請求,不存在就正常發(fā)送并且將該api添加到數(shù)組中。等請求完結(jié)后刪除數(shù)組中的這個api
總結(jié)
axios 是基于 XMLHttpRequest 的封裝,針對 fetch ,也有類似的解決方案 AbortSignal 查看詳情。大家可以針對各自的項(xiàng)目進(jìn)行選取
??愛心三連擊
1.看到這里了就點(diǎn)個在看支持下吧,你的「在看」是我創(chuàng)作的動力。
2.關(guān)注公眾號
程序員成長指北,回復(fù)「1」加入Node進(jìn)階交流群!「在這里有好多 Node 開發(fā)者,會討論 Node 知識,互相學(xué)習(xí)」!3.也可添加微信【ikoala520】,一起成長。
“在看轉(zhuǎn)發(fā)”是最大的支持
