Promise は未来の値を入れる「箱」のようなもの
JavaScript でネットワーク通信やファイルの読み込み、タイマー処理など、時間がかかる処理を行うとき、結果をその場で返すことはできません。代わりに返ってくるのが Promise です。Promise は「いずれ手に入るはずの値」を表すオブジェクトだと考えてください。
最初の console.log では、Promise が pending(保留中)の状態で表示されます。0.5 秒後に Promise が解決され、.then のコールバックがその値を受け取って実行されるわけです。Promise そのものはただのオブジェクトにすぎませんが、値が届いたタイミングで「待っている側」に通知できる仕組みを持っている点がポイントです。
Promise の 3 つの状態
Promise は常に、次の 3 つのうちいずれかの状態にあります。
- pending(保留中) - 処理がまだ進行中。値はまだありません。
- fulfilled(成功) - 処理が成功し、値が得られた状態。
- rejected(失敗) - 処理が失敗し、エラーが得られた状態。
Promise は pending から fulfilled または rejected のどちらかに 一度だけ 遷移し、その後はずっとその状態のままです。一度解決された Promise を元に戻したり、二重に解決したりすることはできません。
Promise.resolve(value) はすでに fulfilled 状態の Promise を作り、Promise.reject(error) はすでに rejected 状態の Promise を作ります。テストを書くときや、関数によっては値が即座に決まるケースで Promise を返したいときに便利です。
値の取り出し方:.then と .catch の使い方
Promise の中身は直接取り出せません。代わりに .then にコールバックを渡しておくと、値の準備ができた時点で Promise 側がそのコールバックを呼んでくれます。
.catch(fn) は Promise が reject されたときに走ります。実は内部的には .then(undefined, fn) のシンタックスシュガーです。チェーンの末尾に .catch() を 1 つ置いておけば、そこまでの どのステップ で reject されてもまとめて拾えるので、.then ごとに付ける必要はありません。
Promise チェーン:.then は毎回新しい Promise を返す
ここが一番つまずきやすいポイントです。.then() は単にコールバックを実行するだけでなく、新しい Promise を返します。その Promise は、コールバックが返した値で resolve されます。だからこそチェーンがつながるわけです。
各ステップが次の処理へと値を渡していきます。.then のコールバックが Promise を返した場合、チェーンは その Promise の完了を待ってから次に進むので、非同期処理をきれいに組み立てられます。
非同期処理を3ステップ順番に、しかもネストなしで書けています。同じ処理をコールバックで書いたものと見比べれば、Promise がここまで普及した理由がすぐわかるはずです。
エラーはチェーンを伝って流れていく
reject された Promise は、.catch にたどり着くまで途中の .then をすべてスキップします。Promise のエラーハンドリングは、基本的にこれだけです。
.then の中で例外を投げると、その .then が返した Promise が reject されます。以降の .then はその reject をそのまま次へ受け流し、最終的に .catch が拾ってくれる、という流れです。基本的には、チェーンの末尾に .catch を1つ置いておけば十分です。逆に .catch が1つもないチェーンは「unhandled promise rejection」の警告が出るので、必ず対処しておきましょう。
new Promise で自作する書き方
普段は、ライブラリが返してくれる Promise を使うことがほとんどです。ただ、ごくたまに Promise を返してくれないもの--たとえば昔ながらのコールバック形式の API--を自分で Promise 化したい場面が出てきます。
new Promise に渡す関数は executor(実行関数) と呼ばれます。引数は2つで、resolve(成功時の値を渡して呼ぶ)と reject(エラーを渡して呼ぶ)です。どちらか一方をちょうど1回だけ呼んでください。2回目以降の呼び出しは無視されます。
覚えておくと後々ラクになるコツが2つあります。
new Promiseを使うのは、まだ Promise 化されていないものをラップするときだけにしましょう。すでに Promise を返す関数なら、そのままreturnすれば十分です。rejectには必ず文字列ではなくErrorオブジェクトを渡しましょう。スタックトレースは残しておく価値があります。
並列実行には Promise.all を使う
.then でつなぐ Promise チェーンは順番に実行されます。独立した非同期処理が複数あって同時に走らせたいときは、Promise.all の出番です。
3つのタイマーはすべて並行して走ります。Promise.all は、すべての Promise が fulfilled になったタイミングで、入力と同じ順序の結果の配列を返してくれます。トータルの所要時間は900msではなく、だいたい400msほどです。
ただし注意点があります。Promise.all は、いずれかの Promise が reject された瞬間に全体として reject され、他の結果は捨てられてしまいます。「3つのAPI呼び出しがすべて揃わないとページを描画できない」といった、全部の結果が必須のケースでは、これが正しい挙動です。そうでない場合は allSettled を使いましょう。
一部失敗してもOKなとき: Promise.allSettled
Promise.allSettled は、fulfilled であれ rejected であれ、すべての Promise が完了するのを待ってから、結果のレポートをまとめて返してくれます。
それぞれの結果は { status: "fulfilled", value } または { status: "rejected", reason } というオブジェクトで返ってきます。一部が失敗しても処理を続けたい場面、たとえばイベントのバッチログ、サムネイルの一括取得、独立したヘルスチェックなどで便利です。
あわせて押さえておきたいコンビネータがあと2つあります。
Promise.race([...])- 複数の Promise のうち、成功・失敗を問わず 最初 に決着したものの結果を返します。タイムアウト処理に便利です。Promise.any([...])- 最初に成功した Promise の値で fulfilled になり、reject は無視します。全部失敗したときだけ reject されます。
Promise は常に非同期で動く
たとえすでに resolve 済みの Promise であっても、.then のコールバックは必ず非同期で呼ばれます。同期的に実行されたり、同じティック内で走ったりすることはありません。
出力は 前、後、即座に の順になります。.then のコールバックは、現在実行中のコードが終わるのを待ってから、マイクロタスクキュー経由で実行されるからです。「Promise のコールバックは絶対に同期的には実行されない」--このルールがあるおかげで、Promise と同期コードを混ぜても挙動が読みやすくなります。同期コードが先に片付くことが保証されているわけですね。
次は async/await へ
.then をつないでいく書き方でも動きはしますが、ステップが3つ4つと増えてくると、どうしても階段状のネストになって見づらくなります。そこで登場するのが async/await。Promise の上に乗った構文で、非同期処理をまるで同期コードのように書けます。エラーは try/catch で拾えるし、途中の値も普通の変数に入れられる。次の章で詳しく見ていきましょう。
よくある質問
JavaScriptのPromiseとは何ですか?
Promiseは「まだ結果が出ていない値」を表すオブジェクトです。典型例はfetchなど非同期処理の将来の戻り値ですね。状態は常にpending(待機中)、fulfilled(成功)、rejected(失敗)のいずれか。最終的な値を受け取るには、.then()や.catch()でコールバックを登録します。
thenとcatchはどう違うの?
.then(onFulfilled)はPromiseが成功したときに呼ばれ、解決された値を受け取ります。一方.catch(onRejected)は、そのPromise(もしくはチェーンの上流のどこか)がrejectされたときに呼ばれ、エラーを受け取ります。チェーンの末尾に.catch()をひとつ置いておけば、上流のどの段階の失敗もまとめて拾えます。
Promise.allは何をしてくれる?
Promise.all([p1, p2, p3])はPromiseの配列を受け取り、全部がfulfilledになった時点で、解決値の配列としてまとめて返してくれます。ただしひとつでもrejectされると、その瞬間に全体が失敗扱いに。失敗したものがあっても結果を全部受け取りたいときはPromise.allSettledを使いましょう。
Promiseとasync/awaitはどちらを使うべき?
実は中身は同じで、async/awaitはPromiseの上に乗った糖衣構文です。新しく書くコードはasync/awaitのほうが読みやすいことが多いですが、戻り値はやはりPromiseですし、エラー処理はtry/catchや.catch()、並列実行にはPromise.allという具合に結局Promiseを触ります。Promiseの仕組みを理解しておくとasync/awaitもすんなり腑に落ちますよ。