原文連結: blog.angularindepth.com/learn-to-co…
本文為 RxJS 中文社群 翻譯文章,如需轉載,請註明出處,謝謝合作!
如果你也想和我們一起,翻譯更多優質的 RxJS 文章以奉獻給大家,請點選【這裡】
在開發複雜度相當高的應用時,通常資料來源都不止一個。這些資料來源可能是多個像 Firebase 這樣的外部資料點,也可能是若干個使用者與之互動的 UI 元件。序列組合 ( sequence composition ) 是一項可以讓你跨多個資料來源來建立複雜查詢的技術,它是通過將這些相關的多個資料流組合成單個資料流來實現的。RxJS 提供了各式各樣的操作符來幫助你完成此項任務,在本文中我們將介紹一些最常用的操作符。
在本文的寫作過程中,為了更好地展現出所有操作符之間的區別,我設計創造了一些超級直觀的資料流動圖,這讓我幾乎成為了一名兼職的專業動畫師。但是,所有圖表都是以 GIF 動圖的形式嵌入到本文中的,所以需要一點時間才能全部載入出來。還請耐心等待。
在本文出現的程式碼中,我都將使用 pipeable 操作符,如果不熟悉的話,可以點選這裡檢視。我還會使用一個自定義的 stream
操作符,它會以訂閱時傳入的第一個引數作為名稱來非同步地生成不斷髮出值的流。
下面是本文中用到的圖表型別的說明:
併發地合併多個流
我們第一個要介紹的操作符就是 merge 。此操作符可以組合若干個流,然後併發地發出每個輸入流中的所有值。一旦輸入流中產生了值,這些值會作為結果流的一部分而被髮出。這種過程在文件中通常被稱之為打平 ( flattening ) 。
當所有輸入流完成時,結果流就會完成,如何任意輸入流報錯,那麼結果流就會報錯。如果某個輸入流沒有完成的話,那麼結果流便不會完成。
如果你只是對來自多個流中所有的值感興趣,就好像它們是由一個流所產生的,而不關心值發出的順序,那麼請使用 merge
操作符。
在下面的動圖中,可以看到 merge
操作符組合了兩個流 A
和 B
,這兩個流各自生成 3 個值,每當發出值時值便會傳遞到結果流中。
下面是與動圖配套的程式碼示例:
const a = stream('a', 200, 3, 'partial');
const b = stream('b', 200, 3, 'partial');
merge(a, b).subscribe(fullObserver('merge'));
// 還可以使用例項操作符
// a.pipe(merge(b)).subscribe(fullObserver('merge'));
複製程式碼
可編輯的 stackblitz 線上 demo: combining-sequences-merge.stackblitz.io
順序地連線多個流
接下來要介紹的操作符是 concat 。它按順序訂閱每個輸入流併發出其中所有的值,同一時間只會存在一個訂閱。只有當前輸入流完成的情況下才會去訂閱下一個輸入流並將其值傳遞給結果流。
當所有輸入流完成時,結果流就會完成,如何任意輸入流報錯,那麼結果流就會報錯。如果某個輸入流沒有完成的話,那麼結果流便不會完成,這意味著某些流永遠都不會被訂閱。
如果值發出的順序很重要,並且你想要傳給操作符的第一個輸入流先發出值的話,那麼請使用 concat
操作符。舉個例子,有兩個流,一個從快取中獲取值,一個從遠端伺服器獲取值。如果你想要將兩者組合起來並確保快取中的值先發出的話,就可以使用 concat
。
在下面的動圖中,可以看到 concat
操作符組合了兩個流 A
和 B
,這兩個流各自生成 3 個值,先是 A
發出的值傳遞到結果流,然後才是 B
。
下面是與動圖配套的程式碼示例:
const a = stream('a', 200, 3, 'partial');
const b = stream('b', 200, 3, 'partial');
concat(a, b).subscribe(fullObserver('concat'));
// 還可以使用例項操作符
// a.pipe(concat(b)).subscribe(fullObserver(‘concat’));
複製程式碼
可編輯的 stackblitz 線上 demo: concat.stackblitz.io
讓多個流競爭
接下來要介紹的操作符 race 引入了一個相當有趣的概念。它本身並對流進行任何組合,而是選擇第一個產生值的流。一旦第一個流發出值後,其他的流就會被取消訂閱,完全忽略掉。
當被選中的流完成時,結果流也隨之完成,如果被選中的流報錯,那麼結果流也將報錯。同樣,如果被選中的流不完成,那麼結果流便永遠不會完成。
如果有多個提供值的流時此操作符會非常有用,舉個例子,某個產品的伺服器遍佈世界各地,但由於網路條件,延遲是不可預測的,並且差異巨大。使用 race
的話,可以向多個資料來源傳送同樣的請求,隨後消費首個響應請求的結果。
在下面的動圖中,可以看到 race
操作符組合了兩個流 A
和 B
,這兩個流各自生成 3 個值,但只有 A
的值被髮出了,因為它首先發出了值。
const a = intervalProducer(‘a’, 200, 3, ‘partial’);
const b = intervalProducer(‘b’, 500, 3, ‘partial’);
race(a, b).subscribe(fullObserver(‘race’));
// 還可以使用例項操作符
// a.pipe(race(b)).subscribe(fullObserver(‘race’));
複製程式碼
可編輯的 stackblitz 線上 demo: combining-sequences-race-b-is-ignored.stackblitz.io
使用高階 observables 來組合未知數量的流
之前介紹過的操作符,無論是靜態的還是例項的,都只能用來組合已知數量的流,但如果預先並不知道用來組合的全部流呢,該怎麼辦?實際上,這種一種非常常見的非同步場景。舉個例子,某個網路請求會根據返回值的結果來發起一些其他的請求。
RxJS 提供了一些接收流中流的操作符,也稱之為高階 observables 。這些操作符將接收內部流的值並按照前一章節所介紹過的組合規則來進行操作。
如何任何內部流報錯的話,這些操作符也將報錯,並且它們只能使用例項操作符。現在我們來一個個地進行介紹。
MergeAll
此操作符會合並所有內部流發出的值,合併方式就如同 merge
操作符,是併發的。
在下面的動圖中,可以看到高階流 H
,它會生成兩個內部流 A
和 B
。 mergeAll
操作符將合併這兩個流中的值,每當發出值時值便會傳遞到結果流中。
下面是與動圖配套的程式碼示例:
const a = stream(‘a’, 200, 3);
const b = stream(‘b’, 200, 3);
const h = interval(100).pipe(take(2), map(i => [a, b][i]));
h.pipe(mergeAll()).subscribe(fullObserver(‘mergeAll’));
複製程式碼
可編輯的 stackblitz 線上 demo: merge-all.stackblitz.io.
ConcatAll
此操作符將合併所有內部流發出的值,合併方式就如同 concat
操作符,是按順序連線。
在下面的動圖中,可以看到高階流 H
,它會生成兩個內部流 A
和 B
。 concatAll
操作符首先從流 A
中取值,然後再從流 B
中取值,並將所有值傳遞到結果流中。
下面是與動圖配套的程式碼示例:
const a = stream(‘a’, 200, 3);
const b = stream(‘b’, 200, 3);
const h = interval(100).pipe(take(2), map(i => [a, b][i]));
h.pipe(concatAll()).subscribe(fullObserver(‘concatAll’));
複製程式碼
可編輯的 stackblitz 線上 demo: concat-all.stackblitz.io.
SwitchAll
有時候從所有內部流中接收值並非是我們想要的效果。在某些場景下,我們可能只對最新的內部流中的值感興趣。一個比較好的例子就是搜尋。當使用者輸入關鍵字時,就向伺服器傳送請求,因為請求是非同步的,所以返回的請求結果是一個 observable 。在請求結果返回之前,如果使用者更新了搜尋框中的關鍵字會發生什麼情況?第二個請求將會發出,現在已經有兩個請求傳送給伺服器了。但是,第一次搜尋的結果使用者已經不再關心了。更有甚者,如果第一次的搜尋結果要是晚於第二次的搜尋結果的話 (譯者注: 比如伺服器是分散式的,兩次請求請求的不是同一個節點),那麼使用者看到的結果將是第一次的,這會讓使用者感到困擾。我們不想讓這種事情發生,這也正是 switchAll
操作符的用武之地。它只會訂閱最新的內部流並忽略(譯者注: 忽略 = 取消訂閱)前一個內部流。
在下面的動圖中,可以看到高階流 H
,它會生成兩個內部流 A
和 B
。switchAll
操作符首先從流 A
中取值,當發出流 B
的時候,會取消對流 A
的訂閱,然後從流 B
中取值,並將值傳遞到結果流中。
下面是與動圖配套的程式碼示例:
const a = stream(‘a’, 200, 3);
const b = stream(‘b’, 200, 3);
const h = interval(100).pipe(take(2), map(i => [a, b][i]));
h.pipe(switchAll()).subscribe(fullObserver(‘switchAll’));
複製程式碼
可編輯的 stackblitz 線上 demo: switch-all.stackblitz.io.
concatMap、 mergeMap 和 switchMap
有趣的事是對映操作符 concatMap、 mergeMap 和 switchMap 的使用頻率要遠遠高於它們所對應的處理高階 observable 的操作符 concatAll
、 mergeAll
和 switchAll
。但是,如果你細想一下,它們幾乎是一樣的。所有 *Map
的操作符實際上都是通過兩個步驟來生成高階 observables 的,先對映成高階 observables ,再通過相對應的組合邏輯來處理高階 observables 所生成的內部流。
我們先來看下之前的 meregeAll
操作符的程式碼示例:
const a = stream('a', 200, 3);
const b = stream('b', 200, 3);
const h = interval(100).pipe(take(2), map(i => [a, b][i]));
h.pipe(mergeAll()).subscribe(fullObserver('mergeAll'));
複製程式碼
map
操作符生成了高階 observables ,然後 mergeAll
操作符將這些內部流的值進行合併,使用 mergeMap
可以輕鬆替換掉 map
和 mergeAll
,就像這樣:
const a = stream('a', 200, 3);
const b = stream('b', 200, 3);
const h = interval(100).pipe(take(2), mergeMap(i => [a, b][i]));
h.subscribe(fullObserver('mergeMap'));
複製程式碼
兩段程式碼的結果是完全相同的。concatMap
和 switchMap
亦是如此,請大家自行試驗。
通過配對的方式來組合流
之前的操作符都是讓我們可以打平多個流並將這些流中的值原封不動地傳遞給結果流,就好像所有值來自同一個流。我們接下來要介紹的這組操作符都接收多個流作為輸入,但不同之處在於它們將每個流中的值進行配對,然後生成單個組合值(譯者注: 預設是陣列)來作為結果流中的值。
每個操作符都可選擇性地接收一個投射函式 ( projection function ) 作為最後的引數,該函式定義額了結果流中的值如何進行組合。在本文的示例中,我都將使用預設的投射函式,它只是簡單地通過逗號作為分隔符將值連線起來。在本節的最後我將展示如何使用自定義的投射函式。
CombineLatest
第一個介紹的操作符是 combineLatest
。使用它可以取多個輸入流中的最新值,並將這些值轉換成一個單個值傳遞給結果流。RxJS 會快取每個輸入流中的最新值,只有當所有輸入流都至少發出一個值後才會使用投射函式(從之前快取中取出最新值)來計算出結果值,然後通過結果流將計算的結果值發出。
當所有輸入流完成時,結果流就會完成,如何任意輸入流報錯,那麼結果流就會報錯。如果某個輸入流沒有完成的話,那麼結果流便不會完成。換句話說,如何任何輸入流沒發出值就完成了,那麼結果流也將完成,並且在完成的同時不會發出任何值,因為無法從已完成的輸入流中取值放入到結果流中。還有,如果某個輸入流即不發出值,也不完成,那麼 combineLatest
將永遠不會發出值以及完成,原因同上,它將一直等待全部的輸入流都發出值。
如果你需要對某些狀態的組合進行求值,並且當其中某個狀態發生變化時再次進行求值,則此運算子非常有用。最簡單的例子就是監控系統。每個服務都可以用一個流來表示,流返回布林值以標識服務是否可用。當所有服務都可用時,監控狀態會是綠燈,所以投射函式應該只是簡單地執行邏輯與操作即可。
在下面的動圖中,可以看到 combineLatest
操作符組合了兩個流 A
和 B
。一旦所有的輸入流都至少發出一個值後,結果流會將這些值組合後發出:
下面是與動圖配套的程式碼示例:
const a = stream('a', 200, 3, 'partial');
const b = stream('b', 500, 3, 'partial');
combineLatest(a, b).subscribe(fullObserver('latest'));
複製程式碼
可編輯的 stackblitz 線上 demo: combine-latest.stackblitz.io.
Zip
zip
操作符的合併方式也非常有趣,它的機制在某種程度上類似於衣服或者包上的拉鍊。它將兩個及兩個以上的輸入流中的對應值組合成一個元祖(兩個輸入流的情況下為一對)。它會等待所有的輸入流中都發出相對應的值後,再使用投射函式來將其轉變成單個值,然後在結果流中發出。只有從每個輸入流中湊齊對應的新值時,結果流才會發出值,因此如果其中一個輸入流比另一個的值發出地更快,那麼結果值發出的速率將由兩個輸入流中的較慢的那個決定。
當任意輸入流完成時並且與之配對的值從其他輸入流發出後,結果流也將完成。如果任意輸入流永遠不完成的話,那麼結果流也將永遠不會完成,如果任意輸入流報錯的話,結果流也將報錯。
使用 zip
操作符可以很簡單地實現使用定時器來生成範圍值的流。下面是基礎示例,其中使用投射函式來只返回 range
流中的值:
zip(range(3, 5), interval(500), v => v).subscribe();
複製程式碼
在下面的動圖中,可以看到 zip
操作符組合了兩個流 A
和 B
。一旦相對應的值配對成功,結果流就會發出組合值:
const a = stream('a', 200, 3, 'partial');
const b = stream('b', 500, 3, 'partial');
zip(a, b).subscribe(fullObserver('zip'));
複製程式碼
可編輯的 stackblitz 線上 demo: zip.stackblitz.io.
forkJoin
有時候,有一組流,但你只關心每個流中的最後一個值。通常這些流也只有一個值。舉個例子,你想要發起多個網路請求,並只想當所有請求都返回結果後再執行操作。此操作符的功能與 Promise.all 類似。但是,如果流中的值多於一個的話,除了最後一個值,其他都將被忽略掉。
只有當所有輸入流都完成時,結果流才會發出唯一的一個值。如果任意輸入流不完成的話,那麼結果流便永遠不會完成,如何任意輸入流報錯的話,結果流也將報錯。
在下面的動圖中,可以看到 forkJoin
操作符組合了兩個流 A
和 B
。當所有輸入流都完成後,結果流將每個輸入流中的最後一個值組合起來併發出:
下面是與動圖配套的程式碼示例:
const a = stream('a', 200, 3, 'partial');
const b = stream('b', 500, 3, 'partial');
forkJoin(a, b).subscribe(fullObserver('forkJoin'));
複製程式碼
可編輯的 stackblitz 線上 demo: fork-join.stackblitz.io.
WithLatestFrom
本文最後介紹的一個操作符是 withLatestFrom
。當有一個主線流,同時還需要其他流的最新值時,可以使用此操作符。withLatestFrom
與 combineLatest
有些類似,不同之處在於 combineLatest
是當任意輸入流發出值時,結果流都發出新的值,而 withLatestFrom
是隻有當主線流發出值時,結果流才發出新的值。
如同 combineLatest
,withLatestFrom
會一直等待每個輸入流都至少發出一個值,當主線流完成時,結果流有可能在完成時從未發出過值。如果主線流不完成的話,那麼結果流永遠不會完成,如果任意輸入流報錯的話,結果流也將報錯。
在下面的動圖中,可以看到 withLatestFrom
操作符組合了兩個流 A
和 B
,B
是主線流。每次 B
發出新的值時,結果流都會使用 A
中的最新值來發出組合值:
下面是與動圖配套的程式碼示例:
const a = stream('a', 3000, 3, 'partial');
const b = stream('b', 500, 3, 'partial');
b.pipe(withLatestFrom(a)).subscribe(fullObserver('latest'));
複製程式碼
可編輯的 stackblitz 線上 demo: with-latest-from.stackblitz.io.
投射函式
正如本節開始所提到的,所有通過配對的方式來組合流的操作符都可以接收一個可選的投射函式。此函式定義了結果值是如何進行轉換的。使用投射函式可以選擇只發出某個特定輸入流中的值或者以任意方式來連線值:
// 返回第二個流中的值
zip(s1, s2, s3, (v1, v2, v3) => v2)
// 使用 - 作為分隔符來連線值
zip(s1, s2, s3, (v1, v2, v3) => `${v1}-${v2}-${v3}`)
// 返回單個布林值
zip(s1, s2, s3, (v1, v2, v3) => v1 && v2 && v3)
複製程式碼
如果想集中看所有的動圖,請參見 Pierre Criulanscy 的這個 gist 。