Cancel previous request for ALL
I assume most of requests deserve a cancel, right?
序
過去在處理「取消請求」的時候,以 axios 來說都會以 CancelToken 來處理,或是搭配 Rxjs 的 switchMap,但現在有了更棒的做法:就是 Browser 支援的 AbortController
舉個 🌰
使用者在一個充滿表格的 CMS 後台,進入的第一個畫面是 500 items / per page 的頁面,在等待三秒後不加思考的晃到了最後一頁 (先不論 pagination 是否有做阻擋,因為也可能可以透過 url query 去變動),這時最後一頁的資訊可能只有少少的 3 筆,所以 response 很快就回來了,但這段時間,後端其實也把上一個 500 筆 query 處理完也接著回傳給前端,在這兩組 request 交纏下,因為 response timing 的差異,導致使用者極有可能是人在「最後一頁」但看到「第一頁」的資訊
前端為了避免這種窘境,會在該 function 做一個 cancel flag 去標記,以便在重複觸發請求時可以把標記的請求做取消
以 axios CancelToken(deprecated) + Vue 為例
import axios from 'axios'
const cancelToken = ref(null)
const handleFetch = () => {
// 每次請求前,如果有存在 token 就做執行取消,所以第一次不會有影響
if (cancelToken) {
cancelToken.cancel()
}
// 將此次的請求標記並存在 cancelToken 中
cancelToken.value = axios.CancelToken.source()
return http({
method: 'get',
url: '/admin/orders',
params: {
limit: 500,
page: 1
}
}, {
// 標記丟進 axios,後續在取消時才有對應的請求可以中止
cancelToken: cancelToken.value.token
})
}
在 axios v0.22.0 已經廢棄 CancelToken 的用法,全面擁抱 browser 原生的 AbortController,於是改寫成以下
import axios from 'axios'
const controller = ref(null)
const handleFetch = () => {
// 每次請求前,如果有存在 token 就做執行取消,所以第一次不會有影響
if (controller) {
controller.abort()
}
// 將此次的請求標記並存在 cancelToken 中
controller.value = new AbortController()
return http({
method: 'get',
url: '/admin/orders',
params: {
limit: 500,
page: 1
}
}, {
// 標記丟進 axios,後續在取消時才有對應的請求可以中止
signal: controller.value.signal
})
}
這樣確實解決了時間差異導致畫面不同步的問題,但這時候把視角再往上拉一點,我不禁反問:難道不是所有請求都該這樣處理嗎?
試探
於是我把手伸進了 interceptor 裡,踏上 cancel request one for all 的求道之路
同樣的邏輯先搬進 request interceptor 試一試
import axios from 'axios'
const http = axios.create({ ... })
let controller = null
http.interceptors.request.use(
(config) => {
if (controller) {
controller.abort()
}
controller = new AbortController()
config.signal = controller.signal
return config
},
(error) => {
// handle the request error
return Promise.reject(error);
}
)
乍看之下,好像是沒有問題,但要注意這裡畢竟是所有請求的中央處理,也就是不管是誰發起的他都六親不認一律取消前一個請求
那現在任務就很明確了:辨識發起的請求是否為同一個
實作
先來看看 axios request config 長什麼樣子
我第一個想到的是用 map 來記錄所有發過的請求,並儲存取消標記到對應的值,unique key 則使用 url + method
的組合拳 (如果站內有多個來源也可以考慮加入 baseURL
辨識)
const histories = new Map()
http.interceptors.request.use(
(config) => {
if (histories.has(`${config.url}__${config.method}`)) {
histories.get(`${config.url}__${config.method}`)()
}
const controller = new AbortController()
histories.set(`${config.url}__${config.method}`, controller.abort.bind(controller))
config.signal = controller.signal
return config
},
(error) => {
// handle the request error
return Promise.reject(error);
}
)
這樣一來所有相同路徑的重複請求都會被限制,同時只有最新的那個在作用 🤘 當然這只是我認為應該有的功能,也許沒這麼普遍落實也是有他的原因 ?! 在真正遇到痛點前就先這麼做吧!
Todo
不過後續要考量的東西也不少,目前還不會遇到就先表列記錄一下:
- Map 記憶體極限?在規模較大的專案中,是否會遇到請求紀錄肥到拖慢效能的程度?
- 開放參數調整讓想保持重複請求的特例可以使用, e.g.,
例如有些 api 可能會依據 query 來做區別,但在前端是需要把不同 query 都一併呈現在同一個畫面上,可能就會需要保持重複請求,或者也可以把 query params 視情況加入到 map 的辨識名稱上,達到同樣的效果demo.jsx
options: { keepRequest: true }