KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

RxJS - mergeMap vs switchMap、適切に使い分けましょう!

はいさい!カケハシの新米メンバー、オースティンと申します。

沖縄から参上しております!

概要

RxJS のmergeMapswitchMapの違いと使い方について解説します。

背景

Observableを使っていると、必ず直面する問題があります。それは、複数のObservableをどうやって一緒に実行できるか、という問題です。

とあるObservableの処理が終わった後に、そのデータを元に、別のObservableでさらに非同期処理をすることは開発者として多々あります。

Promisethenでまた別のPromiseを返してチェーンしていくのと同じことです。

しかし、RxJS では、Promiseと違ってObservableを直列にチェーンするのに使うpipeOperatorFunctionが複数存在します。代表的なのは、mergeMapmergeWith、およびswitchMapです。

なぜ RxJS には複数のチェーン手法が存在するのか

この疑問は湧きませんか?

この疑問に答えるためにはまず、ObservablePromiseはどう異なるのか考える必要があります。

筆者に言わせれば、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が解決された後でも、ログが出続けるのです。

log1

同じ例を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)された時に呼ばれるのです。

log2

すると、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で合わせた二つのObservableof(clickCount)intervalです。

要するに、mergeMapObservable<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 回目のクリックを元にしたcombineLatestObservable<[number, number]>は最後まで続くのだという結果を記憶に留めておきましょう。

switchMap

switchMapmergeMapと同じように、上流の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が出たのです!

まとめ

ここまでmergeMapswitchMapの違いを解説してきましたが、いかがでしょうか?

似たような効果があるのに、歴然な違いがあるので、筆者は知った時に驚きました。

この違いを知らずにコードを書いていると、解せないエラーが起きそうな気がします。

たとえば、HTTP リクエストでクリックに対してログを記録させたい時に、どれを使えばいいと思いますか?

ビジネスモデルにもよるのですが、switchMapだと、ログのリクエストがまだ終わっていないのに、ユーザーがまたクリックすると、前のログのリクエストがキャンセルされ、最後のクリックだけがログに残る結果になるのです。なので、mergeMapが向いているでしょう。

逆に、自動推測などだと、最新の値だけに対してリクエストを投げたいはずなので、mergeMapよりswitchMapが適しているのではないでしょうか?

RxJS は奥深くて強力なのですが、強力だからこそ誤った使い方をすると痛い目に遭うなと思っています。

どんどん理解を深めていきましょう!