はいさい!カケハシの新米メンバー、オースティンと申します。
沖縄から参上しております!
概要
RxJS のmergeMap
とswitchMap
の違いと使い方について解説します。
背景
Observable
を使っていると、必ず直面する問題があります。それは、複数のObservable
をどうやって一緒に実行できるか、という問題です。
とあるObservable
の処理が終わった後に、そのデータを元に、別のObservable
でさらに非同期処理をすることは開発者として多々あります。
Promise
のthen
でまた別のPromise
を返してチェーンしていくのと同じことです。
しかし、RxJS では、Promise
と違ってObservable
を直列にチェーンするのに使うpipe
のOperatorFunction
が複数存在します。代表的なのは、mergeMap
、mergeWith
、およびswitchMap
です。
なぜ RxJS には複数のチェーン手法が存在するのか
この疑問は湧きませんか?
この疑問に答えるためにはまず、Observable
とPromise
はどう異なるのか考える必要があります。
筆者に言わせれば、Observable
の最も大きな違いは、未解決のObservable
の処理を取り消す、つまり止めることが簡単にできる点でしょう。
Promise
では、Promise
が解決してもその中で実行された処理を取り消す方法がありません。以下のコードを見ましょう。
const promise = new Promise((resolve) => { let count = 0; setInterval(() => console.log(`Loading ${++count} seconds.`), 1000); setTimeout(resolve, 5000); }); promise.then(() => console.log("Resolved!!"));
読者は読んでお分かりかと思いますが、このコードを実行してみると、以下のように、Promise
が解決された後でも、ログが出続けるのです。
同じ例をObservable
で書きます。
import { Observable } from "rxjs"; const observable$ = new Observable((observer) => { let count = 0; const interval = setInterval(() => console.log(`Loading ${++count} seconds.`), 1000); setTimeout(() => { observer.next("Timeout ended."); observer.complete(); }, 5000); return () => clearInterval(interval); }); observable$.subscribe({ next: console.log, complete: () => console.log("Completed!") });
Observable
の executor 関数が、戻り値としてその interval をクリアする関数を返すのですが、これが Observable が解決(complete)された時に呼ばれるのです。
すると、Promise
と違って永遠に続くinterval
が残されません。
この 後片付け の機能が、Observable
の非常に優れたところなのです。
そして、本記事につながる部分ですが、この後片付けがあるからこそ、Observable
をチェーンする時の手法が用途によって異なるのです。
mergeMap とは何か
mergeMap
は、map と似ていますが、簡単にいうと、一つのOberservable
から流れたデータを違うObservable
に流すチェーンOperatorFunction
なのです。
以下の例を見てみましょう。
import { fromEvent, scan, mergeMap, interval, takeWhile, tap, combineLatest, of } from "rxjs"; const docClick$ = fromEvent(document, "click"); const clickCount$ = docClick$.pipe( scan((acc) => acc + 1, 0), tap((count) => console.log(`Document was clicked ${count} time(s).`)) ); clickCount$ .pipe( mergeMap((clickCount) => { const intervalPerClickCount = [of(clickCount), interval(500).pipe(takeWhile((i) => i < 5))]; return combineLatest(intervalPerClickCount); }) ) .subscribe(([clickCount, i]) => console.log(`mergeMap: Click no ${clickCount}, interval count: ${i}`));
document
をクリックすると、クリックした回数をまずclickCount$
で足し算します。
それから、mergeMap
を使って新しいObservable
を返します。
その新しいObservable
は、combineLatest
で合わせた二つのObservable
、of(clickCount)
とinterval
です。
要するに、mergeMap
でObservable<number>
をObservable<[number, number]>
というふうに変えています。
試してみる
document
を一回クリックすると以下のように出力されます。
Document was clicked 1 time(s). mergeMap: Click no 1, interval count: 0 mergeMap: Click no 1, interval count: 1 mergeMap: Click no 1, interval count: 2 mergeMap: Click no 1, interval count: 3 mergeMap: Click no 1, interval count: 4
document
を 2 回連発でクリックすると以下のようにログが出力されます。
Document was clicked 1 time(s). Document was clicked 2 time(s). mergeMap: Click no 1, interval count: 0 mergeMap: Click no 2, interval count: 0 mergeMap: Click no 1, interval count: 1 mergeMap: Click no 2, interval count: 1 mergeMap: Click no 1, interval count: 2 mergeMap: Click no 2, interval count: 2 mergeMap: Click no 1, interval count: 3 mergeMap: Click no 2, interval count: 3 mergeMap: Click no 1, interval count: 4 mergeMap: Click no 2, interval count: 4
ここで重要な観察ですが、2 回目のクリックがあっても、1 回目のクリックを元にしたcombineLatest
のObservable<[number, number]>
は最後まで続くのだという結果を記憶に留めておきましょう。
switchMap
switchMap
はmergeMap
と同じように、上流のObservable
を新しいObservable
に合わせます。
しかし、重要な違いがあります。
switchMap は、上流の最も最新も値をのみとって、下流の新しいObservable
に流すのです。
たとえ、以前の値に基づいて流した下流のObservable
がまだ解決されていないとしても、それらのObservable
を止めるのです。
上記のソースコードでmergeMap
が使われていたところをswitchMap
にしてみましょう。
clickCount$ .pipe( switchMap((clickCount) => { const intervalPerClickCount = [of(clickCount), interval(500).pipe(takeWhile((i) => i < 5))]; return combineLatest(intervalPerClickCount); }) ) .subscribe(([clickCount, i]) => console.log(`switchMap: Click no ${clickCount}, interval count: ${i}`));
これも実験してみましょう!
試してみる
1 回だけクリックしてみる
Document was clicked 1 time(s). switchMap: Click no 1, interval count: 0 switchMap: Click no 1, interval count: 1 switchMap: Click no 1, interval count: 2 switchMap: Click no 1, interval count: 3 switchMap: Click no 1, interval count: 4
mergeMap
と同じ結果です。
2 回連発してクリックしてみる
Document was clicked 1 time(s). Document was clicked 2 time(s). switchMap: Click no 2, interval count: 0 switchMap: Click no 2, interval count: 1 switchMap: Click no 2, interval count: 2 switchMap: Click no 2, interval count: 3 switchMap: Click no 2, interval count: 4
なるほど!**最後のクリックだけ、5 回のinterval
が出たのです!
まとめ
ここまでmergeMap
とswitchMap
の違いを解説してきましたが、いかがでしょうか?
似たような効果があるのに、歴然な違いがあるので、筆者は知った時に驚きました。
この違いを知らずにコードを書いていると、解せないエラーが起きそうな気がします。
たとえば、HTTP リクエストでクリックに対してログを記録させたい時に、どれを使えばいいと思いますか?
ビジネスモデルにもよるのですが、switchMap
だと、ログのリクエストがまだ終わっていないのに、ユーザーがまたクリックすると、前のログのリクエストがキャンセルされ、最後のクリックだけがログに残る結果になるのです。なので、mergeMap
が向いているでしょう。
逆に、自動推測などだと、最新の値だけに対してリクエストを投げたいはずなので、mergeMap
よりswitchMap
が適しているのではないでしょうか?
RxJS は奥深くて強力なのですが、強力だからこそ誤った使い方をすると痛い目に遭うなと思っています。
どんどん理解を深めていきましょう!