Promise
は非同期処理の最終的な完了もしくは失敗を表すオブジェクトです。多くの人々は既存の用意された Promise を使うことになるため、このガイドでは、Promise の作成方法の前に、関数が返す Promise の使い方から説明します。
本質的に、Promise はコールバックを関数に渡すかわりに、関数が返したオブジェクトに対してコールバックを登録するようにする、というものです。
例えば createAudioFileAsync()
という非同期に音声ファイルを生成する関数を考えましょう。この関数はコンフィグオブジェクトと 2 つのコールバック関数を受け取り、片方のコールバックは音声ファイルが無事作成されたときに呼び出され、もう一つはエラーが発生したときに呼び出されます。
以下のコードは createAudioFileAsync()
を使用したものです。
function successCallback(result) {
console.log("Audio file ready at URL: " + result);
}
function failureCallback(error) {
console.log("Error generating audio file: " + error);
}
createAudioFileAsync(audioSettings, successCallback, failureCallback);
最近では関数は Promise を返し、代わりにその Promise にコールバックを登録することができます。
もし createAudioFileAsync()
が Promise を返すように書き換えられたとすれば、以下のようにシンプルに使用することができます。
createAudioFileAsync(audioSettings).then(successCallback, failureCallback);
これは以下のコードの短縮形です。
const promise = createAudioFileAsync(audioSettings);
promise.then(successCallback, failureCallback);
これを非同期関数呼び出し(asynchronnous function call)と呼びます。この記述方法にはいくつか利点があるので、順に説明します。
保証
旧来のコールバック渡しとは異なり、Promise では以下が保証されています。
- 現在の JavaScript イベントループの実行完了より前には、コールバックが決して呼び出されない。
- 非同期処理が完了もしくは失敗した後に
then()
により登録されたコールバックでも、上記のように呼び出される。 then()
を何回も呼び出して複数のコールバックを追加してもよく、それぞれのコールバックは追加順に独立して実行される。
とはいえ、最もすぐわかる Promise の利点は Promise チェーンでしょう。
Promise チェーン
一般的なニーズとしては、複数の非同期処理を順番に実行し、前の処理が完了してからその結果を次の処理で使うというものがあります。これは Promise チェーンを作成することで行えます。
さあ魔法の時間です。then()
関数は元の Promise とは別の新しい Promise を返します。
const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);
もしくは、以下のように書いても構いません。
const promise2 = doSomething().then(successCallback, failureCallback);
2 つ目の Promise は doSomething()
の完了を表すだけではなく、渡した successCallback
もしくは failureCallback
の完了も表し、これらのコールバックは Promise を返すまた別の非同期関数であっても構いません。その場合、promise2
に追加されたコールバックはいずれも Promise のキューにおいて、successCallback
または failureCallback
が返す Promise の後ろに追加されます。
基本的に、それぞれの Promise はチェーン(連鎖)上の各非同期処理の完了を表します。
昔は、複数の非同期処理を順番に実行するには、従来のコールバック地獄を作ることになりました。
doSomething(function(result) {
doSomethingElse(result, function(newResult) {
doThirdThing(newResult, function(finalResult) {
console.log('Got the final result: ' + finalResult);
}, failureCallback);
}, failureCallback);
}, failureCallback);
モダンな関数を使えば、その代わりに戻り値の Promise にコールバックを付加して Promise チェーンとして記述できます。
doSomething()
.then(function(result) {
return doSomethingElse(result);
})
.then(function(newResult) {
return doThirdThing(newResult);
})
.then(function(finalResult) {
console.log('Got the final result: ' + finalResult);
})
.catch(failureCallback);
then
関数の引数はオプション(必須ではない)です。また、catch(failureCallback)
は then(null, failureCallback)
の短縮形です。記述にはアロー関数を使っても構いません。
doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);
重要: コールバック関数から処理結果を返すのを忘れないでください。さもないと後続のコールバック関数からその処理結果を利用することができなくなります (アロー関数を使った () => x
は () => { return x; }
の短縮形です)。
catch後のチェーン
失敗、つまり catch
の後にチェーンするのも可能で、これはチェーン内の動作が失敗した後でも新しい動作を行うのに便利です。次の例を読んでください:
new Promise((resolve, reject) => {
console.log('Initial');
resolve();
})
.then(() => {
throw new Error('Something failed');
console.log('Do this');
})
.catch(() => {
console.log('Do that');
})
.then(() => {
console.log('Do this whatever happened before');
});
これは下記のテキストを出力します:
Initial Do that Do this whatever happened before
注意:Do this
のテキストは Something failed
エラーが reject を引き起こしたため出力されないことに注意してください。
エラーの伝播
以前のコールバック地獄形式の記述方法では failureCallback
を 3 回書く必要がありましたが、Promise チェーンでは failureCallback
は 1 回で済みます。
doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => console.log(`Got the final result: ${finalResult}`))
.catch(failureCallback);
例外が発生すると、ブラウザーはチェーンをたどって .catch()
ハンドラーか onRejected
を探します。この振る舞いは同期的なコードの動作と非常によく類似しています。
try {
const result = syncDoSomething();
const newResult = syncDoSomethingElse(result);
const finalResult = syncDoThirdThing(newResult);
console.log(`Got the final result: ${finalResult}`);
} catch(error) {
failureCallback(error);
}
ECMAScript 2017 のシンタックスシュガー async
/await
を使えば、完全にそっくりのコードになります。
async function foo() {
try {
const result = await doSomething();
const newResult = await doSomethingElse(result);
const finalResult = await doThirdThing(newResult);
console.log(`Got the final result: ${finalResult}`);
} catch(error) {
failureCallback(error);
}
}
async/await は Promise の上に成り立っています。例えば上記の doSomething()
はこれまでと同じ(Promise を返す)関数です。この書き方の詳細についてはこちらをご覧ください。
Promise は例外やプログラミングエラーを含むすべてのエラーをとらえることで、コールバック地獄の根本的な問題を解決します。これは非同期処理を合成するのに不可欠です。
Promise の失敗イベント
Promise が失敗するたびに、グローバルスコープ(通常 window
オブジェクトか、Web Worker 内ならば Worker
か Worker ベースのインターフェイスをもつオブジェクト)に以下の 2 つのイベントのどちらかが送られます:
rejectionhandled
- Promise が失敗したとき、それが
reject
関数などによって処理されたあとに送られる。 unhandledrejection
- Promise が失敗して、ハンドラーが存在しないときに送られる。
いずれの場合でも、イベントオブジェクト( PromiseRejectionEvent
型)は失敗した Promise を表す promise
プロパティと、その Promise が失敗した理由を表す reason
プロパティを持ちます。
これらのイベントを使えば、Promise のエラーハンドラーのフォールバックを指定することができ、また Promise を管理する際の問題をデバッグするのにも役立ちます。これらのイベントのハンドラーはコンテキストごとにグローバルであり、どこから発生したかに関わらず、すべてのエラーは同じイベントハンドラーによって処理されます。
特に便利なケースとして、Node.js 用のコードを書いているときにプロジェクト内のモジュールで Promise が失敗しハンドルされないことがよくあります。これらは Node.js の実行環境によりコンソールに出力されます。これらの失敗を分析したりハンドラーを設定したいとき、あるいは単にコンソールがこれらで埋め尽くされないようにしたいとき、以下のように unhandledrejection
イベントのハンドラーを追加することができます。
window.addEventListener("unhandledrejection", event => {
/* ここで該当の Promise を event.promise で、失敗の理由を
event.reason で取得して調べることができます */
event.preventDefault();
}, false);
イベントの preventDefault()
メソッドを呼び出すことによって、失敗した Promise がハンドルされないときの JavaScript の実行環境のデフォルトの動作を防ぐことができます。特に Node.js がそうですが、通常はデフォルトの動作ではエラーがコンソールに出力されます。
当然ながら理想的には、これらのイベントを捨てる前に失敗した Promise を調べて、いずれもコードのバグによるものではないことを確かめるべきです。
古いコールバック API をラップする Promise の作成
Promise
はコンストラクタを使って 1 から作ることもできます。これは古い API をラップする場合にのみ必要となるはずです。
理想的には、すべての非同期関数は Promise を返すはずですが、残念ながら API の中にはいまだに古いやり方で成功/失敗用のコールバックを渡しているものがあります。典型的な例としては setTimeout()
関数があります。
setTimeout(() => saySomething("10 seconds passed"), 10*1000);
古い形式のコールバックと Promise の混在は問題を引き起こします。というのは、saySomething()
が失敗したりプログラミングエラーを含んでいた場合にそのエラーをとらえられないからです。setTimeout
にその責任があります。
幸いにも setTimeout
を Promise の中にラップすることができます。ベストプラクティスは、問題のある関数を可能な限り低いレベルでラップした上で、二度と直接呼ばないようにするというものです。
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
wait(10*1000).then(() => saySomething("10 seconds")).catch(failureCallback);
基本的に、Promise のコンストラクタには、手動で Promise を resolve もしくは reject できるようにする実行関数を渡します。setTimeout()
は失敗することはないので、reject は省略しました。
合成 (Composition)
Promise.resolve()
と Promise.reject()
はそれぞれ既に resolve もしくは reject された Promise を手動で作成するショートカットで、たまに役立つことがあります。
Promise.all()
と Promise.race()
は同時並行で実行中の非同期処理を合成するためのツールです。
以下のように複数の処理を並行に開始し、すべてが終了するのを待つことができます。
Promise.all([func1(), func2(), func3()])
.then(([result1, result2, result3]) => { /* result1, result2, result3 が使える */ });
以下のように工夫すれば、逐次実行をする直列的な合成も記述することができます。
[func1, func2].reduce((p, f) => p.then(f), Promise.resolve())
.then(result3 => { /* result3 が使える */ });
基本的に、これは非同期関数の配列を Promise.resolve().then(func1).then(func2).then(func3);
と同等の Promise チェーンへと reduce しています。
このような処理は以下のように、関数型プログラミングでよくある再利用可能な合成関数にすることがすることができます。
const applyAsync = (acc,val) => acc.then(val);
const composeAsync = (...funcs) => x => funcs.reduce(applyAsync, Promise.resolve(x));
composeAsync
関数は任意の個数の関数を引数として受け取って、1本のパイプラインとして合成された関数を返します。この関数に渡された初期値は合成された関数を通過していきます。
const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);
ECMAScript 2017 では直列的な合成は async/await でもっと単純に書くことができます。
let result;
for (const f of [func1, func2, func3]) {
result = await f(result);
}
/* 最終的な結果(result3)が使える */
タイミング
想定外の事態とならないよう、たとえすでに resolve された Promise であっても、then()
に渡される関数が同期的に呼ばれることはありません。
Promise.resolve().then(() => console.log(2));
console.log(1); // 1, 2
渡された関数は、すぐに実行されるのではなくマイクロタスクのキューに入れられます。現在のイベントループの終わりにこのキューは空になったときに、この関数が実行されます(つまりかなり早い段階です)。
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
wait().then(() => console.log(4));
Promise.resolve().then(() => console.log(2)).then(() => console.log(3));
console.log(1); // 1, 2, 3, 4
ネスト
単純な Promise チェーンならば、ネストは不用意な合成の結果生まれるものなので、ネストせずに平らにしておくのがベストです。よくある間違いを参照してください。
ネストとは catch
ステートメントのスコープを制限するための制御構造です。正確には、ネストされた catch
はそのスコープ内の失敗しかキャッチせず、Promise チェーン上でスコープ外のエラーには反応しません。正しく使えばより正確にエラーからの回復ができるようになります。
doSomethingCritical()
.then(result => doSomethingOptional(result)
.then(optionalResult => doSomethingExtraNice(optionalResult))
.catch(e => {})) // オプションの処理が失敗すれば無視して進める
.then(() => moreCriticalStuff())
.catch(e => console.log("Critical failure: " + e.message));
インデントではなく外側の括弧 ()
によってオプションの処理がネストされていることに注意してください。
内側の catch
ステートメントは doSomethingOptional()
と doSomethingExtraNice()
からの失敗だけをキャッチし、キャッチしたあと moreCriticalStuff()
へと処理が続きます。重要なのは、もし doSomethingCritical()
が失敗したらそのエラーは最後の catch
によってだけキャッチされるということです。
よくある間違い
Promise チェーンを合成するときは以下のようなよくある間違いに気をつける必要があります。以下の例にいくつかの間違いが含まれています。
// 悪い例。間違いを 3 つ見つけてください。
doSomething().then(function(result) {
doSomethingElse(result) // 内側のチェーンから Promise を返していない + 不必要なネスト
.then(newResult => doThirdThing(newResult));
}).then(() => doFourthThing());
// チェーンの最後を catch で終わらせていない
最初の間違いは適切にチェーンを構成できていないことです。これは、新しい Promise を作成したがそれを返すのを忘れているときに起きます。結果としてチェーンは壊れ、2 つのチェーンが独立に実行されることになります。これはつまり doFourthThing()
は doSomethingElse()
や doThirdThing()
の終了を待たないことになり、おそらく意図せず並行して実行されることになります。別々のチェーンでは別々のエラーハンドリングが行われるため、キャッチされないエラーが発生することになります。
2 つ目の間違いは不必要にネストしていることであり、1 つめの間違いを可能にしているものでもあります。ネストするということは内側のエラーハンドラーが制限されるということであり、もしこれが意図していないものであれば、エラーがキャッチされないということが起こりえます。これの変化形で Promise コンストラクターアンチパターンというものがあり、ネストに加えて、Promise を既に使用しているコードを不必要な Promise コンストラクターでラップするというものです。
3 つ目の間違いはチェーンを catch
で終わらせていないことです。ほとんどのブラウザーではそのようなチェーンは Promise の失敗がキャッチされないことになります。
よい経験則としては、Promise チェーンは常に return
するか catch
で終わらせ、新しい Promise を得るたびにすぐに return
してチェーンを平らにすることです。
doSomething()
.then(function(result) {
return doSomethingElse(result);
})
.then(newResult => doThirdThing(newResult))
.then(() => doFourthThing())
.catch(error => console.log(error));
() => x
は () => { return x; }
の短縮形であることに注意してください。
これで適切なエラーハンドリングがされた 1本のチェーンができました。
async
/await
を使えば、すべてではないにしてもほとんどの問題は解決しますが、このシンタックスで最もよくある間違いが await
キーワードを忘れることであるという欠点があります。
Promises とタスクが衝突するとき
(イベントとコールバックのような) Promise とタスクが予知できない順序で発火するような状況に陥る場合、Promise が条件付きで作成されて Promise の状態をチェックしたり帳尻合わせしたりするマイクロタスクを利用できることがあります。
マイクロタスクでこの問題を解決できると考えたなら、microtask guide を見て、関数をマイクロタスクでキューに入れる queueMicrotask()
の使い方を学んでください。