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

也可以選擇使用函數(shù)防抖忽略前面的函數(shù)調(diào)用,以此來(lái)解決場(chǎng)景二存在的問(wèn)題
該方案能覆蓋場(chǎng)景一和場(chǎng)景二,不過(guò)也存在一個(gè)大問(wèn)題:wait time是一個(gè)固定時(shí)間,而ajax請(qǐng)求的響應(yīng)時(shí)間不固定,wait time設(shè)置小于ajax響應(yīng)時(shí)間,兩個(gè)ajax請(qǐng)求依舊會(huì)存在重疊部分,wait time設(shè)置大于ajax響應(yīng)時(shí)間,影響用戶體驗(yàn)。總之就是wait time的時(shí)間設(shè)定是個(gè)難題
請(qǐng)求攔截和請(qǐng)求取消
作為一個(gè)成熟的ajax應(yīng)用,它應(yīng)該能自己在pending過(guò)程中選擇請(qǐng)求攔截和請(qǐng)求取消
請(qǐng)求攔截
用一個(gè)數(shù)組存儲(chǔ)目前處于pending狀態(tài)的請(qǐng)求。發(fā)送請(qǐng)求前先判斷這個(gè)api請(qǐng)求之前是否已經(jīng)有還在pending的同類,即是否存在上述數(shù)組中,如果存在,則不發(fā)送請(qǐng)求,不存在就正常發(fā)送并且將該api添加到數(shù)組中。等請(qǐng)求完結(jié)后刪除數(shù)組中的這個(gè)api。
請(qǐng)求取消
用一個(gè)數(shù)組存儲(chǔ)目前處于pending狀態(tài)的請(qǐng)求。發(fā)送請(qǐng)求時(shí)判斷這個(gè)api請(qǐng)求之前是否已經(jīng)有還在pending的同類,即是否存在上述數(shù)組中,如果存在,則找到數(shù)組中pending狀態(tài)的請(qǐng)求并取消,不存在就將該api添加到數(shù)組中。然后發(fā)送請(qǐng)求,等請(qǐng)求完結(jié)后刪除數(shù)組中的這個(gè)api
實(shí)現(xiàn)
接下來(lái)介紹一下本文的主角 axios 的 cancel token(查看詳情)。通過(guò)axios 的 cancel token,我們可以輕松做到請(qǐng)求攔截和請(qǐng)求取消
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)示例中,先定義了一個(gè) 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方法,所以接下來(lái)我們?cè)偃?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">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方法返回一個(gè)具有token和cancel屬性的對(duì)象,這兩個(gè)屬性都和CancelToken構(gòu)造函數(shù)有關(guān)聯(lián),所以接下來(lái)我們?cè)倏纯?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">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是一個(gè)CancelToken的實(shí)例,而source.cancel是一個(gè)函數(shù),調(diào)用它會(huì)在CancelToken的實(shí)例上添加一個(gè)reason屬性,并且將實(shí)例上的promise狀態(tài)resolve掉
官網(wǎng)另一個(gè)示例
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();
它與第一個(gè)示例的區(qū)別就在于每個(gè)請(qǐng)求都會(huì)創(chuàng)建一個(gè)CancelToken實(shí)例,從而它擁有多個(gè)cancel函數(shù)來(lái)執(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方法返回一個(gè)鏈?zhǔn)秸{(diào)用的promise,等同于
Promise.resolve(config).then('request攔截器中的resolve方法', 'request攔截器中的rejected方法').then(dispatchRequest, undefined).then('response攔截器中的resolve方法', 'response攔截器中的rejected方法')在閱讀源碼的過(guò)程中,這些編程小技巧都是非常值得學(xué)習(xí)的
接下來(lái)看看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屬性,那么就會(huì)拋出異常,從而被response攔截器中的rejected方法捕獲,并不會(huì)發(fā)送請(qǐng)求,這個(gè)可以用來(lái)做請(qǐng)求攔截
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對(duì)象,發(fā)送ajax請(qǐng)求,在這之后如果執(zhí)行cancel函數(shù)將cancelToken.promise狀態(tài)resolve掉,就會(huì)調(diào)用request.abort(),可以用來(lái)請(qǐng)求取消
解耦
剩下要做的就是將cancelToken從業(yè)務(wù)代碼中剝離出來(lái)。我們?cè)陧?xiàng)目中,大多都會(huì)對(duì)axios庫(kù)再做一層封裝來(lái)處理一些公共邏輯,最常見的就是在response攔截器里統(tǒng)一處理返回code。那么我們當(dāng)然也可以將cancelToken的配置放在request攔截器??蓞⒖糳emo
let pendingAjax = []const fastClickMsg = '數(shù)據(jù)請(qǐng)求中,請(qǐng)稍后'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ù)組中的這個(gè)api并且執(zhí)行數(shù)組中在pending的ajax請(qǐng)求的cancel函數(shù)進(jìn)行請(qǐng)求取消,然后就正常發(fā)送第二次的ajax請(qǐng)求并且將該api添加到數(shù)組中。等請(qǐng)求完結(jié)后刪除數(shù)組中的這個(gè)api
let pendingAjax = []const fastClickMsg = '數(shù)據(jù)請(qǐng)求中,請(qǐng)稍后'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)行請(qǐng)求攔截,不重復(fù)發(fā)送請(qǐng)求,不存在就正常發(fā)送并且將該api添加到數(shù)組中。等請(qǐng)求完結(jié)后刪除數(shù)組中的這個(gè)api
總結(jié)
axios 是基于 XMLHttpRequest 的封裝,針對(duì) fetch ,也有類似的解決方案 AbortSignal 查看詳情。大家可以針對(duì)各自的項(xiàng)目進(jìn)行選取
??愛心三連擊
1.看到這里了就點(diǎn)個(gè)在看支持下吧,你的「在看」是我創(chuàng)作的動(dòng)力。
2.關(guān)注公眾號(hào)
程序員成長(zhǎng)指北,回復(fù)「1」加入Node進(jìn)階交流群!「在這里有好多 Node 開發(fā)者,會(huì)討論 Node 知識(shí),互相學(xué)習(xí)」!3.也可添加微信【ikoala520】,一起成長(zhǎng)。
“在看轉(zhuǎn)發(fā)”是最大的支持
