【JavaScript】Promiseを使った非同期処理
Promiseについてきちんと理解できていなかったので初歩から学ぶ。
- Promiseとは
- 使用できる環境
- 基本の構文
- Promise.prototype.then()
- Promiseの状態変化
- Promise.prototype.catch()
- Promise.resolve()
- Promise.reject()
- Promiseをつなげて使う
- Promise.all()
- Promise.race()
- まとめ
- 参考
Promiseとは
非同期処理を抽象的に扱えるオブジェクト。
ES2015から導入された。
これまで非同期処理を制御するためにはコールバック関数を使う方法が多く、非同期処理が続くとコールバック関数がどんどんとネスト化されていわゆる「コールバック地獄」におちいる課題があった。
しかしこのpromise
を使うことで並列的に記述することができコードの可読性がぐっと上がり、保守性や拡張性が増す。
特にNode.jsでは頻繁に非同期処理が行われるのでその恩恵は大きい。
また、Ajax処理を簡潔に行える Fetch
や ES2017のasync/await
もPromiseオブジェクトを扱っている模様。
使用できる環境
caniuse.comを確認すると、
となっており、ブラウザ上ではこのままでは使えそうにないため、es6-promise などのPolyfillを使うのが現実的。
基本の構文
new Promise((resolve, reject) => { /* * 非同期処理の記述 */ // 非同期処理が成功した時に呼び出す resolve(value); // 非同期処理にエラーが発生した場合に呼び出す reject(reason); });
new
演算子でコンストラクタ関数を実行することでPromiseオブジェクト
を作成できる。
コンストラクタ関数の引数には、resolve
と reject
の2つの引数をもった関数を渡し、その関数内で非同期処理を記述する。
非同期処理が成功したときには前者のresolve
を、エラーが発生した場合にはreject
を呼び出すことで、この関数での処理は終わる。
Promise.prototype.then()
先ほどの処理ではresolve
またはreject
を呼び出すところまで行った。
この後の処理を行うのが、Promiseオブジェクトのインスタンスメソッドであるthen
メソッド。
then
メソッドの第一引数に関数を渡すと、resolve
、つまり非同期成功時の処理を定義することができる。
そして第二引数にはreject
、つまりエラー時の処理を定義することができる。
▼簡単なサンプル
// resolve(成功)の場合 new Promise((resolve, reject) => { setTimeout(() => { resolve('Hello'); }, 1000); }).then(value => console.log('OK: ' + value)); // 1秒後に 「OK: Hello」 // reject(失敗)の場合 new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('何らかのエラー')); }, 1000); }).then( value => console.log('OK: ' + value), reason => console.log('NG: ' + reason) ); // 1秒後に 「NG: Error: 何らかのエラー」
resolve('Hello)
やreject(new Error('何らかのエラー'))
のように、引数を与えることでthen
メソッド内で値を引き継ぐことができる。- rejectの場合は、エラー特定のために
Error
オブジェクトを渡すことが多い。
- rejectの場合は、エラー特定のために
しかし、このthen
メソッドの第二引数でresolve
時の処理を記述するのはあまりお勧めできず、後述のcatch
メソッドを使う方が、可読性や機能性の面から推奨。詳しくは後述。
Promiseの状態変化
new Promise
でインスタンス化したPromiseオブジェクトには3つの状態がある。
- Pending : promiseオブジェクトが作成された初期状態等
- Fulfilled :
resolve
(成功)したとき - Rejected :
reject
(失敗)したとき
この状態を確認してみる。
// pending -> resolvedの例 const p = new Promise((resolve, reject) => { setTimeout(() => { resolve(); }, 3000); }); console.log(p); // Promise {<pending>} [[PromiseStatus]] : "pending" // 3秒後の実行 console.log(p); // Promise {<resolved>} [[PromiseStatus]] : "resolved"
// pending -> rejectedの例 const p = new Promise((resolve, reject) => { setTimeout(() => { resolve(); }, 3000); }); console.log(p); // Promise {<pending>} [[PromiseStatus]] : "pending" // 3秒後の実行 console.log(p); // Promise {<rejected>} [[PromiseStatus]] : "rejected"
このようにPromiseオブジェクトの状態がpending
から変化したときに、その状態に応じて成功時または失敗時の処理が実行される。
※なお、この状態([[PromiseStatus]]
)を触るAPIは存在しないため基本的にはそこまで気にしなくてもよい。
もう1点、Promiseの状態について重要なことは、then
メソッドで登録した関数が呼ばれるのは1回限りで、Fulfilled
とRejected
のどちらかの状態になったら不変であること。
こちらも確認してみる。
const p = new Promise((resolve, reject) => { setTimeout(() => { resolve('Hello'); resolve('How are you?'); // ① console.log('hogehoge'); }, 3000); setTimeout(() => { reject(new Error('何らかのエラー')); }, 4000); }).then( value => 'OK: ' + value, reason => 'NG: ' + reason ); console.log(p); setTimeout(() => console.log(p), 3000); setTimeout(() => console.log(p), 4000); /* 実行結果 Promise {<pending>} (3秒後) hogehoge Promise {<resolved>: "OK: Hello"} (4秒後) Promise {<resolved>: "OK: Hello"} */
①の箇所ではresolve
を2回呼び出しているが、thenメソッドが実行されたのは1回だけ。
しかし、直後のconsole.log('hogehoge');
が実行されているので、エラーになったり、処理が止まっているわけではない。
再度言うと、Promiseオブジェクトの状態が変化したときに、一度だけ呼ばれる関数を登録するのがthen
などのメソッドとなる。
Promise.prototype.catch()
これまでの例では、非同期処理中にエラーが起きたとき(reject
が呼び出されたとき)はthen
メソッドの第二引数にエラー時の処理を記述していた。
※エラー時の処理を書きたいだけの場合は下記のように、第一引数にundefined
やnull
を指定する。
new Promise((resolve, reject) => { setTimeout(() => reject(new Error('何らかのエラー')), 1000); }).then(null, reason => console.log(reason)); // 1秒後に表示: Error: 何らかのエラー
このPromise.then(undefined, onRejected)
のエイリアスとなるのが、catch
メソッド。
catch
メソッドのメリット
1. コードの見通しがよくなる
上記のサンプルを書き換えるとこうなる。
new Promise((resolve, reject) => { setTimeout(() => reject(new Error('何らかのエラー')), 1000); }).catch(reason => console.log(reason));
これだけだと、あまりメリットを感じないが、then
メソッドと組み合わせると、成功時とエラー時の流れが分かりやすくなる。
new Promise((resolve, reject) => { setTimeout(() => { try { /* * 何らかの非同期処理 */ resolve('Hello') } catch(e) { reject(e); } }, 1000); }) .then(value => { // 成功時 console.log(value); }) .catch(reason => { // エラー時 console.log(reason); });
then
メソッド内に2つの処理を入れるよりもだいぶ見やすくなっているのがわかる。
2. onResolved
時にエラーが起きた場合でも拾うことができる
下記はthen
メソッドの第二引数にonRejected
を定義したサンプル。
new Promise((resolve, reject) => { // 略 }) .then(value => { throw new Error('何らかのエラー'); console.log(value); },reason => { console.log(reason); }); // Uncaught (in promise) Error: 何らかのエラー
第一引数のonResolved
内でエラーを投げているが、コンソール表示の Uncaught (in promise) Error: 何らかのエラー
のようにエラーをキャッチできていない。
では、このcatchを使わずにエラーを捕まえるにはどうしたらよいか。
詳しくは後述するが、then
メソッドをつなげばOK。
new Promise((resolve, reject) => { // 略 }) .then(value => { throw new Error('何らかのエラー'); console.log(value); },reason => { console.log(reason); }) .then(null, reason => { console.log(reason); // Error: 何らかのエラー });
ただ、見ての通り直感的には分かりにくいコードになってきている。
さらに見てみると、最初のコンストラクタ関数実行時にエラーが発生した場合はどうなるか。
new Promise((resolve, reject) => { setTimeout(() => { try { throw new Error('最初のエラー'); // ① resolve('Hello'); } catch(e) { reject(e); } }, 1000); }) .then(value => { throw new Error('2番目のエラー'); // ② console.log(value); },reason => { console.log('1: ' + reason); // A }) .then(null, reason => { console.log('2: ' + reason); // B });
①が発生した場合、「A」が実行されて、1: Error: 最初のエラー
と出力される。
②が発生した場合、「B」が実行されて、2: Error: 2番目のエラー
と出力される。
ますます複雑化してきてしまっている。
ではcatch
メソッドの場合はどうなるか。
new Promise((resolve, reject) => { setTimeout(() => { try { throw new Error('最初のエラー'); // ① resolve('Hello'); } catch(e) { reject(e); } }, 1000); }) .then(value => { throw new Error('2番目のエラー'); // ② console.log(value); }) .catch(reason => { console.log(reason); // A });
①が発生した場合、「A」が実行されて、1: Error: 最初のエラー
と出力される。
②が発生した場合、「A」が実行されて、2: Error: 2番目のエラー
と出力される。
つまり、catch
が①も②も両方のエラーを拾ってくれるのが分かる。
このようなことから、エラー処理の記述が1箇所で済むため、基本的には onRejected
はcatch
メソッドで記述するのが良い。
インデント
少し話がそれるが、上記のサンプルでは、then
メソッドとcatch
メソッドをnew Promise()
から1レベルインデントを下げている。
これはnew Promise()
つまり非同期処理が完了してから実行されるメソッドなので、その手続きを視覚的にわかりやすくするための工夫である。
※オープンソースのコードを見るとよく見かける。
Promise.resolve()
Promiseオブジェクトの静的メソッドであるresolve()
メソッドを使うと、new
でインスタンス化せずとも、Promiseオブジェクトを作成できる。
Promise.resolve()
は下記コードのシンタックスシュガーである。
new Promise(resolve => { resolve(); });
また、与えられた引数によって、返されるPromiseオブジェクトの状態が異なる。
Promiseオブジェクトが渡された場合
受け取ったPromiseオブジェクトをそのまま返す。
const p = new Promise((resolve, reject) => { resolve('Hello'); }); console.log(Promise.resolve(p) === p); // true
これだけだと「だから?」という感じだが、この特性があることで後述するメソッドチェーンが実行できるメリットがある。
Thenableなオブジェクトが渡された場合
Thenableとは、 Promiseライク なオブジェクトのことで、.then
というメソッドを持っているものを指す。
arguments
や NodeList
などを ArrayLike
と呼ぶのと同じ感じ。
今はあまり使わないかもしれないが、Thenableなものとしては jQuery.ajax()
の戻り値がそうである。
console.log( Promise.resolve($.ajax('http://httpbin.org/get')) ); // Promise {<pending>} // [[PromiseStatus]]: "resolved" // [[PromiseValue]]: Object
確認すると確かにPromiseオブジェクトに変換されて返されている分かる。
それ以外の値が渡された場合
その値をもってFullfilledの状態になったPromiseオブジェクトを返す。
const p = Promise.resolve('Hello'); p.then(value => console.log(value)); // Hello
以上のことをまとめると下記のようになる。
与えらた引数 | 返されるPromiseオブジェクトの状態 |
---|---|
Promiseオブジェクト | 受け取ったPromiseオブジェクトをそのまま返す |
Thenableなオブジェクト | Promiseオブジェクトに変換して返す |
それ以外の値 | その値をもってFullfilledな状態になったPromiseオブジェクトを返す |
引数なし | 結果となる値を持たないFullfilledな状態になったPromiseオブジェクトを返す |
またPromiseの多くの処理は内部処理にPromise.resolve
のアルゴリズムを使って、値をPromiseオブジェクトに変換している。
これもまたPromiseのメソッドチェーンを理解するうえで重要なポイントとなるが、詳しくは後述。
Promise.reject()
こちらもPromise.resolve()
と同じくPromiseオブジェクトの静的メソッドで、 渡された値をもってRejectedな状態のPromiseオブジェクト を返す。
Promise.reject('何らかのエラー');
は下記コードのシンタックスシュガー。
Promise.resolveに比べてそこまで出番はない気がする。
new Promise((resolve, reject) => { reject(new Error('何らかのエラー')); });
Promiseをつなげて使う
Promiseは then
と catch
をメソッドチェーンとしてつなげて書くことができる。
よくあるajaxでのGETを例にしてみる。
下記は郵便番号検索APIを使ってレスポンスをメソッドチェーンで加工しながら渡していき最終的にひとつの住所を出力している(ちなみにこれは日本一長い住所らしい)。
const get = (url) => { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.onload = e => { if (xhr.status === 200) { resolve(xhr.responseText); } else { reject(new Error(xhr.statusText)); } }; xhr.onerror = e => { reject(new Error(xhr.statusText)); }; xhr.send(); }); } get('http://zipcloud.ibsnet.co.jp/api/search?zipcode=6028368') .then(res => JSON.parse(res)) .then(json => json.results[0]) .then(value => value.address1 + value.address2 + value.address3) .then(address => console.log(address)) .catch(reason => console.log(reason)); /* 実行結果 * '京都府京都市上京区北町上の下立売通天神道西入上る' */
なぜこのようなことができるのかというと、then
メソッドが新しいPromiseオブジェクトを返しているから。
ではどのようなPromiseオブジェクトを作成しているかというと、then
に渡された関数の戻り値を Promise.resolve
に渡して作成したものとなっている(これが先に少し触れた内部的にPromise.resolveのアルゴリズムが使われているということの一つ)。
Promise.resolve
は渡された値によって、返すPromiseオブジェクトの状態が異なるが、今回の場合は 文字列
または オブジェクト(配列)
であるから、その値をもったfullfilledなPromiseオブジェクトが常に返されている。
fullfilledなPromiseオブジェクトはthen
で繋ぐと第一引数の関数(onFullfilled)が実行される。
それが続いていくという流れ。
もっとシンプルな例で整理すると下記のようになる。
Promise.resolve() .then(() => { // ① return 'Hello'; }) .then(value1 => console.log(value1)) // ② Hello .then(value2 => console.log(value2)); // ③ undefined
①のthen
に渡された関数の戻り値は'Hello' なので、内部的にPromise.resolve('Hello')
が実行されて、②のthen
で引数を参照している。②の関数の戻り値はundefined
なので③ではそのままundefine
となるという流れ。
もうひとつ例として、then
内の関数の戻り値がPromiseオブジェクトの場合どうなるかというと、先にあげたように Promise.resolve
にPromiseオブジェクトが渡された場合はそのままPromiseオブジェクトが返されるので、そのPromiseオブジェクトの状態によって以降の処理が変わる。
下記は、非同期でリクエスト用の郵便番号を作成してからリクエストを投げるサンプル。
/** * 非同期でリクエスト用の郵便番号を作成 */ const asyncMakeZipCode = () => { return new Promise((resolve, reject) => { setTimeout(() => { resolve('6028368'); }, 1000); }); } asyncMakeZipCode() .then(zipCode => { return get(`http://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipCode}`); // ① }) .then(res => JSON.parse(res)) .then(json => json.results[0]) .then(value => value.address1 + value.address2 + value.address3) .then(address => console.log(address)) .catch(reason => console.log(reason));
①でPromiseオブジェクトを返しているが、Fullfilledな状態なので以降の処理が続く。
当然、ajaxが失敗してRejectedになれば catch
まで飛ぶ。
then
は常に非同期であることに注意
下記は、すぐに resolve()
を実行をして一見非同期処理を書いていないように感じるが、実際には then
に渡された関数が実行されるときから非同期となる。
console.log('outer Promise1'); new Promise((resolve, reject) => { console.log('A'); resolve(); }) .then(() => { console.log('B'); return 'Hello'; }) .then(value => { console.log('C'); }); console.log('outer Promise2');
実行結果は
outer Promise1 A B C outer Promise2
とはならずに
outer Promise1 A outer Promise2 B C
となる。
なぜこのように非同期になるのかというと、Promiseの仕様で Promise.then
で関数を登録する段階でPromiseオブジェクトの状態が決まっていても、そこで登録したコールバック関数は非同期で呼び出させるようになっているため。
そのような仕様になっている理由としては、同期と非同期処理の混在の問題が起きないようにするため。
詳しくは 2.3.1. 同期と非同期の混在の問題 - JavaScript Promiseの本を参照。
エラーハンドリング
Promise.prototype.catch() の箇所で説明したように、非同期処理でエラーが発生し、PromiseオブジェクトがRejectedな状態になったときの処理は、 catch
を使って実行することができる。
では下記のようなコードの場合、処理はどう流れるか?
const double = num=> num * 2; const onRejected = err => console.log(err); new Promise((resolve, reject) => { setTimeout(() => { resolve(10); }, 1000); }) .then(double) .catch(onRejected) .then(double) .then(value => console.log(value)); // 40
結果的には、catch
を飛び越えて処理が一番最後まで進み、40
という数値が出力される。
もし、catch
よりも前の箇所で reject
が呼ばれると、すぐに catch
に渡された onRejected
が実行される。
onRejected
の処理が終わるか、onRejected
がPromiseを返した場合そのPromiseがfullfilledな状態になったとき、次の then
に渡している関数が実行される。
上記の実行の途中でエラーを発生させてみると、catch
のあとの then
が実行されているのが確認できる。
new Promise((resolve, reject) => { // 略 }) .then(double) .then(() => { throw new Error('何らかのエラー'); // エラー発生 }) .catch(onRejected) .then(double) .then(value => console.log(value)); /* 実行結果 * Error: 何らかのエラー * NaN */
これまでの話を整理すると、下記のようなコードは次の図のように説明できる。
async1()
.then(async2)
.catch(onRejected)
.then(aync3);
async1
async2
async3
はそれぞれPromiseを返す関数とする。
Promise.all()
Promise.all
を使うと、複数の非同期処理を並列に実行して、そのすべてが成功した時の処理を書くことができる。
引数にPromiseを返す処理を配列で渡し、そのすべてがfullfilledになったときに、then
に渡した関数が実行される。
その then
に渡された関数の引数には、各Promise内で resolve
に渡された値が配列として渡る。
サンプルは下記。
const asyncFunc = value => { return new Promise(resolve => { setTimeout(() => { resolve(value); }, Math.floor(Math.random() * 2000)); }); } Promise.all([ asyncFunc(10), asyncFunc(100), asyncFunc(1000) ]) .then(value => console.log(value)); /* 実行結果 * [10, 100, 1000] */
Promise.all
に渡された配列内の関数はそれぞれ終了タイミングが0~2秒のランダムになっているが、実行結果は必ず [10, 100, 1000]
となっている。
これは、引数に渡される配列内の値の順番は、Promise.all
へ渡した処理の順番と同じということが保証されているため。
エラー発生時
Promise.all
に渡したProimseのうち、ひとつでもrejected(失敗)になったものが発生した場合、即 catch
に登録した処理へ移行する。
他のPromiseがfullfilledになっていても then
に登録した処理は飛ばされる。
下記で確認してみる。
const asyncFunc = value => { return new Promise(resolve => { setTimeout(() => { console.log(value); // エラー発生時に実行されるか確認 resolve(value); }, Math.floor(Math.random() * 2000)); }); } const errorFunc = () => { return new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('何らかのエラー')); }, Math.floor(Math.random() * 2000)); }); } Promise.all([ asyncFunc(10), errorFunc(), // エラー発生させる asyncFunc(100), asyncFunc(1000) ]) .then(value => console.log(value)) .catch(err => console.log(err)); /* 実行結果 * 1000 * 10 * Error: 何らかのエラー * 100 */
エラーを発生させる errorFunc
を Promise.all
に渡したところ、
正しく、then
に渡された関数が実行されることなく catch
に移行している。
この場合、errorFunc
のあとの残りの関数( asyncFunc
) は実行されるのか確認するために asyncFunc
内で console.log(value)
を実行させてみた結果、すべて実行されているのが確認できた。
まとめると、Promise.all
は渡された配列内のPromiseがすべてfullfilledになったら自身もfullfilledになり、ひとつでもrejectedになったら自身もrejectedに移行するPromiseオブジェクトを返す。
Promise.race()
Promise.race
は、記述の仕方は Promise.all
と同じで、配列として渡したPromiseのうち、どれかひとつでもfullfilled または rejected になったら次の処理に移行する。
その名の通り、レース(競争)させるイメージ。
下記はPromise.all
の説明で使った最初のサンプルを all
から race
に変えただけのもの。
const asyncFunc = value => { return new Promise(resolve => { setTimeout(() => { resolve(value); }, Math.floor(Math.random() * 2000)); }); } Promise.race([ asyncFunc(10), asyncFunc(100), asyncFunc(1000) ]) .then(value => console.log(value)); /* 実行結果 * 10 または 100 または 1000 */
実行結果はどれかひとつだけなので、then
に渡される関数の引数には all
とは異なり配列ではなく、ひとつの値である。
また残りのPromiseについては、キャンセルされることなく並行して実行される。
下記はall
で使用したサンプルを少し変更したもの。
const asyncFunc = value => { return new Promise(resolve => { setTimeout(() => { resolve(value); console.log(value); // ※2番目以降でもキャンセルされないか確認用 }, Math.floor(Math.random() * 2000)); }); } const errorFunc = () => { return new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('何らかのエラー')); console.log('エラー'); // ※2番目以降でもキャンセルされないか確認用 }, Math.floor(Math.random() * 2000)); }); } Promise.race([ asyncFunc(10), errorFunc(), asyncFunc(100), asyncFunc(1000) ]) .then(value => console.log('sucess: ' + value)) .catch(err => console.log('fail: ' + err)); /* 実行結果1: asyncFunc(1000)が最初に完了した場合 * 1000 * sucess: 1000 * 10 * 100 * エラー */ /* 実行結果2: errorFunc()が最初に完了した場合 * エラー * fail: Error: 何らかのエラー * 1000 * 10 * 100 */
下記は2.9. Promise.race - JavaScript Promiseの本からの引用。
ES6 Promisesの仕様には、キャンセルという概念はありません。 必ず、resolve or rejectによる状態の解決が起こることが前提となっています。 つまり、状態が固定されてしまうかもしれない処理には不向きであるといえます。 ライブラリによってはキャンセルを行う仕組みが用意されている場合があります。
まとめ
自分用の備忘録としてだらだらと書いてしまったが、これまでのことをざっくりとまとめてみる。
- Promiseは非同期処理を抽象的に扱うことができるオブジェクト
- Promiseは3つの状態を持つ
- Pending (初期状態)
- Fullfilled (成功時)
- Rejected (失敗時)
- Fullfilled になったとき
then
に渡された関数が実行される - Rejected になったとき
catch
に渡された関数が実行される Promise.resolve
は渡す引数によって返すPromiseオブジェクトの状態が異なる- Promiseは
then
とcatch
をメソッドチェーンでつないで使うことができるthen
は常に新しいPromiseオブジェクトを返すthen
に渡された関数の戻り値をPromise.resolve
に渡して実行している
then
は常に非同期
- Promiseにキャンセルの概念はない