原文連結:RxJS: Avoiding switchMap-Related Bugs
原文作者:Nicholas Jamieson;發表於2018年3月12日
譯者:yk;如需轉載,請註明出處,謝謝合作!
翻譯內容有些部分借鑑了 vannxy 的 RxJS: 避免 switchMap 的相關 Bug,深表感謝。
不久前,Victor Savkin 釋出了一篇關於在 Angular 應用中使用 NgRx effects 時,因濫用 switchMap
而導致的一個不易察覺的 bug 的推文:
Victor Savkin
@victorsavkin我看過的每個 Angular 應用都因為 switchMap 的使用不當而產生很多錯誤。這是跟 RxJS 有關的 issues 的最大來源。 #angular
那麼這個 bug 是什麼呢?
讓我們以購物車為例,看看下面的 effect 和 epic 是怎麼濫用 switchMap
的,然後我們再考慮用一些替代的操作符。
這是一個濫用 switchMap
的 NgRx effect:
@Effect()
public removeFromCart = this.actions.pipe(
ofType(CartActionTypes.RemoveFromCart),
switchMap(action => this.backend
.removeFromCart(action.payload)
.pipe(
map(response => new RemoveFromCartFulfilled(response)),
catchError(error => of(new RemoveFromCartRejected(error)))
)
)
);
複製程式碼
這是一個與之等價的 redux-observable
epic:
const removeFromCart = actions$ => actions$.pipe(
ofType(actions.REMOVE_FROM_CART),
switchMap(action => backend
.removeFromCart(action.payload)
.pipe(
map(response => actions.removeFromCartFulfilled(response)),
catchError(error => of(actions.removeFromCartRejected(error)))
)
)
);
複製程式碼
我們的購物車列出了使用者打算購買的商品,每個商品都有一個“移出購物車”按鈕。點選該按鈕就會將 RemoveFromCart
動作調至 effect/epic ,後者與後端通訊並移除該商品。
大多數情況下,這都將如期執行。然而,使用 switchMap
會在這裡引入競爭條件(race condition)。
如果使用者一連點選了多個商品的“移出購物車”按鈕,則結果將取決於按鈕點選的頻率。
如果使用者在 effect/epic 與後端通訊時再次點選了按鈕,之前的移除操作就會被掛起,而在這使用 switchMap
則會中止之前被掛起的操作。
因此,根據按鈕點選的頻率,程式可能會:
- 刪除所有被點選的商品;
- 僅刪除部分被點選的商品;
- 在後端刪除了部分被點選的商品,而前端的購物車卻沒反應。
很明顯,這是一個 bug。
不幸的是,當需要使用 flattening operator(打平操作符)時,switchMap
通常會被建議是首選,但這並不是在所有場景下都是安全的。
RxJS 共有四個 flattening operator 可供選擇:
mergeMap
(也稱為flatMap
);concatMap
;switchMap
;exhaustMap
。
讓我們來看看這些操作符,瞭解它們之間的差異,並決定哪個操作符最適合購物車場景。
mergeMap/flatMap
如果將 switchMap
替換為 mergeMap
,effect/epic 將同時處理每個已調來的操作。也就是說,被掛起的移除操作將不會被中止;後端請求會被同時發起,當移除完成後再處理響應。
需要重點注意的是,由於操作是併發處理的,所以響應的順序可能與請求的順序不同。舉個例子,如果使用者依次點選了兩個商品的“移出購物車”按鈕,後點選的商品可能會先被移除。
在購物車場景裡,商品的移除順序並不重要,所以使用 mergeMap
來代替 switchMap
可解決這個問題。
concatMap
雖然從購物車中移除商品的順序可能無關緊要,但也有一些操作對執行順序是有嚴格要求的。
再舉個例子,如果我們的購物車有一個用於增加商品數量的按鈕,則以正確的順序來處理排程的操作是非常重要的。否則,前後端的商品數量可能最終會不同步。
對於順序十分重要的操作,我們應該使用 concatMap
,其實 concatMap
就相當於使用 mergeMap
時將其“允許併發量”引數 concurrent
設定為 1
。也就是說,使用 concatMap
的 effect/epic 每次只會處理一個後端請求,每個操作都會按照它們被呼叫的順序排隊。
concatMap
是安全且保守的選擇。如果你不確定在 effect/epic 中使用何種 flattening operator 時,就用 concatMap
吧。
switchMap
每當相同型別的操作被呼叫時,使用 switchMap
會中止之前已被掛起的後端請求。這使 switchMap
對於增加、修改以及刪除操作來說不那麼安全。甚至在處理讀操作時也會引入 bug 。
switchMap
是否適合特定的讀操作取決於當另一個相同型別的操作被呼叫時,後端對先前操作做出的響應是否還有用。讓我們來看看一個使用 switchMap
的操作是如何引入 bug 的。
如果我們的購物車中的每個商品都有一個“詳情”按鈕,用於在行內顯示一些商品的詳細資訊,effect/epic 則使用 switchMap
來處理該按鈕的點選動作,這裡又引入了一個競爭條件。如果使用者一連點選了多個商品的“詳情”按鈕,那麼這些被點選的商品是否會顯示詳細資訊則同樣取決於使用者點選按鈕的頻率。
和 RemoveFromCart
操作一樣,使用 mergeMap
就可以解決這個問題了。
switchMap
只應當用於 effects/epics 中的讀操作處理,並且當另一個相同型別的操作被呼叫時,後端對先前操作做出的響應可被丟棄。
讓我們來看看 switchMap
的一個適用場景。
如果我們的購物車要顯示商品的總價加上運費,對購物車內容做出的每個更改都會觸發一次 GetCartTotal
操作。這時在 effect/epic 中使用 switchMap
來處理 GetCartTotal
是完全合適的。
如果當 effect/epic 正在處理一個 GetCartTotal
操作時,購物車的內容發生了變動,那麼對當前處理中的請求做出的響應將會是過時的,也就是更改之前購物車內的商品總數,因此中止正在處理中的請求是沒有任何問題的。事實上,相較於掛起請求,等其完成之後再將其忽略,或更有甚者把過期的響應資料也渲染出來,直接中止請求會是更好的選擇。
exhaustMap
exhaustMap
或許是最不為人知的一個 flattening operator 了,但它很好解釋:你可以認為它是 switchMap
的對立物。
當使用 switchMap
時,之前掛起的後端請求會被中止,也就是說更傾向於處理最新調來的操作。而當使用 exhaustMap
時,如果當前有正在處理的後端請求,那麼新調來的操作都會被忽略。
讓我們來看看 exhaustMap
的一個適用場景。
開發人員應該對有一類使用者再熟悉不過了:按鈕狂擊者。當他們點選一個按鈕卻發現什麼都沒發生時,就會繼續點點點一直點。
假如我們的購物車有一個重新整理按鈕,effect/epic 使用 switchMap
來處理重新整理,那麼每次點選按鈕都會中止先前被掛起的重新整理操作。所以狂點按鈕沒有任何意義,而且可能使使用者等待更長的時間,直到重新整理被執行。
如果在 effect/epic 中使用 exhaustMap
來替代 switchMap
處理重新整理的話,多餘的點選將會被忽略。
總結
總而言之,當你需要在一個 effect/epic 中使用 flattening operator 時,你應該:
- 當操作不能被中止/忽略,且必須保證執行順序的情況下,使用
concatMap
;(這也是一個保守的選擇,因為其總會按照預期執行) - 當操作不能被中止/忽略,但執行順序並不重要的情況下,使用
mergeMap
; - 當處理讀操作時,且當另一個相同型別的操作被呼叫時,先前的請求應當被中止的情況下,使用
switchMap
; - 當相同型別的操作應當被忽略的情況下,使用
exhaustMap
。
使用 TSLint 來避免濫用 switchMap
我曾在 rxjs-tslint-rules
包裡新增了一條 rxjs-no-unsafe-switchmap
規則。
該規則可以識別 NgRx effects 和 redux-observable
epics,並確定它們的操作型別,然後根據操作型別搜尋具體的動詞(例如:add
,update
,remove
等等)。它有一些合理的預設值,如果你覺得這些預設值過於常規,也可以對它進行配置。
在啟用了該條規則後,我在我去年寫的一些應用上執行了 TSLint ,發現了不少 effects 都在以不安全的方式使用 switchMap
。所以,謝謝你的推文,Victor。