Promiseによる非同期処理
2024/02/03更新
目次
概要
従来よりJavaScriptのコードの複雑化の一因であった非同期処理について、ES2015からPromise
オブジェクトが登場し、より簡単に分かりやすく記述できるようになった。さらに、ES2017からは、async/await構文が導入され、Promise
による記述をさらに簡略化できるようになった。
Promiseオブジェクト
Promiseの基本
Promiseは、従来コールバック関数を多用するなどして複雑化していた非同期処理の記述を簡単にするためのオブジェクトである。
Promiseオブジェクトは、通常非同期関数によって返される。Promiseオブジェクトは、待機(pending)・履行(fulfilled)・拒否(rejected)の3通りの状態を持っており、非同期処理が完了すると待機から履行または拒否へ状態が変化するしくみとなっている。履行または拒否のいずれかになった状態(待機ではない状態)を完了(settled)といい、一度完了したら待機へ戻ることはない。履行・拒否・完了は、日本語ではそれぞれ「成功」「失敗」「決定」と言った方が分かりやすいかもしれない。
このPromiseオブジェクトに後述のthen()
などを使って関数をあらかじめ登録しておけば、Promiseの状態が変化した時点でその関数が実行されるようになる。
// Promiseを返す非同期関数の作成 const myAsyncFunc = function(url) { // returnするのはPromiseオブジェクト。 return new Promise(function(resolve, reject) { // ここに目的の非同期処理を記述する。 const img = new Image(); img.src = url; // 非同期処理が成功したときは、resolve()を呼んでPromiseを「履行」する。 // resolve()の引数として渡したものがthen()に渡る。 img.onload = function() { resolve(`${url} success`); }; // 非同期処理が失敗したときは、reject()を呼んでPromiseを「拒否」する。 // reject()の引数として渡したものはthen()やcatch()などに渡る。 img.onerror = function() { reject(`${url} failed`); }; }); };
上記のようにして定義した非同期関数は、次のようにして使うことができる。
// チェーン(連鎖)表記 myAsyncFunc(~).then(thenFunc1).then(thenFunc2); // 分かりやすく分解して書いた場合 const promise1 = myAsyncFunc(~); const thenPromise1 = promise1.then(thenFunc1); const thenPromise2 = thenPromise1.then(thenFunc2);
myAsyncFunc
は、Promiseオブジェクト(promise1
)を返し、非同期処理を実行する。promise1
が履行されると、then()
であらかじめ設定したthenFunc1
が実行される。thenFunc1
の引数には、promise1
が内部でresolve()
に渡したオブジェクトや値が渡される。then()
はPromiseを返すため、promise1.then(~)
もまたPromiseオブジェクト(thenPromise1
)である。thenPromise1
は、thenFunc1
がPromiseを返したときは、そのPromiseの履行(または拒否)をもって履行(または拒否)される。なお、Promise以外のオブジェクトや値が返されたときは、そのオブジェクトや値をもって即座に履行される。thenPromise1
が履行されると、さらにthenFunc2
が実行される。thenFunc2
の引数には、thenPromise1
が履行時に渡したオブジェクトや値が渡される。このようにして、
then()
の連鎖による非同期関数の直列実行が簡単にできる。
具体的なコードで見てみると、以下のようになる。
// myAsyncFunc('aaa.jpg')を実行 const promise1 = myAsyncFunc('aaa.jpg'); // promise1が履行したときの処理 const thenPromise1 = promise1.then(function(msg) { // myAsyncFunc('aaa.jpg')が完了(履行)したときにここが実行される。 console.log(msg); // aaa.jpg success // myAsyncFunc('bbb.jpg')を実行 const promise2 = myAsyncFunc('bbb.jpg'); // thenの中でPromiseを返したので、promise2の結果がthenPromise1の結果となる。 return promise2; }); // thenPromise1が履行したときの処理 const thenPromise2 = thenPromise1.then(function(msg) { // myAsyncFunc('bbb.jpg')が完了(履行)したときにここが実行される。 console.log(msg); // bbb.jpg success // thenの中でPromiseを返さないと、thenPromise2は即座に履行される。 return 'Done.'; });
then()
の中で、再びpromise.then(~)
のチェーンをネストして記述するのは、あまり良くないとされており、実際意図しないコードになる可能性もあるため、注意が必要である。then()
の中で再び非同期処理が行なわれてPromiseオブジェクトが登場した場合(上記例のpromise2
)は、すぐにそれをreturn
して、次のthen()
に回した方が分かりやすくなる。
Promiseの完了と解決の違い
Promiseの説明の中では、ここまでに出てきた履行や完了の他に「解決(resolved)」という用語が出てくることがある。解決は、Promiseコンストラクタに渡した関数が、引数として渡される解決用関数を呼び出した状態のことを指す。一般には、「解決」によって履行または拒否が定まるので、それはつまり「完了」と同じ状態であり、さらに文脈的に明らかな場合は特に「履行」状態になったことを指すことが多い。
しかし、解決用関数に別のPromiseが渡された場合、元のPromiseが履行と拒否のどちらになるかは、渡されたPromise次第となり、「解決」はされても「完了」はしていない状態になる。then()
は「解決」ではなく「完了」のタイミングで実行されるので、解決用関数の呼び出しと同時に必ずthen()
が呼ばれるとは限らない。
以下は、「解決」と「完了(履行)」が時間差で訪れるコードの例である。
const firstPromise = new Promise(function(resolveFirst) { const anotherPromise = new Promise(function(resolveAnother) { // anotherPromiseは1秒後に「解決」して「完了」状態になる。 setTimeout(resolveAnother, 1000); }); resolveFirst(anotherPromise); console.log('Resolved.'); // resolveFirst()が呼び出されたので、この時点でfirstPromiseは「解決」済みとなる。 // しかし、firstPromiseはまだ履行または拒否が定まっておらず「完了」状態ではない。 }); firstPromise.then(function() { // then()は、「解決」のタイミングではなく、「完了」のタイミングで呼ばれる。 console.log('Settled.'); });
参考:Promise() constructor - JavaScript | MDN
「Return value」の節に「The promise object will become "resolved" when either of the functions resolveFunc or rejectFunc are invoked.」「Note that if you call resolveFunc or rejectFunc and pass another Promise object as an argument, it can be said to be "resolved", but still not "settled".」との説明がある。
Promiseによる並列実行
then()
では直列実行が記述できたが、並列実行をしたい場合はPromise.all()
を使う。Promise.all()
自体もまたPromiseである。Promise.all()
にPromiseの配列を渡すと、渡した全てのPromiseが履行したときに履行となり、then()
には各Promiseが履行したオブジェクトや値の配列が渡される。
// myAsyncFunc1、myAsyncFunc2、myAsyncFunc3は全てPromiseを返す非同期関数 const procs = [myAsyncFunc1(), myAsyncFunc2(), myAsyncFunc3()]; // Promise.all()にPromiseの配列を渡すと… Promise.all(procs).then(function(arr) { // …全てのPromiseが履行たときにthenの中身が実行される。 console.log(arr[0]); // myAsyncFunc1が履行した値 console.log(arr[1]); // myAsyncFunc2が履行した値 console.log(arr[2]); // myAsyncFunc3が履行した値 });
Promise.all()
と似たものにPromise.race()
がある。こちらは複数のPromiseの中でいずれか一つが最初に完了(履行または拒否)したときに完了(履行または拒否)するPromiseを返す。Promise.all()
はAND条件、Promise.race()
はOR条件で非同期処理を並列実行するものと考えることができる。
最初から解決済みのPromiseとしてPromise.resolve(obj)
というものが使える。定数値をPromise
オブジェクトでラップしたい場合などに使える。なお、拒否済みのPromiseはPromise.reject(obj)
である。Promise.all()
やPromise.race()
に渡す配列にPromise以外が含まれていた場合は、自動的に履行済みのPromiseとしてラップされる。
Promiseのエラー処理
then()
の引数としてもう一つ関数を渡すと、Promiseが拒否されたときに実行されるエラー処理を指定できる。
Promise.reject('Not found').then( // 履行時に実行する処理 function(obj) { console.log('OK'); }, // 拒否時に実行する処理 function(err) { console.log(`Error: ${err}`); } ); // Error: Not found
エラー処理はcatch()
を使って指定することもできる。catch(func)
は、then(null, func)
と同等である。Promiseの拒否は、then()
やcatch()
で処理されない限り、伝搬していく。以下の例では、2番目の処理で拒否されているため、次のthen()
で指定された3番目の処理は行われず、catch()
で指定されたエラー処理までスキップされている。ただし、catch()
内で再び履行されているため、最後の4番目の処理は実行されている。
// 1番目の処理 Promise.resolve(1).then(function(obj) { // 2番目の処理 console.log(`OK: ${obj}`); return Promise.reject(2); // 拒否! }).then(function(obj) { // 3番目の処理 console.log(`OK: ${obj}`); return Promise.resolve(3); }).catch(function(err) { // エラー処理 console.log(`Error: ${err}`); return Promise.resolve(4); }).then(function(obj) { // 4番目の処理 console.log(`OK: ${obj}`); }); // OK: 1 // Error: 2 // OK: 4
Promiseでは、例外も適切にエラー処理を行なうことができる。例えば、new Promise(~)
内で、例外が発生したりthrow obj
されたりすると、そのPromiseをreject(obj)
で拒否するのと同じことになる。また、then(~)
内で例外が発生したりthrow obj
されたりした場合も、そのthen()
が拒否という扱いになって、後続のcatch()
などがハンドリングすることができる。逆に、promise.then(~)
などを従来のtry-catch文で囲っても、このような例外をハンドリングすることはできないので注意が必要である。
//try { myAsyncFunc().then(function(obj) { // then内で発生する例外 throw 'Promise error!'; }).catch(function(err) { // Promiseの正しい例外処理 console.log(err); // Promise error! }); //} catch (e) { // // then内で発生する例外に対して、従来のtry-catch文でこのように外側を囲っても無意味である。 // console.log(e); //}
async/await
async宣言とawait実行
async
とawait
は、Promise
による非同期関数の記述をさらに簡潔にできる構文である。
基本的なルールは以下の通りである。Promise
を理解していれば、async/awaitを使うことはたやすい。
Promise
を返す非同期関数を実行するときに、await
キーワードを付けると、その非同期関数が終わるまで、その行で処理が一時停止するようになり、コード上はあたかも同期関数のように記述できるようになる。await
をつけて実行した非同期関数の戻り値はPromise
オブジェクトではなく、その関数がresolve
した値となる。また、その関数がreject
した場合は、例外が投げられるため、従来のtry-catch文が使える。await
実行を含む関数は、必ずasync
キーワードを付けて宣言しなければならない。async
キーワードを付けた関数は必ずPromise
を返すように自動的にラップされる。関数内でreturn
するとPromise
をresolve
したことになり、関数内でthrow
するとPromise
をreject
したことになる。
// Promiseを返す非同期関数 const myAsyncFunc = function(x) { return new Promise(function(resolve, reject) { if (x) { resolve('hoge'); } else { reject('fuga'); } }); }; // 従来のthen/catchによる書き方 const myFunc = function(x) { const promise = myAsyncFunc(x); promise.then(function(msg) { console.log(msg); }).catch(function(msg) { console.log(msg); }); }; myFunc(true); // hoge myFunc(false); // fuga // async/awaitによる書き方 const myFunc = async function(x) { let msg; try { msg = await myAsyncFunc(x); } catch (e) { msg = e; } console.log(msg); }; myFunc(true); // hoge myFunc(false); // fuga
別の非同期関数の結果を返す関数を作りたい場合、await
せずにPromise
を返すか、await
してから値を返すかで迷うことがある。
// どちらがいいのか? // (1) awaitして値を返しPromiseでラップする。 const myFunc = async function() { return await anotherAsyncFunc(); } // (2) awaitせずにPromiseをそのまま返す。 const myFunc = function() { return anotherAsyncFunc(); }
上記の場合、anotherAsyncFunc
がPromise
を返す非同期関数とすると、myFunc
はいずれもPromise
を返すことになるため、同じような挙動になると思われる。
ただ、anotherAsyncFunc()
がreject
された場合の動作が大きく異なる。(1)の場合は、return
する前に例外が発生してしまう。つまり、ここはtry-catch文で囲んで、anotherAsyncFunc()
の結果に対してmyFunc
が責任を持つ必要がある。一方、anotherAsyncFunc()
がどんな結果になろうとも、それをそのままmyFunc
の結果としたい場合は、(2)のようにPromise
をそのまま返せばよい。