?
前言
前段時間老板心血來潮,要我們前端組對整個的項目都做一下接口防止重復請求的處理(似乎是有用戶通過一些快速點擊薅到了一些優惠券啥的)。。。聽到這個需求,第一反應就是,防止薅羊毛最保險的方案不還是在服務端加限制嗎?前端加限制能夠攔截的畢竟有限。可老板就是執意要前端搞一下子,行吧,搞就搞吧。
雖然大部分的接口處理我們都是加了loading的,但又不能確保真的是每個接口都加了的,可是如果要一個接口一個接口的排查,那這維護了四五年的系統,成百上千的接口肯定要耗費非常多的精力,根本就是不現實的,所以就只能去做全局處理。
現在,我們就來總結一下這次的防重復請求的實現方案:
方案一
這個方案是最容易想到也是最樸實無華的一個方案:通過使用axios攔截器,在請求攔截器中開啟全屏Loading,然后在響應攔截器中將Loading關閉。

這個方案固然已經可以滿足我們目前的需求,但不管三七二十一,直接搞個全屏Loading還是不太美觀,何況在目前項目的接口處理邏輯中還有一些局部Loading,就有可能會出現Loading套Loading的情況,兩個圈一起轉,頭皮發麻。
方案二
加Loading的方案不太友好,而對于同一個接口,如果傳參都是一樣的,一般來說都沒有必要連續請求多次吧。那我們可不可以通過代碼邏輯直接把完全相同的請求給攔截掉,不讓它到達服務端呢?這個思路不錯,我們說干就干。
首先,我們要判斷什么樣的請求屬于是相同請求:
一個請求包含的內容不外乎就是請求方法,地址,參數以及請求發出的頁面hash。那我們是不是就可以根據這幾個數據把這個請求生成一個key來作為這個請求的標識呢?
function generateReqKey(config, hash) {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
}
有了請求的key,我們就可以在請求攔截器中把每次發起的請求給收集起來,后續如果有相同請求進來,那都去這個集合中去比對,如果已經存在了,說明就是一個重復的請求,我們就給攔截掉。
當請求完成響應后,再將這個請求從集合中移除。合理,nice!
具體實現如下:

是不是覺得這種方案還不錯,萬事大吉?
no,no,no! 這個方案雖然理論上是解決了接口防重復請求這個問題,但是它會引發更多的問題。
比如,我有這樣一個接口處理:

那么,當我們觸發多次請求時:

這里我連續點擊了4次按鈕,可以看到,的確是只有一個請求發送出去,可是因為在代碼邏輯中,我們對錯誤進行了一些處理,所以就將報錯消息提示了3次,這樣是很不友好的,而且,如果在錯誤捕獲中有做更多的邏輯處理,那么很有可能會導致整個程序的異常。
而且,這種方案還會有另外一個比較嚴重的問題:
我們在上面在生成請求key的時候把hash考慮進去了(如果是history路由,可以將pathname加入生成key),這是因為項目中會有一些數據字典型的接口,這些接口可能有不同頁面都需要去調用,如果第一個頁面請求的字典接口比較慢,第二個頁面的接口就被攔截了,最后就會導致第二個頁面邏輯錯誤。
那么這么一看,我們生成key的時候加入了hash,講道理就沒問題了呀。
可是倘若我這兩個請求是來自同一個頁面呢?
比如,一個頁面同時加載兩個組件,而這兩個組件都需要調用某個接口時:

那么此時,后調接口的組件就無法拿到正確數據了。啊?這,真是難頂!
方案三
方案二的路子,我們發現確實問題重重,那么接下來我們來看第三種方案,也是我們最終采用的方案。
延續我們方案二的前面思路,仍然是攔截相同請求,但這次我們可不可以不直接把請求掛掉,而是對于相同的請求我們先給它掛起,等到最先發出去的請求拿到結果回來之后,把成功或失敗的結果共享給后面到來的相同請求。

思路我們已經明確了,但這里有幾個需要注意的點:
- 我們在拿到響應結果后,返回給之前我們掛起的請求時,我們要用到發布訂閱模式(日常在面試題中看到,這次終于讓我給用上了(^▽^))
- 對于掛起的請求,我們需要將它攔截,不能讓它執行正常的請求邏輯,所以一定要在請求攔截器中通過return Promise.reject()來直接中斷請求,并做一些特殊的標記,以便于在響應攔截器中進行特殊處理。
最后,直接附上完整代碼:
import axios from "axios"
let instance = axios.create({
baseURL: "/api/"
})
class EventEmitter {
constructor() {
this.event = {}
}
on(type, cbres, cbrej) {
if (!this.event[type]) {
this.event[type] = [[cbres, cbrej]]
} else {
this.event[type].push([cbres, cbrej])
}
}
emit(type, res, ansType) {
if (!this.event[type]) return
else {
this.event[type].forEach(cbArr => {
if(ansType === 'resolve') {
cbArr[0](res)
}else{
cbArr[1](res)
}
});
}
}
}
function generateReqKey(config, hash) {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
}
const pendingRequest = new Set();
const ev = new EventEmitter()
instance.interceptors.request.use(async (config) => {
let hash = location.hash
let reqKey = generateReqKey(config, hash)
if(pendingRequest.has(reqKey)) {
let res = null
try {
res = await new Promise((resolve, reject) => {
ev.on(reqKey, resolve, reject)
})
return Promise.reject({
type: 'limiteResSuccess',
val: res
})
}catch(limitFunErr) {
return Promise.reject({
type: 'limiteResError',
val: limitFunErr
})
}
}else{
config.pendKey = reqKey
pendingRequest.add(reqKey)
}
return config;
}, function (error) {
return Promise.reject(error);
});
instance.interceptors.response.use(function (response) {
handleSuccessResponse_limit(response)
return response;
}, function (error) {
return handleErrorResponse_limit(error)
});
function handleSuccessResponse_limit(response) {
const reqKey = response.config.pendKey
if(pendingRequest.has(reqKey)) {
let x = null
try {
x = JSON.parse(JSON.stringify(response))
}catch(e) {
x = response
}
pendingRequest.delete(reqKey)
ev.emit(reqKey, x, 'resolve')
delete ev.reqKey
}
}
function handleErrorResponse_limit(error) {
if(error.type && error.type === 'limiteResSuccess') {
return Promise.resolve(error.val)
}else if(error.type && error.type === 'limiteResError') {
return Promise.reject(error.val);
}else{
const reqKey = error.config.pendKey
if(pendingRequest.has(reqKey)) {
let x = null
try {
x = JSON.parse(JSON.stringify(error))
}catch(e) {
x = error
}
pendingRequest.delete(reqKey)
ev.emit(reqKey, x, 'reject')
delete ev.reqKey
}
}
return Promise.reject(error);
}
export default instance;
補充
到這里,這么一通操作下來上面的代碼講道理是萬無一失了,但不得不說,線上的情況仍然是復雜多樣的。而其中一個比較特殊的情況就是文件上傳。

可以看到,我在這里是上傳了兩個不同的文件的,但只調用了一次上傳接口。按理說是兩個不同的請求,可為什么會被我們前面寫的邏輯給攔截掉一個呢?
我們打印一下請求的config:

可以看到,請求體data中的數據是FormData類型,而我們在生成請求key的時候,是通過JSON.stringify方法進行操作的,而對于FormData類型的數據執行該函數得到的只有{}。
所以,對于文件上傳,盡管我們上傳了不同的文件,但它們所發出的請求生成的key都是一樣的,這么一來就觸發了我們前面的攔截機制。
那么我們接下來我們只需要在我們原來的攔截邏輯中判斷一下請求體的數據類型即可,如果含有FormData類型的數據,我們就直接放行不再關注這個請求就是了。
function isFileUploadApi(config) {
return Object.prototype.toString.call(config.data) === "[object FormData]"
}
最后
到這里,整個的需求總算是完結啦!不用一個個接口的改代碼,又可以愉快的打代碼了,nice!
Demo地址:https://github.com/GuJiugc/JueJinDemo
本文完~
閱讀原文:https://mp.weixin.qq.com/s/qrsYhY9Qqc4XtqorFgjErg
該文章在 2024/12/24 9:48:29 編輯過