いまさら聞けないJavaScriptのPromiseを丁寧に解説

f:id:shogonir:20191214232228p:plain

目次

  1. はじめに
  2. Promiseとは
  3. Promiseを使う
    3.1. 基本的な使い方(then, catch)
    3.2. 実行される順番に注意
    3.3. then, catchが両方実行されることもある
  4. Promiseを作る
    4.1. Promise.resolve(), Promise.reject()
    4.2. new Promise()
  5. まとめ

1. はじめに

この記事では、JavaScriptのPromiseについて説明したいと思います。
Promiseは通信などの非同期処理を行う際に必須になる知識ですが、
「Promiseはなんとなく使えるけど苦手」という方も多いのではないでしょうか。

もちろんなんとなく知っている状態でもコードは書けるのですが、
思い通りにPromiseを操るために知識を持っておいて損はないと思います。

また、「最近は async / await が流行っているから、もうPromiseは知らなくていい」と
言う方も、もしかしたら居るかもしれません。
しかし async / await も実際のところはPromiseを使っているだけなのです。

こういった理由でPromiseをしっかり理解しておくことは非常に重要です。
ぜひこの記事を読んで、Promiseについて知って欲しいです。

2. Promiseとは

Promiseは非同期処理の状態と結果を保持するためのクラスです。

Promiseがとりうる状態はpending, resolved, rejectedの3つあります。
pendingは待機中の状態であり、他の2つのステータスに遷移することができます。
resolved(fulfilledと言うこともある)は処理が完了し成功したことを意味するステータスです。
rejected(failedと言うこともある)は処理が完了し失敗したことを意味するステータスです。

 

f:id:shogonir:20191214233411p:plain

 

Promiseはresolved, rejectedのどちらかのステータスになると、
その後に他のステータスにうつることはありません。
そのため、この2つのステータスを特にsettled(落ち着いた)と言います。

 

f:id:shogonir:20191214234956p:plain

 

なんとなくPromiseについて分かってきたとおもうので、
次に実際のコードでPromiseの使い方を説明したいと思います。

3. Promiseを使う

この章ではfetchを使ってPromiseの使い方を説明したいと思います。
fetchは通信を行うメソッドで、その状態と結果をPromiseで返します。

fetchについては詳しくは下記のリファレンスを参照してください。

developer.mozilla.org

 

3.1. 基本的な使い方(then, catch)

次のように使います。

fetch('https://example.com')
  .then((response) => {
    console.log('resolved');
  })
  .catch((error) => {
    console.log('rejected');
  });

このように記述すると、非同期で通信を開始します。
この通信が完了するまで、Promiseのステータスはpendingです。
通信が完了してresolvedに変化すると、thenに渡したコールバックが実行されます。
エラーでrejectedになると、catchに渡したコールバックが実行されます。

then, catchに渡すコールバック関数は、1つの引数をとることができ、
Promiseの結果を受け取って任意の処理を行わせることができます。

fetchの場合、thenに渡される結果はResponseになるので、
通信で取得したレスポンスのステータスコードJSONなどを使えます。

catchはほとんどの場合Errorを受け取ることが多いです。

 

3.2. 実行される順番に注意

Promiseを使った時に処理がどういう順番で実行されるか分からないと、
厄介な不具合の原因になることがあるので注意が必要です。

次のコードが実行されたとき、コンソールにはどのように表示されるでしょうか。
ただし、通信は一定時間後に必ず成功するとします。

 

console.log('a');
fetch('https://example.com')
  .then((response) => {
    console.log('b');
    console.log('resolved');
  })
  .catch((error) => {
    console.log('c');
    console.log('rejected');
  });
console.log('d');

 

先ほどのソースコードにコンソールへの出力が追加されています。
a, b, c, dはどの順番で表示されるか分かりますでしょうか。
ただし、通信は一定時間後に必ず成功するとします。

正解は a, d, b の順番です。
なぜこの順番で出力されるのか解説したいと思います。

 

f:id:shogonir:20191215001111p:plain

 

a が表示された後、fetchがあるので通信が開始されます。
fetchはpendingのPromiseを返し、これにthen, catchでコールバックが設定されます。
あくまでコールバックは登録されただけなので実行はされません。
つまりこの時点で b, c がコンソールに表示されることはありません。
通信は非同期処理として待機されるので、その間に処理が下に進み d が表示されます。
その後、通信が完了したときにthenのコールバックのみ実行され b が表示されます。
よって、a, d, b の順番でコンソールに表示されるのです。

もし「コールバック関数が渡した段階では実行されない」ということについて、
理解できないという方は「マニュアル」を想像すると良いと思います。
あなたが「災害対策マニュアル」を渡されたとして、それを読むことはあっても
その場ですぐに実践して机のしたに潜り込むということはないと思います。
実践するのは飽くまで災害が発生した時ですよね?
これと同じで、コールバック関数は渡した瞬間ではなく、
それを実行すべきイベントが発生した時に実行されるのです。

 

3.3. then, catchが両方実行されることもある

これもPromise初心者のハマりポイントなのではないでしょうか。
次のソースコードが実行されると何が出力されるでしょうか。
今回も通信は一定時間後に成功するものをします。

 

console.log('a');
fetch('https://example.com')
  .then((response) => {
    console.log('b');
    console.log(unknown);
    console.log('c');
  })
  .catch((error) => {
    console.log('d');
    console.log('rejected');
  });
console.log('e');

 

このソースコードではthenに渡したコールバック関数で、
どこにも宣言しいないunknownという変数を参照してエラーになってしまいます。
このようにthenのコールバック関数でエラーになった場合は、
catchのコールバック関数も実行されるのです。
なのでコンソールには a, e, b, d が表示されます。

thenでエラーが出た時にcatchも実行されることが分かっていれば、
普段の実装で困ることは特にないと思います。
「でもPromiseのステータスはresolvedになったら
もう変わらないんじゃないの?」と思った人は下の解説を読ん下さい。

 

f:id:shogonir:20191215003603p:plain

 

解説する上で、then, catchの仕様を再度確認する必要があります。
then, catchはPromiseのメソッドで、コールバック関数を
実行した結果を新しいPromiseに包んで返します。
コールバック関数がErrorをthrowしたら、rejectedなPromiseになり、
何かしら値を返すことができたら、resolvedなPromiseになります。

なので、先ほどのソースコードでは3つのPromiseがあったことになります。
1つ目はfetchが作成したもの、2つ目はthenが作成したもの、3つ目はcatchが作成したものです。

また、コールバック関数がどのPromiseに登録されたかも整理します。
thenのコールバック関数が登録されたのは1つ目のPromiseで、
catchのコールバック関数が登録されたのは2つ目のPromiseです。
3つ目のPromiseにはコールバック関数が登録されていません。

処理の流れを解説していきます。
fetchの通信が完了して1つ目のPromiseのステータスが
pendingからresolvedに変化し、thenのコールバック関数が実行されます。
ここでReferenceErrorが起きるため、2つ目のPromiseは
pendingからrejectedに変化し、catchのコールバック関数が実行されます。
3つ目のPromiseはresolvedになりますがコールバック関数が登録されていないので、
これ以上やるべき処理がないので終了します。

ということで、then, catchのコールバック関数が両方実行されることもあります。
常にどちらかしか実行されないと思っているとハマることがあるので注意です。

4. Promiseを作る

Promiseの作り方を知っておくと便利な時があります。

 

4.1. Promise.resolve(), Promise.reject()

新しく作りたいPromiseのステータスがsettledな場合、便利なメソッドがあります。
Promise.resolve() でresolvedなPromiseが、
Promise.reject() でrejectedなPromiseが作成できます。

また、これらのメソッドには1つの引数を渡すことができ、
渡したものがそのままPromiseの結果として保持されます。

例えば Promise.resolve(100) で作成されたPromiseは最初からresolvedなので、
これにthenでコールバック関数を登録するとすぐにコールバック関数が実行されます。
このコールバック関数の引数には結果の100が入っているはずです。

 

const promise = Promise.resolve(100);

promise.then((result) => {
  console.log(result);   // 100
});

 

それではもう一つのPromiseを作成する方法を紹介します。
正直なところ次に紹介する方法をつかうことがほとんどです。

 

4.2. new Promise()

コンストラクタでもPromiseを作成することができます。
こうして作られたPromiseは最初は必ずpendingのステータスになります。

Promiseのコンストラクタはexecutorという関数を引数にとります。
executorはresove, rejectという2つの関数を引数にとる関数です。
コンストラクが実行された瞬間にpendingのPromiseが新たに作成され、
executorもそのときに実行されます。

そして resolve() が実行されたときにPromiseはresolvedに変化し、
reject() が実行されたときにrejectedに変化します。
settledなステータスに変化するともう変化できないので、
resolveとrejectが両方実行されることはありません。

また、 resolve() reject() に渡したものがそのPromiseの結果として、
then, catch のコールバック関数を実行する際に渡されます。

このPromiseのコンストラクタを例にしてよく説明されるのが、
次のような sleep() という関数を実装する例です。

 

function sleep(milliSec) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, milliSec);
  });
}

sleep(1000)
  .then(() => {
    console.log('slept');
  });

 

このようなソースコードがあった時に何が起きるのか解説します。

まず sleep(1000) が実行された瞬間にpendingなPromiseが作成され、executorが実行されます。
setTimeout()によって resolve() が1秒後(1000ms)に実行されるように登録されます。
resove() が実行されたときにPromiseがresolvedになり、thenのコールバック関数が実行され、
コンソールに 'slept' と表示されるという流れになります。

5. まとめ

この記事ではPromiseについて詳しく解説しました。
Promiseの使い方をなんとなく理解できている方は多いと思いますが、
この記事を読んでいて新たな発見はあったでしょうか?

これからPromiseを使う時に少しでも助けになれば幸いです。

また、このPromiseは async / await と一緒に使うとより便利になります。
しかも、async / await は本質的にはPromiseを使っているだけなので、
この記事を読んだ方はすぐに async / await を理解できるはずです。
ということで次は async / await について記事にしたいと思います。