這個問題來源於一次無意中在項目裏發現的bug,場景是組件會在切換項目後重新渲染,每次重渲染後,組件掛載完畢會請求對應的當前項目的數據,存到store中展示到頁面上。
但網絡請求時間的長短是難以預測的,這就引發了一個問題:
切換到B項目,請求發出了但還沒回來,這時候再切換到A項目。那麼現在同時存在兩個請求,先前的請求B和新的請求A。
請求A的速度比較快,馬上成功了,將數據存到store中,展示出來。
但過一會請求B又回來了,也會把數據存到store。但這時候是在A項目下,數據一開始是A的,後來又會被替換成請求B的。這個時候就有問題了。
這就是請求的競態問題,就像現在這樣:
爲了模擬效果,在webpackDevServer中加了一個接口,根據項目參數來控制響應時間
before(app, server){
app.get("/api/request/:project", function (req, res) {
const { project } = req.params
if (project === "B") {
setTimeout(() => {
res.send({result: "B的結果"})
res.status(200)
}, 4000)
return
}
setTimeout(() => {
res.send({result: "A的結果"})
res.status(200)
}, 1000)
})
}
解決辦法
只針對這個問題,有一種簡單的方法,可以將請求回的數據放到組件中存儲,由於切換項目後,先前的組件銷燬了,顯示的是新的組件,那麼自然就不會出現問題了。
但有一點需要注意:這種方式需要在組件銷燬時,取消掉請求,或者在請求成功後判斷組件是否已經銷燬,再設置state,因爲我們不能在一個已銷燬的組件中設置它的state。
更合適的
不過對於已有項目來說,往往放到store中的數據需要被別的地方依賴,不能將數據放到組件中存儲。
既然如此,就要考慮如何在儘可能不動數據的基礎上,來進行控制了。
現在的問題出在兩個請求一個快一個慢,慢的請求數據覆蓋了快的請求的數據。
那麼控制的點在哪呢?在於請求成功後將數據存入store這個時候。
怎麼控制呢?是否將請求成功的數據更新到store。
那怎麼判斷是否應更新到store中呢?
其實明確一點就好了,切換項目導致了一先一後兩個請求發出,後發出的請求肯定是對應着當前已經切換的項目。
只要識別出當前的請求是不是後發出的請求就可以判斷是否應該存儲數據了。
可以根據時間來判斷,這就需要在store中存儲一個時間,來記錄當次請求發出的時間:reqStartLastTime
具體做法
在發出請求的時候,獲取一個時間戳作爲當前請求的開始時間點,然後等請求完成,將這個時間點記錄到store中。這樣下次請求過來的時候,這個時就相當於上一次請求的發出時間點了。
請求成功時,從store中獲取到上次的時間點,與本次請求發出的時間相比較,只有當本次發出時間大於上次請求發出時間的時候,纔將數據存到store中。
結合場景來說,切換到項目B,發請求記錄B發出的時間點,再切換到A,發請求記錄A發出的時間點。此時,兩個時間點A > B > store中記錄的時間點。
A請求先回來,判斷,A時間點 > store中記錄的時間點,通過,存儲據,將A時間點更新到store中,過了一會B回來了,驗證B時間點 < A時間點,不通過,不存數據。
export const fetchData = project => {
return (dispatch, getState) => {
const reqStartThisTime = Date.now()
dispatch({ type: FETCH_BEGIN })
fetch(`/api/request/${project}`)
.then(res => res.json())
.then(res => {
// 從store中獲取上一次的請求發出時間reqStartLastTime
const { view: { reqStartLastTime } } = getState()
// 進行比較,只有當前請求時間大於上一次的請求發出時間的時候,再存數據
if (reqStartThisTime > reqStartLastTime) {
dispatch({
type: FETCH_SUCCESS,
payload: res.result,
})
}
}).finally(() => {
const { view: { reqStartLastTime } } = getState()
// 請求完成時,將當前更新時間爲後發出請求的請求時間
dispatch({
type: RECORD_REQUEST_START_POINT,
payload: reqStartThisTime > reqStartLastTime ? reqStartThisTime :reqStartLastTime
})
})
}
}
效果如下,重點觀察請求200的先後順序:
至此,問題已經被解決了,但仍然不完美,因爲不止這一個地方需要如此的處理。
更進一步
上面爲一個特定場景提供了一個解決方案。通過在異步action中設置、獲取、以及比較請求的發出時間來控制是否應向store中存儲請求結果,但這樣的場景可能會很多,這就造成了重複的邏輯。對於重複性的邏輯儘可能封裝起來,將精力集中到業務上,這就要引出redux的中間件概念了。
redux的middleware的目標是改造dispatch,但是也可以不改造,可以將一些通用邏輯放到中間件中去,最後直接調用dispatch就好。
回顧一下上面在異步action中的過程:
- 請求發出時,記錄當前時間爲本次請求時間
- 請求完成時,比較本次發出時間與上次發出時間,將大的記錄到store中
- 請求成功時,比較本次發出時間與上次發出時間,一旦前者大於後者,將數據放到store中。
可以看出,也只有前兩步的邏輯可以與業務分開。因爲請求完成記錄結果這個動作是完全在請求成功的回調之內的,這時dispatch的是真正的action,無法下手。
那麼現在可以敲定,將存儲請求時間的邏輯抽象到中間件中,而在異步action的業務代碼中,只從store中獲取本次請求時間與上次請求時間,進行比較,從而決定是否要存儲請求結果。
注意,上面只向store記錄了一個時間,就是上次請求發出時間,但這裏將記錄當前時間爲本次請求時間也抽象出來了,所以要在store中再增加一個字段,記錄成當前請求發出的時間
新建一箇中間件,其實可以將redux-thunk的邏輯整合進來,再增加要抽象的邏輯。
function reqTimeControl({ dispatch, getState }) {
return next => {
return action => {
if (typeof action === "function") {
const result = action(dispatch, getState);
if (result && "then" in result) {
// 請求開始時將當前時間記錄爲本次請求時間,放入store
const thisTime = Date.now()
next({
type: "__RECORD_REQUEST_THIS_TIME__",
payload: thisTime
})
const { reqStartLastTime, reqStartThisTime } = getState()
result.finally(() => {
// 請求完成將本次請求時間與上次請求時間進行比較,將store中的時間更新爲大的
if (reqStartThisTime > reqStartLastTime) {
next({
type: "__RECORD_REQUEST_START_POINT__",
payload: reqStartThisTime
})
}
})
}
return result
}
return next(action)
}
}
}
export default reqTimeControl
在傳入applyMiddleware,替換掉redux-thunk
import { global, view, reqStartLastTime, reqStartThisTime } from "./reducer"
import reqTimeControl from "./middleware"
const store = createStore(
combineReducers({ global, view, reqStartLastTime, reqStartThisTime }),
applyMiddleware(reqTimeControl),
)
export default store
爲了讓中間件知道請求的狀態,需要在異步action中將返回promise的fetch返回出去。現在只用獲取兩個時間進行比較,就能決定是否更新數據了。
export const fetchData = project => {
return (dispatch, getState) => {
dispatch({
type: FETCH_BEGIN,
})
return fetch(`/api/request/${project}`)
.then(res => res.json())
.then(res => {
const { reqStartLastTime, reqStartThisTime } = getState()
if (reqStartThisTime > reqStartLastTime) {
dispatch({
type: FETCH_SUCCESS,
payload: res.result,
})
}
})
}
}
可以看到,將邏輯抽象到中間件中依然實現了效果,但是卻只用關心業務處理了。
redux變化
還不算完
現在的存儲store業務邏輯是處在請求成功的回調中的,中間件無法對這裏dispatch的action進行控制。所以第二種方法依然沒有完全擺脫掉重複邏輯,仍然需要在業務中配合返回fetch的promise,而且需要從store中獲取兩個時間進行比較。
中間件又來了
有沒有辦法將所有涉及到時間的處理邏輯放到一個地方,讓業務代碼還像之前那樣,只包含action,不感知到這層處理呢?當然可以,但需要讓中間件來接管請求。因爲這一層處理,貫穿整個網絡請求的週期。
如何來讓中間件接管請求,以及如何讓中間件做這一系列的控制呢?思考一下,在中間件中可以獲取到異步action(也就是dispatch的函數)執行的結果,但在這個異步action中,不會去發請求,請求交給中間件來處理,中間件監聽到返回結果中一旦有發送請求的信號,那麼就開始請求。
所以,要改造一下需要發請求的異步action的樣板代碼:
export const fetchData = project => {
return dispatch => {
return dispatch(() => {
return {
// FETCH就是告訴中間件,我要發起異步請求了,
// 對應了三個請求的action: FETCH_BEGIN, FETCH_SUCCESS, FETCH_FAILURE,
// 請求地址是url
FETCH: {
types: [ FETCH_BEGIN, FETCH_SUCCESS, FETCH_FAILURE ],
url: `/api/request/${project}`
},
}
})
}
}
再看中間件中的處理,要根據返回結果,判斷是否應該發送請求,改造一下:
function reqTimeControl({ dispatch, getState }) {
return next => {
return action => {
if (typeof action === "function") {
let result = action(dispatch, getState);
if (result) {
if ("FETCH" in result) {
const { FETCH } = result
// dispatch請求中的action
next({ type: FETCH.types[0] })
// 這裏將result賦值爲promise是爲了保證在組件中的調用函數是一個promise,
// 這樣能夠在組件中根據promise的狀態有更大的業務自由度,比如promise.all
result = fetch(FETCH.url).then(res => res.json()).then(res => {
next({
type: FETCH.types[1],
payload: res
})
return res
}).catch(error => {
next({
type: FETCH.types[2],
payload: error,
})
return error
})
}
}
return result
}
return next(action)
}
}
}
export default reqTimeControl
這裏需要注意一下,要將fetch作爲result返回出去,目的是讓我們在組件中調用的獲取數據的函數是一個promise,從而可以更自由地進行控制,例如需要等待多個請求都完成時做一些操作:
const ProjectPage = props => {
const { fetchData, fetchOtherData, result, project } = props
useEffect(() => {
Promise.all([fetchData(), fetchOtherData()]).then(res => {
// do something
})
}, [])
return <div>
<h1>{ result }</h1>
</div>
}
現在已經完成了中間件的改造,讓它可以控制發起異步請求,結合我們遇到的場景以及上面的解決方案,只需要將上面實現的邏輯整合到中間件中即可:
function reqTimeControl({ dispatch, getState }) {
return next => {
return action => {
if (typeof action === "function") {
let result = action(dispatch, getState);
if (result) {
if ("FETCH" in result) {
const { FETCH } = result
const thisTime = Date.now()
// dispatch請求中的action
next({ type: FETCH.types[0] })
result = fetch(FETCH.url).then(res => res.json()).then(res => {
// 請求完成時根據時間判斷,是否應更新數據
const { reqStartLastTime } = getState()
if (thisTime > reqStartLastTime) {
next({
type: FETCH.types[1],
payload: res
})
return res
}
}).catch(error => {
next({
type: FETCH.types[2],
payload: error,
})
return error
}).finally(() => {
// 請求完成時將本次的請求發出時間記錄到store中
const { reqStartLastTime } = getState()
if (thisTime > reqStartLastTime) {
next({
type: "__RECORD_REQUEST_START_POINT__",
payload: thisTime
})
}
})
}
}
return result
}
return next(action)
}
}
}
export default reqTimeControl
值得注意的是:由於是在中間件中,直接獲取當前時間就行,所以不用再把每次的請求發出時間記錄到store中,也就省去了這一步:
next({
type: "__RECORD_REQUEST_THIS_TIME__",
payload: thisTime
})
效果依然相同,這裏就不放圖了。
到現在爲止,應該達到了一個令人滿意的效果,將維護時間、判斷是否存數據這樣的重複邏輯抽象到中間件中,只關心業務代碼就可以,而且可以應對幾乎所有這樣的場景。
總結
這次把簡單問題的重複邏輯抽象到了中間件中,搭配Redux纔算解決了請求的競態問題。解決問題的同時也用中間件實現了一個簡單的請求攔截器,可以進行token的添加、loading狀態的處理等操作。文章中的例子只是實現了一個大致的效果,實際上中間件中的fetch要封裝一下才能應對大部分的業務場景,比如各種請求method、body、header參數這些。另外,如果想了解中間件的原理,爲你準備了一篇文章:簡單梳理Redux的源碼與運行機制
如果想了解我的更多技術文章,可以關注公衆號:一口一個前端