【JavaScript】Promise のメカニズムを理解する

Promiseの本質を理解する JavaScript

この記事では、JavaScript を実務で使用する上で避けては通れない、そしてなかなか理解するのが難しい概念である Promise について解説する。

プログラミング学習未経験からでもITエンジニアとしての基礎を身に付けることができるオススメのスクール >> 【DMM WEBCAMP】

Promise とは

Promise オブジェクトは、非同期処理をより簡単に、可読性が上がるように書けるようにするために、ES6 から導入されたオブジェクト。

非同期処理の記述は、

  • 非同期で実行させたい処理を記述したコールバック関数
  • そのコールバック関数を API に登録して、非同期処理をスタートさせる

という2つの処理の記述で構成される。例えば、「1秒後にコンソールに Hello と表示させる」という非同期処理を記述するには、次のように行う。

// 1秒後に実行させたい処理
function hello() {
  console.log('Hello');
}

// Web APIs の setTimeout API に hello 関数を登録し、
// タイマーをスタートさせる。
setTimeout(hello, 1000);

あるいは、次のようにこの2つの記述をまとめることもできる。

setTimeout(function() {
  console.log('Hello');
}, 1000);

ここまでは特に何も問題を感じない、非常にシンプルな記述だ。

では次に、上記のサンプルを参考に、次のような記述が可能となるにしたい。

  • 1秒後にコンソールに Hello と表示させる。
  • その表示からさらに2秒後に Hello World! と表示させる。
  • その表示からさらに3秒後に Bye と表示させる。
setTimeout(function() {
  console.log('Hello');
  setTimeout(function() {
    console.log('Hello World!');
    setTimeout(function() {
      console.log('Bye');
    }, 3000);
  }, 2000);
}, 1000);
// Hello
// Hello World!
// Bye

このように記述することで上記の要件を果たせたが、実行させたい処理の順番が、関数呼び出しのネスト(入れ子)の深さとして反映されるという問題が生じる。一般的にネストの深いコードは可読性が悪いので嫌われる。上記のコードの書き方だと、非同期処理を接続するほどネストが深くなり、コードの可読性がどんどん悪くなる。

そこで次のように処理を個別に定義して記述する方法を採ってみることにする。

function func1() {
  console.log('Hello');
  setTimeout(func2, 2000);
}

function func2() {
  console.log('Hello World!');
  setTimeout(func3, 3000);
}

function func3() {
  console.log('Bye');
}

setTimeout(func1, 1000);
// Hello
// Hello World!
// Bye

このようにすることで実行させたい処理の順番は、関数の宣言の順番として反映させることができるので、いくらかコードの可読性は解消されるかもしれない。しかし、JavaScript の無名関数の便利さは失われ、一つ一つの処理のために名前を与える必要が生じる。

また、連続して実行される非同期処理の記述が、複数の関数宣言文に分離されてしまったため、例えばコードを引き継いだ人が func3 という関数はどこを起点として実行されるのかを解読するためには、func2 の宣言を見つける必要があり、さらにそれを呼ぶ func1 の宣言を見つけ、最終的に func1(); という呼び出しを見つける必要がある。つまり結局、この書き方もコードの可読性が悪い。

このように JavaScript における非同期処理は記述が複雑になる問題を抱えている。そこでこの問題を解消するために、ES6 からは Promise オブジェクトが導入された。

最初に述べたように、非同期処理は

  • 非同期で実行させたい処理を記述したコールバック関数
  • そのコールバック関数を API に登録して、非同期処理をスタートさせる

という2つの記述で構成される。Promise オブジェクトを使うとこれらの2つの記述を分離しながらも、1つの Promise のインスタンスを通して関連付けられるため、非同期処理を連続させてもコールバック関数のネストが深くなったり、関数の宣言が散らばったりしない、という特徴をもっている。

しかしながら、その他のビルトインオブジェクトに比べて Promise オブジェクトは複雑に感じられる。そこで以下では、Promise オブジェクトが持つ一つ一つの特徴を拾いながら Promise オブジェクトを理解していくことを目指す。

Promise コンストラクタ

コンストラクター関数の引数 executor

Promise オブジェクトも他の多くのビルトインオブジェクトと同様に関数オブジェクトであり、コンストラクター関数として new 演算子とともに使用する。

とりあえずインスタンスを生成してみよう。

new Promise();

// Uncaught TypeError: Promise resolver undefined is not a function
// at new Promise (<anonymous>)

上のサンプルから分かるように、引数を与えないでコンストラクター関数を呼ぶとエラーになる。Promise の引数(Promise resolver と呼ぶらしい)が undefined であり、関数でないというエラーが発生している。つまり Promise コンストラクターは引数に関数を渡す必要がある

Chrome ではこの関数のことを Promise resolver と呼んでいるようだが、JavaScript の仕様である ECMAScript では「executor」と表現しているため、以降では Promise コンストラクターに渡す関数のことを ECMAScript にならい、executor と表現することにする。

executor は即座に実行される

コンストラクター関数の引数には何らかの関数を渡す必要があることが分かったので、適当に関数を渡してみよう。

new Promise(function() {
  console.log('Hello Promise');
});

// Hello Promise

このように、new 演算子で Promise のインスタンスを生成すると、コンストラクター関数の引数に渡した関数(executor)が実行される。

しかも、この引数に渡した関数の実行は同期的に行われる。つまり、非同期処理として実行されるなら、いったんタスクキューに追加され実行の順番を待つことになるが、そうはならない。次のサンプルから分かるように、コンストラクターに渡した関数は、new 演算子によるインスタンス生成時に即座に実行される。

console.log('new 前');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

new Promise(function() {
  console.log('Hello Promise');
});

console.log('new 後');

// new 前
// Hello Promise
// new 後
// setTimeout

executor に渡される2つの関数 resolve, reject

コンストラクターに渡す関数(executor)について調べてみよう。

new 演算子によりインスタンス生成を行う際に、渡した executor が実行されることは分かったが、この関数の内部では何を行うべきなのか?

executor はプログラマーが自由に定義できる関数だが、単なる関数の実行以上の役割を行わせるには、executor の内部でのみ可能な「何か」が必要だ。

実はその秘密は、executor が受け取る引数にある。

resolver は2つの関数を引数で受け取る

この画像のように executor の中で、関数が呼び出し時に受け取る実引数を保持している arguments オブジェクトの内容を表示させてみると、2つの関数を受け取っていることが分かる。

この2つの関数は executor が呼び出されるときに JavaScript エンジンによって渡されるものであり、これこそが executor の内部でのみ可能な「何か」を実現するためのものになっている。

また、これらの関数を受け取る方法は、arguments オブジェクトから取り出しても良いが、コンストラクターに渡す executor に仮引数を定義して受け取るのが分かりやすいのでそのようにする。

その際に executor の第一引数は resolve、第二引数には reject という名前を与えるのが慣習になっているのでそれに従うことにする。つまり通常は以下のように記述する。

new Promise(function(resolve, reject) {
  // インスタンス生成時に実行させる処理
})

英語では、resolve は「解決する」、reject は「拒否する」という意味を持っている。正反対の雰囲気を感じさせるこれらの関数について、どのような働きを持っているのか見ていくことにしよう。

Promiseインスタンスの2つのプロパティ

それでは executor が引数として受け取るこれら2つの関数 resolve, reject がどのような機能を持っているのかを調べてみよう。そのためにまず、new 演算子によるインスタンス化によって生成される Promise のインスタンスが、どの様なプロパティをもったオブジェクトなのかを見てみよう。

Promise のインスタンスは2つの内部プロパティ [[PromiseState]] と [[PromiseResult]] を持っている

この画像のように Chrome の開発者ツールのコンソールで、Promise のインスタンスを表示させてみると、Promise のインスタンスは2つの内部プロパティ [[PromiseState]][[PromiseResult]] を持っていることが分かる。

この2つのプロパティ [[PromiseState]] と [[PromiseResult]] は、コードからは直接アクセスすることはできない、JavaScript エンジンが内部でのみ使用するプロパティとなっている。

executor の第一引数 resolve の働き

executor の第一引数 resolve に渡される関数を、executor の内部で実行してみる。

resolve を executor の内部で実行

すると生成されるインスタンスの [[PromiseState]] 内部プロパティの値が、先ほどは “pending”(保留中)だったが今回は “fulfilled”(完了)になっている。

今度は引数も与えて resolve 関数を呼び出してみよう。例えば次のように resolve 関数を呼び出すときに文字列 “Hello” を実引数として渡してみる。

引数も与えて resolve 関数を呼び出してみる

すると生成されるインスタンスの [[PromiseState]] 内部プロパティの値は “fulfilled”(完了)になり、さらに [[PromiseResult]] 内部プロパティの値は “Hello” になった。

このようにインスタンス生成時に実行される executor 関数の中で、渡された第一引数の resolve 関数を呼び出すと、生成されるインスタンスの2つの内部プロパティ [[PromiseState]], [[PromiseResult]] に影響を及ぼすことが分かった。ここまでをまとめると、

  • resolve, reject のいずれも呼び出さないときは、生成されるインスタンスの2つの内部プロパティの値は、[[PromiseState]] が “pending”(保留中)、[[PromiseResult]] が undefined となる。
  • resolve を呼び出すと、生成されるインスタンスの2つの内部プロパティの値は、[[PromiseState]] が “fulfilled”(完了)、[[PromiseResult]] が resolve に渡した引数の値となる。

ところでこの2つのプロパティ [[PromiseState]], [[PromiseResult]] の値は、インスタンスの生成後も変更することができるのだろうか?

インスタンスを生成した後で resolve 関数の呼び出しを実行できるように、変数 resolve2 を定義し、この変数に resolve を格納してインスタンスを生成した後で呼び出してみよう。

変数に resolve を格納してインスタンスを生成した後で呼び出してみる

このようにインスタンスを生成した後で resolve 関数を呼び出しても、[[PromiseState]] が “fulfilled”(完了)、[[PromiseResult]] が resolve に渡した引数の値となる。

上の画像では、さらにもう一回 resolve 関数を引数を変えて呼び出しているが、2つの内部プロパティは変化していない。つまり2回目以降は無視されるようだ。

以上で executor の第一引数に渡される resolve 関数の働きの一部が分かった。ここまでのことをまとめると、次のようになる。

  • resolve 関数は任意のタイミングで呼び出すことができる。つまり resolve 関数を外部に取り出せば、インスタンスの生成後でも呼び出すことができる。
  • resolve 関数の呼び出しにより、生成されるインスタンスの2つの内部プロパティの値が変更される。その際、[[PromiseState]] が “fulfilled”(完了)、[[PromiseResult]] が resolve に渡した引数の値となる。
  • resolve 関数の呼び出しは1回だけ有効であり、2回目以降は無視される。

executor の第二引数 reject の働き

同様にして executor の第二引数 reject に渡される関数について、その働きを調べてみよう。

reject を executor の内部で実行
引数も与えて reject 関数を呼び出してみる
変数に reject を格納してインスタンスを生成した後で呼び出してみる

このように reject 関数を呼び出すと、resolve 関数のときと同様に、生成されるインスタンスの2つの内部プロパティを変更され、[[PromiseState]] が “rejected”(拒否)、[[PromiseResult]] が reject に渡した引数の値となることが分かる。インスタンスの生成後に reject を呼び出しても、resolve の時と同様にプロパティが変更されている。

resolve と reject ではインスタンスの内部プロパティ [[PromiseState]] に設定される値が “fulfilled”(完了)か “rejected”(拒否)かという違いはあるが、とてもよく似た動作をすることが分かる。

ただし、reject を実行した場合、Chrome ではコンソールにエラーが赤字で表示されている。これは reject の実行が Promise においてはエラーの発生を意味するということを表している。

例えば、以下のように executor の処理内で例外をスローしてみる。

executor の処理内で例外をスローしてみる

このように executor の処理内で「throw ‘Hello’;」のように例外を投げたとしても、「reject(‘Helllo’)」を実行したのと同じ結果を得る。

先述したように、Promise コンストラクターに渡した executor の処理は、同期処理として実行される。しかしその内部で例外を投げても try … catch 文で catch することはできない。catch に失敗したからプログラムが停止するというわけでもない。

executor の内部で throw した例外を catch できない

executor の処理内で投げた例外は、Promise コンストラクターの内部で catch され、reject 関数の呼び出しに置き換えられていると考えるのが妥当だろう。

以上で executor の第二引数に渡される reject 関数の働きの一部が分かった。ここまでのことをまとめると、次のようになる。

  • reject 関数は任意のタイミングで呼び出すことができる。つまり reject 関数を外部に取り出せば、インスタンスの生成後でも呼び出すことができる。
  • reject 関数の呼び出しにより、生成されるインスタンスの2つの内部プロパティの値が変更される。その際、[[PromiseState]] が “rejected”(拒否)、[[PromiseResult]] が resolve に渡した引数の値となる。
  • reject 関数の呼び出しは1回だけ有効であり、2回目以降は無視される。
  • (上記で示さなかったが)reject の呼び出し後の resolve は無視され、resolve の呼び出し後の reject は無視される。つまり resolve と reject はどちらかを呼んだら、それ以上の呼び出しはインスタンスに影響を与えない。
  • executor の実行時に投げられた例外「throw val;」は、reject 関数の呼び出し「reject(val);」に置き換えられる。

Promise インスタンスが意味するもの

上記で Promise オブジェクトのインスタンスが2つの内部プロパティ [[PromiseState]], [[PromiseResult]] をもつこと、そしてこれらが executor の引数で受け取る resolve, reject という関数を呼び出すことによって変更されることを確認した。

Promise インスタンスの変化

ところで Promise オブジェクトは、そもそも非同期処理の記述を簡単に行うために導入されたものだった。ここまでのところ、非同期処理の話が全く出てこなかったので、そろそろ非同期処理と絡めて Promise について考えていきたい。

非同期処理は

  • 非同期で実行させたい処理を記述したコールバック関数
  • そのコールバック関数を API に登録して、非同期処理をスタートさせる

という2つの記述で構成されている。例えば、「1秒後にコンソールに Hello と表示させる」という非同期処理を記述するには、次のように行う。

// 1秒後に実行させたい処理
function hello() {
  console.log('Hello');
}

// Web APIs の setTimeout API に hello 関数を登録し、
// タイマーをスタートさせる。
setTimeout(hello, 1000);

コールバック関数 hello の記述を変更することで、様々な処理を1秒後に実行させることができるのだが、この記述だと setTimeout 関数を呼び出す時点で1秒後に実行させる処理が定義されている必要がある。

ところが Promise を使うと次のような書き方ができる。

// 1秒後に resolve 関数が実行されるようにし、かつ
// 1秒後に [[PromiseState]] プロパティが変化するインスタンスを生成する。
const promise = new Promise(function(resolve) {
  setTimeout(function() {
    resolve();
  }, 1000);
});

executor は即座に実行されるのだったから、この場合 setTimeout によりタイマーがスタートされる。その際に setTimeout API によって1秒後に実行されるコールバック関数では resolve を呼び出している。

この記述は次の2つのことを意味している。

  • 1秒後に resolve 関数が呼び出される。
  • それにより生成されたインスタンスは、1秒後に [[PromiseState]] プロパティの値が “pending” から “fulfilled” に変化する。

まだ、1秒後に行わせたい具体的な処理は与えていないが、とりあえずタイマーだけ動作させ、1秒後に resolve 関数を実行させ、それにより変化することが約束(promise)されたオブジェクト(インスタンス)を手に入れたわけだ。

後はこのインスタンスに「1秒後に実行させたい具体的な処理」を登録し、それを自動で実行させる方法が分かれば、今までには無かった新しい非同期処理の記述方法を取得したことになる。

ここまでの説明で、new Promise(function(resolve, reject) { … }); という Promise インスタンス生成の処理について見てきた。このインスタンス生成の処理が意味することをまとめると、次のように表現できる。

  • executor と呼ばれる関数 function(resolve, reject) { … } を実行する。
  • executor 内では、生成されるインスタンスが、いつ resolve されるか、あるいは reject されるかが記述されている。
  • 未来のある時点(resolve あるいは reject が呼び出される時点)で変化することが約束(promise)されたインタンスが生成される。

以降では、生成されたインスタンスに対して「未来で実行させたい処理」を登録し、登録した処理がその未来が訪れたときに実行されるようにする方法について見ていくことにする。

Promise インスタンスのメソッド

ここからは生成されたインタンスに視点を移し、その性質について少しずつ理解していこう。

then, catch, finally メソッド

Promise のインスタンスが使用できるメソッドについて見てみよう。

Promise インスタンスが使えるメソッド

Promise コンストラクターから生成されたインスタンスは、Promise.prototype オブジェクトが保有しているメソッドを使用でき、上の画像のように catch, finally, then という3つの特別なメソッドを継承していることが分かる。

これら3つのメソッドについて、一つずつ見ていくことにしよう。

then メソッド

then メソッドとは

Promise インスタンスの生成が意味するものについて説明したときに、生成されたインスタンスに対して「未来で実行させたい処理」を登録し、登録した処理がその未来が訪れたときに実行されるようにする方法が必要であることを述べたが、それを与えるのが then メソッドとなっている。

then メソッドは2つの関数を引数で受け取る。ECMAScipt の仕様では、1つ目の関数を onFulfilled、2つ目の関数を onRejected と表現しているので、この記事でも2つの関数をそのように呼ぶことにする。

new Promise(function() {
  // executor
}).then(
  function(arg) {
    // onFulfilled
  },
  function(arg) {
    // onRejected
  }
);
const promise = new Promise(function() {
  // executor
});

promise.then(
  function(arg) {
    // onFulfilled
  },
  function(arg) {
    // onRejected
  }
);
then メソッドによる2つの関数の登録

resolve を実行すると onFulfilled が実行される

インスタンスから then メソッドを呼び出し、onFulfilled と onRejected に適当な関数を設定してから resolve を実行してみる。

let resolve2;
new Promise(function(resolve) {
  resolve2 = resolve;
})
.then(
  function(arg) {
    console.log('onFulfilled:', arg);
  },
  function(arg) {
    console.log('onRejected:', arg);
  }
);

resolve2('resolve value');  // onFulfilled: resolve value
resolve2('resolve value2'); // (何も表示されない)

このサンプルから次のことが分かる。

  • resolve 関数を実行すると、then メソッドの第一引数に渡した関数 onFulfilled が実行される。
  • resolve 関数の引数に渡した値( ‘resolve value’)は、onFulfilled の引数に渡ってくる。
  • resolve 関数の2度目以降の呼び出しは無視される。
resolve を実行すると onFulfilled が実行される

したがって、「未来で実行させたい処理」は then メソッド第一引数(onFulfilled)に登録すればよいということだ。

例えば、

// 1秒後に実行させたい処理を登録し、タイマーをスタートさせる
setTimeout(function() {
  console.log('Hello');
}, 1000);

という記述は、Promise を使用すると次のように書くことができる。

// 1秒後にコールバック関数(まだ登録されていない)が実行されることを
// 約束した Promise インスタンスを生成
const promise = new Promise(function(resolve) {
  setTimeout(function() {
    resolve();
  }, 1000);
})

// 1秒後に実行させたい処理を登録
promise.then(function() {
  console.log('Hello');
});

非同期処理をスタートした後で、そのコールバック関数の登録が行えるというのは Promise の特徴の一つだろう。

しかし次のような疑問点も発生する。「未来で実行させたい処理」の登録より先に「約束された未来」が来てしまったらどうなるのか…? 次にこの点について見てみよう。

resolve の実行と onFulfilled の登録の順番は任意

まずは、「未来で実行させたい処理」の登録 →「約束された未来」の到来のサンプル。

let resolve2;

// コールバック関数(まだ登録されていない)の呼び出し
// が約束された Promise インスタンスを生成
const promise = new Promise(function(resolve) {
  resolve2 = resolve;
});

// コールバック関数(未来で実行させたい処理)の登録
promise.then(function(msg) {
  console.log(msg);
});

// 約束された未来が到来(resolve の実行)
resolve2('Hello, future!');

// Hello, future!

このサンプルでは、resolve の呼び出しにより登録したコールバック関数が実行される。

次に、「約束された未来」の到来 →「未来で実行させたい処理」の登録のサンプルを試してみよう。

let resolve2;

// コールバック関数(まだ登録されていない)の呼び出し
// が約束された Promise インスタンスを生成
const promise = new Promise(function(resolve) {
  resolve2 = resolve;
});

// 約束された未来が到来(resolve の実行)
resolve2('Hello, future!');

// コールバック関数の登録
promise.then(function(msg) {
  console.log(msg);
});

// Hello, future!

この場合は、then メソッドの呼び出し(コールバック関数の登録)によりコールバック関数が実行されている。

以前に述べたように、Promise インタンスは内部に2つのプロパティ [[PromiseState]], [[PromiseResult]] を持ち、resolve(value); の実行により、[[PromiseState]] の値が “pending” から “fulfilled” に変わり、[[PromiseResult]] には value がセットされるのだった。

そのため、インスタンスの内部プロパティで resolve が呼ばれたこと、及び resolve に渡された値が管理されているため、then メソッドによる「未来で実行させたい処理」の登録より先に「約束された未来」(resolve の実行)が来てしまったとしても問題なく登録したコールバック関数が実行できる。

つまり、次のように言える。

  • then メソッドの呼び出しと resolve の呼び出しの実行される順番を気にしなくてよい
  • 後に呼び出された方の処理により、登録したコールバック関数が実行される

reject を実行すると onRejected が実行される

今度は、reject を試してみよう。

let reject2;
new Promise(function(resolve, reject) {
  reject2 = reject;
})
.then(
  function(arg) {
    console.log('onFulfilled:', arg);
  },
  function(arg) {
    console.log('onRejected:', arg);
  }
);

reject2('reject value');  // onRejected: reject value
reject2('reject value2'); // (何も表示されない)

reject を実行した場合も resolve を実行した場合と似たような結果が得られるが、こちらは実行される関数が then メソッドの第二引数 onRejected に渡したものになっている。

  • reject 関数を実行すると、then メソッドの第二引数に渡した関数 onRejected が実行される。
  • reject 関数の引数に渡した値( ‘reject value’)は、onRejected の引数に渡ってくる。
  • reject 関数の2度目以降の呼び出しは無視される。
reject を実行すると onRejected が実行される

ところで「reject の実行が Promise においてはエラーの発生を意味する」ということを以前に説明した。exeutor の処理内で例外を投げても try … catch 文で捕捉できないが、代わりに reject 関数の呼び出しに置き換えられるというものだった。

例外を投げた時に reject 関数が呼び出されるなら、onRejected が実行されるはずだ。試してみよう。

new Promise(function(resolve, reject) {
  throw 'error'
})
.then(
  function(arg) {
    console.log('onFulfilled:', arg);
  },
  function(arg) {
    console.log('onRejected:', arg);
  }
);

// onRejected: error

onRejected が実行されるのが確認できた。また、then メソッドで onRejected を登録することでコンソールに赤字で表示されていたエラーメッセージも表示されなくなった。

このように Promise においてはエラーの発生は reject 関数の呼び出しに置き換えられ、それは then メソッドの第二引数 onRejected の実行を引き起こす。これは Promise においては、onRejected が try … catch 文の catch に相当する役割を持っていて、エラー発生時に行わせる処理を担当していることを意味する。

then メソッドの戻り値

then メソッドの戻り値は新しい Promise インスタンス

then メソッドを呼び出すとその戻り値として新しい Promise インスタンスが返ってくる。

const promise = new Promise(function() {});

const promise2 = promise.then(
  function() {},
  function() {}
);

console.log(promise2);
// Promise {<pending>}
//   __proto__: Promise
//     [[PromiseState]]: "pending"
//     [[PromiseResult]]: undefined

console.log(promise === promise2);
// false
then メソッドを呼び出すとその戻り値として新しい Promise インスタンスが返ってくる

ただし、この返却される Promise インスタンスは、new Promise(function(resolve,rejcet) {…}); のような new 演算子とコンストラクターによるインスタンスの生成とは異なり、executor(つまり function(resolve, reject) {…})による明示的な resolve / reject が記述できない

そのため、executor で resolve / reject を指定する代わりに、then に登録するコールバック関数の戻り値(あるいは throw)によって resolve / reject が制御される

また、then メソッドが Promise インスタンスを返すということは、そのインスタンスからさらに then メソッドを呼び出すことができるため、.then().then().then() という形に then メソッドを連続させて記述できることを意味する。これについては後述する。

値を返した場合

then メソッドで登録したコールバック関数 onFulfilled, onRejected が呼び出された時に値を返した場合は、then メソッドが返す新しい Promise インスタンスは、その値で resolve される

onFulfilled が実行され、値が返された場合
onRejected が実行され、値が返された場合

この画像から、promise が resolve または reject された時に呼び出されたコールバック関数が値を返せば、then が返却した promise2 は即時に resolve されることが分かる。

onFulfilled が実行され、値が返された場合
onRejected が実行され、値が返された場合
何も返さなかった場合

then メソッドで登録したコールバック関数 onFulfilled, onRejected が呼び出された時に戻り値がない場合は、then メソッドが返す新しい Promise インスタンスは、undefined で resolve される。つまり undefined という値を返した場合と同じということだ。

onFulfilled が実行され、戻り値がなかった場合
例外を throw した場合
onRejected が実行され、戻り値がなかった場合

then メソッドで登録したコールバック関数 onFulfilled, onRejected が呼び出された時に例外が投げられた場合は、then メソッドが返す新しい Promise インスタンスは、throw した値で reject される

onFulfilled が実行され、例外が投げられた場合
onRejected が実行され、例外が投げられた場合
onFulfilled が実行され、例外が投げられた場合
onRejected が実行され、例外が投げられた場合
Promise のインスタンスを返した場合

then メソッドで登録したコールバック関数 onFulfilled, onRejected が呼び出された時に Promise インスタンスを返した場合は、then メソッドが返す新しい Promise インスタンスは、onFulfilled, onRejected が返す Promise インスタンスで制御される。つまり、onFulfilled, onRejected が返す Promise インスタンスが resolve, reject の決定権を持つように振る舞うことになる。

onFulfilled が実行され、返された Promise インスタンスが解決された場合

onFulfilled が実行され、返された Promise インスタンスが拒否された場合
onFulfilled が実行され、Promise インスタンスが返された場合
onRejected が実行され、返された Promise インスタンスが解決された場合
onRejected が実行され、返された Promise インスタンスが拒否された場合
onRejected が実行され、Promise インスタンスが返された場合
then メソッドの引数が関数でない場合

以前に、「then メソッドは2つの関数を引数で受け取る」と説明したが、実は引数が関数でない場合や引数が省略された場合もエラーにはならない。

then メソッドの引数を省略し、resolve した場合
then メソッドの引数を省略し、reject した場合

このように、then メソッドの引数が省略された場合や関数でない場合は、then メソッドが返す新しい Promise インスタンスは、then メソッドの呼び出し元のインスタンスと同じ変化を行う。

これは、then メソッドの引数が省略された場合や関数でない場合には、then メソッドの引数 onFulfilled, onRejected には、

onFulfilled = function(val) {
  return val;
}

onRejected = function(err) {
  throw err;
}

のような関数が JavaScript エンジンによって設定されるためだ。

then メソッドの引数を省略し、resolve した場合
then メソッドの引数を省略し、reject した場合

then メソッドのチェーン

then メソッドの呼び出しを連鎖させる

then メソッドの戻り値は新しい Promise インスタンスであるため、そのインスタンスにも当然 then メソッドによりコールバック関数を登録できる。そのため、次のように then メソッドの呼び出しを連鎖させる記述が可能となる。

new Promise(function(resolve, reject) {
  // executor
}).then(
  function(val) {
    // onFulfilled
  },
  function(err) {
    // onRejected
  }
).then(
  function(val) {
    // onFulfilled
  },
  function(err) {
    // onRejected
  }
).then(
  function(val) {
    // onFulfilled
  },
  function(err) {
    // onRejected
  }
)
then メソッドの呼び出しの連鎖
コールバック関数が値を返す場合の連鎖

then メソッドの連鎖がどのような意味を持つのか調べてみよう。まず、then メソッドで登録するコールバック関数が値を返す場合について見てみることにする。

new Promise(function(resolve) { // (1)
  resolve('H');
}).then(
  function(val) {   // (2)
    console.log(val)
    return val + 'E';
  }
).then(
  function(val) {   // (3)
    console.log(val)
    return val + 'L';
  }
).then(
  function(val) {
    console.log(val)
    return val + 'L';
  }
).then(
  function(val) {
    console.log(val)
    return val + 'O';
  }
).then(
  function(val) {
    console.log(val)
    return val + '!';
  }
)

// H
// HE
// HEL
// HELL
// HELLO

このサンプルにおいて、then メソッドの第二引数 onRejected は実行されることはないので省略した。

このサンプルの処理の流れは以下のようになる。

  1. まず executor が実行され、それにより resolve(‘H’) が実行され、最初に生成されるインスタンスは ‘H’ で解決(resolve)される。
  2. 最初に生成されるインスタンスが解決されたことにより、1回目のthen メソッドの第一引数に登録されたコールバック関数が実行される。このコールバック関数は resolve 関数から渡された ‘H’ を受け取り、コンソールに出力した後、’E’ を結合した ‘HE’ を返却する。
    そして値 ‘HE’ がコールバック関数から返却されたことにより、1回目の then メソッドの戻り値の Promise インスタンスは ‘HE’ で解決(resolve)される。
  3. 1回目の then メソッドの戻り値の Promise インスタンスが ‘HE’ で解決されたことにより、2回目のthen メソッドの第一引数に登録されたコールバック関数が実行される。このコールバック関数は呼び出し元のインスタンスの解決値である ‘HE’ を受け取り、コンソールに出力した後、’L’ を結合した ‘HEL’ を返却する。
    そして値 ‘HEL’ がコールバック関数から返却されたことにより、2回目の then メソッドの戻り値の Promise インスタンスは ‘HEL’ で解決(resolve)される。
  4. 2回目の then メソッドの戻り値の Promise インスタンスが ‘HE’ で解決されたことにより、… 省略
  5. … 以下省略

このように then メソッドのコールバック関数で値を返却した場合は、then メソッドが戻り値として生成・返却した Promise インスタンスが即時にその値で解決(resolve)されるため、その次に接続した then メソッドのコールバック関数もすぐに呼び出され値を引き継ぐことになる。

コールバック関数が値を返す場合の連鎖
コールバック関数が Promise インスタンスを返す場合の連鎖

次に then メソッドで登録するコールバック関数が Promise のインスタンスを返す場合について見てみよう。

new Promise(function(resolve) { // (1)
  resolve('H');
}).then(
  function(val) {   // (2)
    console.log(val);
    return new Promise(function(resolve) {
      setTimeout(function() {
        resolve(val + 'E');
      }, 1000)
    })
  }
).then(
  function(val) {   // (3)
    console.log(val);
    return new Promise(function(resolve) {
      setTimeout(function() {
        resolve(val + 'L');
      }, 1000)
    })
  }
).then(
  function(val) {
    console.log(val);
    return new Promise(function(resolve) {
      setTimeout(function() {
        resolve(val + 'L');
      }, 1000)
    })
  }
).then(
  function(val) {
    console.log(val);
    return new Promise(function(resolve) {
      setTimeout(function() {
        resolve(val + 'O');
      }, 1000)
    })
  }
).then(
  function(val) {
    console.log(val);
    return new Promise(function(resolve) {
      setTimeout(function() {
        resolve(val + '!');
      }, 1000)
    })
  }
)

// H
// HE     <--- H を出力してから1秒後
// HEL    <--- HE を出力してから1秒後
// HELL   <--- HEL を出力してから1秒後
// HELLO  <--- HELL を出力してから1秒後

このサンプルでは、then メソッドに登録するコールバック関数で、1秒後に解決される Promise インスタンスを生成し、それを返却するようにしている。

先述したように、then メソッドに登録したコールバック関数から Promise インスタンスが返却された場合は、そのインスタンスが then メソッドの返却するインスタンスの resolve / reject の決定権を持つようになる。

したがって上記サンプルの処理の流れは次のようになる。

  1. まず executor が実行され、それにより resolve(‘H’) が実行され、最初に生成されるインスタンスは ‘H’ で解決(resolve)される。
  2. 最初に生成されるインスタンスが解決されたことにより、1回目のthen メソッドの第一引数に登録されたコールバック関数が実行される。このコールバック関数は resolve 関数から渡された ‘H’ を受け取り、コンソールに出力した後、’E’ を結合した ‘HE’ で1秒後に解決(resolve)される Promise インスタンスを生成・返却する。
    コールバック関数から Promsie のインスタンが返却されたことにより、1回目の then メソッドの返却した Promise インスタンスは resolve / reject の決定権をコールバック関数の返却したインスタンスに支配されることになるが、まだ1秒経過していないため、”pending”(保留中)状態にある。
    1秒経過するとコールバック関数の返却した Promise インスタンスが ‘HE’ で解決(resolve)されるため、then メソッドの返却したインスタンスも ‘HE’ で解決される。
  3. 1回目の then メソッドの戻り値の Promise インスタンスが ‘HE’ で解決されたことにより、2回目のthen メソッドの第一引数に登録されたコールバック関数が実行される。このコールバック関数は呼び出し元のインスタンスの解決値である ‘HE’ を受け取り、コンソールに出力した後、’L’ を結合した ‘HEL’ で1秒後に解決(resolve)される Promise インスタンスを生成・返却する。
    … 省略
  4. … 以下省略

このように、then メソッドに登録したコールバック関数から Promise インスタンスが返却された場合は、then メソッド自身が生成・返却したインスタンスの解決・拒否は、コールバック関数の返却したインスタンスに委ねられるため、後続の then メソッドに登録したコールバック関数も実行を待つことになる。

つまり後続の then メソッドに登録されているコールバック関数は、その一つ前の then メソッドのコールバック関数による処理の完了(resolve / reject)を待ってから実行され、また resolve / reject に渡された値を引き継ぐことになる。

この性質を使えば、非同期処理を連続して実行させる処理は、then メソッドのチェーンで記述することができるようになり、実際上記のサンプルは setTimeout を使った非同期処理を連続させて実行させるものになっている。

コールバック関数が Promise インスタンスを返す場合の連鎖

catch メソッド

catch メソッドとは

次に、Promise のインスタンスが使用できる2つ目のメソッド 、catch メソッドを見ていこう。

new Promise(function(resolve, reject) {
  throw 'error'
})
.catch(
  function(arg) {
    console.log('onRejected:', arg);
  }
);

// onRejected: error

catch メソッドは、then メソッドの第二引数 onRejected と同様に、エラー時のコールバック関数の登録に使用するためのものであり、「.catch(onRejectd)」の実行は、「.then(undefined, onRejected)」を JavaScript エンジンの内部で呼び出しているだけに過ぎない。(参考:Promise.prototype.catch ( onRejected )

catch メソッドを使用する場合は、then メソッドの第一引数 onFulfilled に undefined を与えていることと同等なので、「then の引数が関数でない場合」で説明したように、catch メソッドの呼び出し元のインスタンスが解決(resolve)された場合は、catch メソッド自身が返すインスタンスも即時に解決され同じ値を格納する。

catch メソッドの呼び出し元のインスタンスが解決(resolve)された場合

一方で、catch メソッドの呼び出し元のインスタンスが拒否(reject)された場合は、catch メソッドに登録した第二引数 onRejectd が実行されることになる。

catch メソッドの呼び出し元のインスタンスが拒否(reject)された場合

Promise チェーンにおける catch メソッドの役割

then メソッドや catch メソッドを繋げたものを Promise チェーンと呼ぶ。Promise チェーンにおける catch メソッドの役割を考えてみよう。

catch メソッドが then メソッドの第一引数 onFulfilled に undefined を与えていることと同等であることを説明したが、その一方で then メソッドも実際に使用する際には第二引数 onRejected を省略して使うことが多い。

then(onFulfilled) は、then(onFulfilled, undefined) と同等であり、「then の引数が関数でない場合」で説明したように、呼び出し元のインスタンスが拒否(resolve)された場合は、then メソッド自身の返すインスタンスも同じ値で即時に拒否される。

then(onFulfilled) の呼び出し元のインスタンスが拒否(reject)された場合

したがって、第二引数を省略した then(onFulfilled) は次の性質を持つ。

  • 呼び出し元のインスタンスが解決(resolve)された時は、onFulfilled が実行される。
  • 呼び出し元のインスタンスが拒否(reject)された場合は、then が返却したインスタンスがその拒否された状態を引き継ぐ。

catch(onRejectd) メソッドはそれと対象的な次のような性質を持つ。

  • 呼び出し元のインスタンスが解決(resolve)された場合は、then が返却したインスタンスがその解決された状態を引き継ぐ。
  • 呼び出し元のインスタンスが拒否(reject)された時は、onRejectd が実行される。

これにより、Promise チェーン上のどこかでエラーが発生した場合は、then(onFulfilled) は後続にエラー(拒否された状態)を引き継がせて処理を任せるだけだが、catch() メソッドを後ろに接続しておけば、その拒否された状態を引き継いでエラー処理を行わせることができる。

new Promise(function(resolve, reject) {
  console.log('start');
  resolve();
}).then(function() {
  console.log('then1');
}).then(function() {
  console.log('then2');
  throw 'error';  // 例外を投げる
}).then(function() {
  console.log('then3');
}).then(function() {
  console.log('then4');
}).catch(function(e) {  // catch
  console.log('catch:', e)
})

// start
// then1
// then2
// catch: error
Promise チェーンにおける catch メソッドの役割

タイトルとURLをコピーしました