はいさい!カケハシの新米メンバー、オースティンと申します。 沖縄から参上しております! 概要 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 は奥深くて強力なのですが、強力だからこそ誤った使い方をすると痛い目に遭うなと思っています。 どんどん理解を深めていきましょう!