HOWTO · Angular

Angular Observable VS. Promise

この記事では、Angular で Observable と Promise とは何かについて説明します。さらに、observable と Promise の根本的な違いについて説明します。

このページの内容

最終的に1つの結果を得る場合はPromiseを、時間の経過とともに値が現れるタスクにはObservableを選びます。Angularを使っていても、これらの仕組みが互換になるわけではありません。適切な選択は、コードに必要な動作によって決まります。

Angular における Promise とは

Promiseは、値による履行または理由による拒否という、将来確定する1つの結果を表すJavaScriptオブジェクトです。状態はpendingからfulfilledまたはrejectedへ移り、then、catch、finallyメソッドを使って処理をつなげたり、結果を扱ったりできます。Promiseが確定するのは1度だけで、履行されたPromiseが後から別の値を通知することはありません。

Promiseのexecutorは、Promiseを生成した時点で実行を開始します。Promise APIには組み込みのキャンセルや再試行の操作はありませんが、アプリケーションコードでキャンセルに対応する処理や再試行のロジックをPromiseの周囲に実装できます。処理が増えると、長いチェーンは追いにくくなることもあります。

Angular における Observable とは

Observableは、0個以上の値を通知した後に完了またはエラーとなるストリームを表します。また、どちらの終端通知もなく、無期限に開いたままになることもあります。AngularのHttpClientはRxJSのObservableを返します。Observableは購読時に処理を開始することがよくありますが、開始のタイミングは生成方法によって異なります。RxJSのオペレーターでストリームを変換・結合できます。購読解除によって通知を止められ、生成元がキャンセルに対応していれば、基盤となる処理もキャンセルできる場合があります。

Observableだからといって、常にキャンセルや再試行ができる、あるいは性能が向上するとは限りません。そうした動作は生成元やオペレーターによって異なります。RxJSには便利なツールがそろっていますが、ストリームに慣れていない開発者には学習コストがかかります。

Angular における Promise と Observable の主な違い

比較項目 Promise Observable
値 履行時の値1つ、または拒否の理由 nextの値を0個以上通知します。ストリームは完了またはエラーになることも、どちらの通知もないまま開いた状態を保つこともあります
開始時期 生成時にexecutorが実行されます 購読時に始まることが多く、生成方法によってタイミングが異なります
エラー catchまたは拒否ハンドラーで処理します 購読内で処理するか、catchErrorなどのオペレーターを使います
キャンセル 組み込みのキャンセルメソッドはありません 購読解除で監視を止められ、対応する処理をキャンセルできる場合があります
適した用途 単純な計算など、結果が1つの場合 イベント、変化する値、RxJSでの合成が役立つ処理
再試行 組み込みの再試行はありません。生成する関数を自分で再度呼び出します retryなどのオペレーターは、失敗後にソースを再購読します
共有 すべてのハンドラーが、確定した1つの同じ結果を受け取ります コールドなObservableは購読者ごとに生成元を再実行します。1回の実行で多くの購読者に対応させる場合は、ホットなソースを明示的に共有します

AngularのHTTPリクエストでは、RxJSによる合成やキャンセルの流れが必要ならHttpClientのObservableを使います。レスポンスに配列が含まれていても、要素が複数あることだけを理由にObservableが必要になるわけではありません。リクエストが返すのは、依然として1つのレスポンス値です。同じレスポンスはPromiseでも表せます。タスクと周辺のAPIに合う仕組みを選びましょう。

キャンセル、遅延実行、再試行の詳細

キャンセルは、最もはっきりした実務上の違いを示します。入力しながら検索するフィールドでは、新しいキー入力のたびに、前の語句に対するリクエストを破棄すべきです。HttpClientのObservableでは、switchMapで新しいリクエストに切り替えると古い購読が解除され、HttpClientは進行中のそのリクエストをキャンセルします。Promiseには同等の切り替えがありません。一度開始した基盤の処理は、呼び出したAPIが独自のキャンセル手段(たとえばfetch用のAbortControllerなど、Promise自体とは別のもの)を提供しない限り、実行され続けます。

実行タイミングも異なります。Promiseは、ハンドラーが登録されるかどうかに関係なく、生成時点で処理を開始します。つまり、Promiseの生成自体がすでに副作用です。一方、コールドなObservableは遅延実行です。生成しても処理を記述するだけで、subscribeを呼ぶたびに生成元が再実行されるため、2人の購読者が2回の別々の実行を引き起こすことがあります。1回の共有された実行を期待するコードは、共有オペレーターを使う、Promiseを保持するなど、その旨を明示する必要があります。

再試行と合成も同じパターンに従います。拒否されたPromiseは終了しており、回復するには生成する関数を自分のループで再度呼び出します。Observableは代わりにretryオペレーターでソースを再購読できます。また、キー入力の合間を待つdebounceTimeや、変化のない入力を無視するdistinctUntilChangedのように、Promiseの周りではタイマーとフラグを手書きしなければならないタイミング処理が、RxJSでは単一のオペレーターで済みます。

実務での選び方

結果が本当に1回きりで使い捨てできる場合、たとえば一度だけの計算、すでにPromiseを返すサードパーティ製SDK、asyncとawaitで読むのが自然でキャンセルが不要な短い直線的な処理には、Promiseを選びます。ソースが本質的にストリームである場合、たとえば合成やキャンセルをしたいHttpClientのレスポンス、ルートパラメーター、フォーム値の変化、DOMイベント、WebSocketにはObservableを選びます。遅れて購読する側、繰り返される値、終了処理が重要な場合も同様です。迷った場合は、境界ごとに変換するのではなく、生成側のAPIがすでに返す型をそのまま維持しましょう。

Promise と Observable の相互変換

2つのモデルは相互運用できますが、変換は無料ではありません。RxJSのfromはPromiseをラップし、確定した値を一度だけ通知するObservableにします。一方、firstValueFromとlastValueFromはObservableをasyncとawait向けのPromiseに戻します。HttpClientのObservableをこの方法で変換すると、単一のレスポンスは保持されますが、購読解除によるキャンセルは失われ、その地点でそれ以上のオペレーター合成も終わります。変換は、再利用可能なストリームロジックの内部ではなく、サービスの境界のような明確な地点で行いましょう。

よくある間違い

  • 1つのHTTPレスポンス内の配列を、複数回の通知だと扱うこと。リクエストが通知するのは依然として1回だけです。繰り返し通知するのは、本当に繰り返すソースだけです。
  • リクエストをPromiseに変換し、ページを離れたらキャンセルされると期待すること。解除できる購読がなければ、生成元に停止を伝えるものは何もありません。
  • asyncパイプやtakeUntilDestroyedのような終了戦略なしに、長寿命なコンポーネントで購読し、ビューが消えた後も古い購読が値を受け取り続けること。
  • 確定済みのPromiseが後からより新しい値を受け取ると期待すること。Promiseは1つの結果を保持します。現在の状態を読み直すには、新しい呼び出しまたはストリームが必要です。

Angular における Promise と Observable の例

この単独で動作するJavaScriptの例では、簡易的な学習用Observableを使って購読と複数回の通知を示します。これはAngularで使われるRxJSの実装ではありません。Observableは購読中にqueuedとsavedを通知し、その後、Promiseのコールバックがマイクロタスクで実行されます。

この単独の例を実行します。

class Observable {
  constructor(producer) {
    this.producer = producer;
  }

  subscribe(next) {
    const teardown = this.producer(next);
    return { unsubscribe: teardown };
  }
}

const promise = Promise.resolve("saved");
const updates = new Observable((next) => {
  next("queued");
  next("saved");
  return () => {};
});

console.log("before subscription");
updates.subscribe((value) => console.log(`Observable: ${value}`));
promise.then((value) => console.log(`Promise: ${value}`));
console.log("after scheduling");
before subscription
Observable: queued
Observable: saved
after scheduling
Promise: saved

学習用Observableは購読時に生成元を呼び出し、2つの値を通知します。一方、Promiseは1度だけ確定し、そのコールバックは後続のマイクロタスクで実行されます。アプリケーションコードでは、RxJSのObservableと購読APIを使ってください。結果が1つならPromiseを、ストリームの合成、繰り返しの通知、購読管理がタスクに合うならObservableを選びます。

例:即時実行される Promise と、遅延実行されキャンセル可能な Observable

この2つ目の単独の例では、同じ学習用Observableを使って、表のタイミングとキャンセルの行を示します。Promiseのexecutorは、ハンドラーが登録される前に実行されます。一方、Observableの生成元はsubscribeまで待機し、購読者が2回目のtickの後に購読解除すると、その終了処理(teardown)を実行します。Promiseのコールバックは再びマイクロタスクとして、最初のタイマーのtickより前に実行されます。変更されていない例のラベルは英語のままです。

class Observable {
  constructor(producer) {
    this.producer = producer;
  }

  subscribe(next) {
    const teardown = this.producer(next);
    return { unsubscribe: teardown };
  }
}

const promise = new Promise((resolve) => {
  console.log("Promise executor: runs at construction");
  resolve("done");
});

const ticks = new Observable((next) => {
  console.log("Observable producer: runs at subscription");
  let count = 0;
  const id = setInterval(() => next(++count), 10);
  return () => {
    clearInterval(id);
    console.log("teardown: interval cleared");
  };
});

console.log("before subscription");
const sub = ticks.subscribe((value) => {
  console.log(`tick ${value}`);
  if (value === 2) sub.unsubscribe();
});
promise.then((value) => console.log(`Promise: ${value}`));
Promise executor: runs at construction
before subscription
Observable producer: runs at subscription
Promise: done
tick 1
tick 2
teardown: interval cleared

実行結果は、3つの動作を順番に裏付けます。executorは生成時に実行され、生成元は購読まで何もせず、購読解除によってインターバルを消去する終了処理が実行されたため、それ以降のtickは届きません。