前端接口防止重複請求實現方案

前段時間老闆心血來潮,要我們前端組對整個的項目都做一下接口防止重複請求的處理(似乎是有用戶通過一些快速點擊薅到了一些優惠券啥的)。。。聽到這個需求,第一反應就是,防止薅羊毛最保險的方案不還是在服務端加限制嗎?前端加限制能夠攔截的畢竟有限。可老闆就是執意要前端搞一下子,行吧,搞就搞吧,you happy jiu ok

雖然大部分的接口處理我們都是加了 loading 的,但又不能確保真的是每個接口都加了的,可是如果要一個接口一個接口的排查,那這維護了四五年的系統,成百上千的接口肯定要耗費非常多的精力,根本就是不現實的,所以就只能去做全局處理。下面就來總結一下這次的防重複請求的實現方案:

方案一

這個方案是最容易想到也是最樸實無華的一個方案:通過使用 axios 攔截器,在請求攔截器中開啓全屏 Loading,然後在響應攔截器中將 Loading 關閉。

這個方案固然已經可以滿足我們目前的需求,但不管三七二十一,直接搞個全屏 Loading 還是不太美觀,何況在目前項目的接口處理邏輯中還有一些局部 Loading,就有可能會出現 Loading 套 Loading 的情況,兩個圈一起轉,頭皮發麻。

方案二

加 Loading 的方案不太友好,而對於同一個接口,如果傳參都是一樣的,一般來說都沒有必要連續請求多次吧。那我們可不可以通過代碼邏輯直接把完全相同的請求給攔截掉,不讓它到達服務端呢?這個思路不錯,我們說幹就幹。

首先,我們要判斷什麼樣的請求屬於是相同請求

一個請求包含的內容不外乎就是請求方法地址參數以及請求發出的頁面 hash。那我們是不是就可以根據這幾個數據把這個請求生成一個 key 來作爲這個請求的標識呢?

// 根據請求生成對應的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,講道理就沒問題了呀。

可是倘若我這兩個請求是來自同一個頁面呢?

比如,一個頁面同時加載兩個組件,而這兩個組件都需要調用某個接口時:

那麼此時,後調接口的組件就無法拿到正確數據了。啊這,真是難頂!

方案三

方案二的路子,我們發現確實問題重重,那麼接下來我們來看第三種方案,也是我們最終採用的方案。

延續我們方案二的前面思路,仍然是攔截相同請求,但這次我們可不可以不直接把請求掛掉,而是對於相同的請求我們先給它掛起,等到最先發出去的請求拿到結果回來之後,把成功或失敗的結果共享給後面到來的相同請求

思路我們已經明確了,但這裏有幾個需要注意的點:

最後,直接附上完整代碼:

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)
                }
            });
        }
    }
}


// 根據請求生成對應的key
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
    // 生成請求Key
    let reqKey = generateReqKey(config, hash)
    
    if(pendingRequest.has(reqKey)) {
        // 如果是相同請求,在這裏將請求掛起,通過發佈訂閱來爲該請求返回結果
        // 這裏需注意,拿到結果後,無論成功與否,都需要return Promise.reject()來中斷這次請求,否則請求會正常發送至服務器
        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{
        // 將請求的key保存在config
        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 地址 [1]

原文: https://juejin.cn/post/7341840038964363283

作者: 沽汣

參考資料

[1]

https://github.com/GuJiugc/JueJinDemo

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Vb8ddo1InL54BB89Z4W94A