使用 axios 攔截器解決「 前端並行衝突 」 問題

2021-05-06 12:00:31

背景

並行衝突問題, 是日常開發中一個比較常見的問題。

不同使用者在較短時間間隔內變更資料,或者某一個使用者進行的重複提交操作都可能導致並行衝突。

並行場景在開發和測試階段難以排查全面,出現線上 bug 以後定位困難,因此做好並行控制是前後端開發過程中都需要重視的問題。

對於同一使用者短時間內重複提交資料的問題,前端通常可以先做一層攔截。

本文將討論前端如何利用 axios 的攔截器過濾重複請求,解決並行衝突。

一般的處理方式 — 每次發請求新增 loading

在嘗試 axios 攔截器之前,先看看我們之前業務是怎麼處理並行衝突問題的:

每次使用者操作頁面上的控制元件(輸入框、按鈕等),向後端傳送請求的時候,都給頁面對應的控制元件新增 loading 效果,提示正在進行資料載入,同時也阻止 loading 效果結束前使用者繼續操作控制元件。

這是最直接有效的方式,如果你們前端團隊成員足夠細心耐心,擁有良好的編碼習慣,這樣就可以解決大部分使用者不小心重複提交帶來的並行問題了。

更優的解決方案: axios 攔截器統一處理

專案中需要前端限制並行的場景這麼多,我們當然要思考更優更省事的方案。

既然是在每次傳送請求的時候進行並行控制,那如果能重新封裝下發請求的公共函數,統一處理重複請求實現自動攔截,就可以大大簡化我們的業務程式碼。

專案使用的 axios 庫來傳送 http 請求,axios 官方為我們提供了豐富的 API,我們來看看攔截請求需要用到的兩個核心 API:

1. interceptors

攔截器包括請求攔截器和響應攔截器,可以在請求傳送前或者響應後進行攔截處理,用法如下:

// 新增請求攔截器
axios.interceptors.request.use(function (config) {
  // 在傳送請求之前做些什麼
  return config;
}, function (error) {
  // 對請求錯誤做些什麼
  return Promise.reject(error);
});

// 新增響應攔截器
axios.interceptors.response.use(function (response) {
    // 對響應資料做點什麼
    return response;
  }, function (error) {
    // 對響應錯誤做點什麼
    return Promise.reject(error);
  });

2. cancel token:

呼叫 cancel token API 可以取消請求。官網提供了兩種方式來構建 cancel token,我們採用這種方式:通過傳遞一個 executor 函數到 CancelToken 的建構函式來建立 cancel token,方便在上面的請求攔截器中檢測到重複請求可以立即執行:

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // executor 函數接收一個 cancel 函數作為引數
    cancel = c;
  })
});

// cancel the request
cancel();

本文提供的思路就是利用 axios interceptors API 攔截請求,檢測是否有多個相同的請求同時處於 pending 狀態,如果有就呼叫 cancel token API 取消重複的請求。

假如使用者重複點選按鈕,先後提交了 A 和 B 這兩個完全相同(考慮請求路徑、方法、引數)的請求,我們可以從以下幾種攔截方案中選擇其一:

  • 取消 A 請求,只發出 B 請求
  • 取消 B 請求,只發出 A 請求
  • 取消 B 請求,只發出 A 請求,把收到的 A 請求的返回結果也作為 B 請求的返回結果

第三種方案需要做監聽處理增加了複雜性,結合我們實際的業務需求,最後採用了第二種方案來實現,即:

只發第一個請求。在 A 請求還處於 pending 狀態時,後發的所有與 A 重複的請求都取消,實際只發出 A 請求,直到 A 請求結束(成功/失敗)才停止對這個請求的攔截。

具體實現

  1. 儲存所有 pending 狀態的請求

首先我們要將專案中所有的 pending 狀態的請求儲存在一個變數中,叫它 pendingRequests

可以通過把 axios 封裝為一個單例模式的類,或者定義全域性變數,來保證 pendingRequests 變數在每次傳送請求前都可以存取,並檢查是否為重複的請求。

let pendingRequests = new Map()

把每個請求的方法、url 和引數組合成一個字串,作為標識該請求的唯一 key,同時也是 pendingRequests 物件的 key:

const requestKey = `${config.url}/${JSON.stringify(config.params)}/${JSON.stringify(config.data)}&request_type=${config.method}`;

幫助理解的小 tips:

  • 定義 pendingRequests 為 map 物件的目的是為了方便我們查詢它是否包含某個 key,以及新增和刪除 key。新增 key 時,對應的 value 可以設定使用者自定義的一些功能引數,後面擴充套件功能的時候會用到。
  • configaxios 攔截器中的引數,包含當前請求的資訊
  1. 在請求發出前檢查當前請求是否重複

    在請求攔截器中,生成上面的 requestKey,檢查 pendingRequests 物件中是否包含當前請求的 requestKey

    • 有:說明是重複的請求,cancel 掉當前請求
    • 沒有:把 requestKey 新增到 pendingRequests 物件中

因為後面的響應攔截器中還要用到當前請求的 requestKey,為了避免踩坑,最好不要再次生成,在這一步就把 requestKey 存回 axios 攔截器的 config 引數中,後面可以直接在響應攔截器中通過 response.config.requestKey 取到。

程式碼範例:

// 請求攔截器
axios.interceptors.request.use(
  (config) => {
    if (pendingRequests.has(requestKey)) {
      config.cancelToken = new axios.CancelToken((cancel) => {
        // cancel 函數的引數會作為 promise 的 error 被捕獲
        cancel(`重複的請求被主動攔截: ${requestKey}`);
      });
    } else {
      pendingRequests.set(requestKey, config);
      config.requestKey = requestKey;
    }
    return config;
  },
  (error) => {
    // 這裡出現錯誤可能是網路波動造成的,清空 pendingRequests 物件
    pendingRequests.clear();
    return Promise.reject(error);
  }
);
  1. 在請求返回後維護 pendingRequests 物件

如果請求順利走到了響應攔截器這一步,說明這個請求已經結束了 pending 狀態,那我們要把它從 pendingRequests 中除名:

axios.interceptors.response.use((response) => {
  const requestKey = response.config.requestKey;
  pendingRequests.delete(requestKey);
  return Promise.resolve(response);
}, (error) => {
  if (axios.isCancel(error)) {
    console.warn(error);
    return Promise.reject(error);
  }
  pendingRequests.clear();
  return Promise.reject(error);
})
  1. 需要清空 pendingRequests 物件的場景

遇到網路波動或者超時等情況造成請求錯誤時,需要清空原來儲存的所有 pending 狀態的請求記錄,在上面演示的程式碼已經作了註釋說明。

此外,頁面切換時也需要清空之前快取的 pendingRequests 物件,可以利用 Vue RouterbeforeEach 勾點:

router.beforeEach((to, from, next) => {
  request.clearRequestList();
  next();
});

功能擴充套件

  1. 統一處理介面報錯提示

與後端約定好介面返回資料的格式,對介面報錯的情況,可以統一在響應攔截器中新增 toast 給使用者提示,

對於特殊的不需要報錯的介面,可以設定一個引數存入 axios 攔截器的 config 引數中,過濾掉報錯提示:

// 介面返回 retcode 不為 0 時需要報錯,請求設定了 noError 為 true 則這個介面不報錯 
if (
  response.data.retcode &&
  !response.config.noError
) {
  if (response.data.message) {
    Vue.prototype.$message({
      showClose: true,
      message: response.data.message,
      type: 'error',
    });
  }
  return Promise.reject(response.data);
}
  1. 傳送請求時給控制元件新增 loading 效果

上面利用 axios interceptors 過濾重複請求時,可以在控制檯丟擲資訊給開發者提示,在這個基礎上如果能給頁面上操作的控制元件新增 loading 效果就會對使用者更友好。

常見的 ui 元件庫都有提供 loading 服務,可以指定頁面上需要新增 loading 效果的控制元件。下面是以 element UI 為例的範例程式碼:

// 給 loadingTarget 對應的控制元件新增 loading 效果,儲存 loadingService 範例
addLoading(config) {
  if (!document.querySelector(config.loadingTarget)) return;
  config.loadingService = Loading.service({
    target: config.loadingTarget,
  });
}

// 呼叫 loadingService 範例的 close 方法關閉對應元素的 loading 效果
closeLoading(config) {
  config.loadingService && config.loadingService.close();
}

與上面過濾報錯方式類似,發請求的時候將元素的 class name 或 id 存入 axios 攔截器的 config 引數中,

在請求攔截器中呼叫 addLoading 方法, 響應攔截器中呼叫 closeLoading 方法,就可以實現在請求 pending 過程中指定控制元件(如 button) loading,請求結束後控制元件自動取消 loading 效果。

  1. 支援多個攔截器組合使用

簡單看下 axios interceptors 部分實現原始碼可以理解,它支援定義多個 interceptors,所以只要我們定義的 interceptors 符合 Promise.then 鏈式呼叫的規範,還可以新增更多功能:

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

總結

並行問題很常見,處理起來又相對繁瑣,前端解決並行衝突時,可以利用 axios 攔截器統一處理重複請求,簡化業務程式碼。

同時 axios 攔截器支援更多應用,本文提供了部分常用擴充套件功能的實現,感興趣的同學可以繼續挖掘補充攔截器的其他用法。

今天的內容就這麼多,希望對你有幫助。

如果覺得內容有幫助, 可以關注下我的公眾號,掌握最新動態,一起學習!

image.png