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は届きません。