JavaScript の Promise がよく分からないので調べてみた
JavaScript の Promise を使った非同期処理がよく分からないので調べてみました。
(低レイヤーの話ではありません。)
動作確認環境
- Windows 10 Home 21H2, 64bit
- Microsoft Edge 97
Promise の動作に対する疑問
Promise の説明でありがちなサンプルプログラムです。
function test()
{
alert('#1');
new Promise((resolve) => {
alert('#2');
resolve('hello');
alert('#3');
}).then((result) => {
alert('#5:' + result);
});
alert('#4');
}
実行すると、「#1」「#2」「#3」「#4」「#5:hello」と表示されます。
最初、次の点が分かりませんでした。
- new Promise() の引数内の resolve ってどこから出てきたの?
- なぜ引数 resolve を関数名として使っているの?
- resolve 関数の引数ってだれがどこで決めたの?
- then() の引数内の result ってどこから出てきたの?
- resolve 関数の引数がなぜ then() の result に入るの?
- then() 内の「#5」より後ろの「#4」が先に表示されるってどういうこと?
コールバック関数を記述している
いろいろ調べた結果、「Promise() や then() の引数に書くのは、実行したいプログラムではなく、システムが呼び出すコールバック関数の定義であること」が明確に認識できていなかったのが、混乱した原因だと気付きました。
Promise() や then() の引数は次のようになります。
new Promise(コールバック関数の定義)
.then(コールバック関数の定義);
コールバック関数の定義ですから、たとえば、「alert(‘Hello’)」ではなく、「() => alert(‘Hello’)」や「function () { alert(‘Hello’) }」などと書きます。
また、コールバック関数ですから、引数の数や意味は自分で決めるのではなく、システム側(コールバック関数を呼び出す側)が決めたものに従うことになります。
Promise() に指定するコールバック関数の第 1 引数には、数値や文字列ではなく、「システムが用意しているリゾルブ用関数へのポインタ」がセットされる、と決められています(JavaScript ではポインタとは言わないようですが)。コールバック関数内でこのリゾルブ用関数を呼び出すと、当該 Promise はリゾルブ状態(fulfilled 状態)に遷移します。
then() に指定するコールバック関数の第 1 引数には「リゾルブ用関数の引数で指定された値」がセットされる、と決められています。
以上から、引数を詳しく書くと次のようになります。
new Promise(
コールバック関数の定義。
第 1 引数には「システムが持つリゾルブ用関数へのポインタ」がセットされる。
このリゾルブ用関数を呼び出すと、リゾルブ状態に遷移する。)
.then(
コールバック関数の定義。
第 1 引数には「リゾルブ用関数の引数で指定された値」がセットされる。);
コールバック関数が呼び出されるタイミング
Promise() 内と then() 内のコールバック関数は、呼び出されるタイミングが異なります。
Promise() 内の関数は即座に呼び出されますが、then() 内の関数は即座には呼び出されず、現在実行中の(then の書いてある)関数を抜けた後、かつ、Promise がリゾルブされたタイミングで呼び出されます。
setTimeout() がタイムアウト時に呼び出されるコールバック関数を登録して次の行に進むのに似ていて、then() はリゾルブ用関数が呼び出されたときに呼び出されるコールバック関数を登録して次の行に進みます。
以上から、引数をさらに詳しく書くと次のようになります。
new Promise(
即座に呼び出されるコールバック関数の定義。
第 1 引数には「システムが持つリゾルブ用関数へのポインタ」がセットされる。
リゾルブ用関数を呼び出すと、リゾルブ状態に遷移する。)
.then(
現在実行中の関数を抜けた後、
かつ、プロミスがリゾルブされたときに呼び出されるコールバック関数の定義。
第 1 引数には「リゾルブ用関数の引数で指定された値」がセットされる。);
Promise クラスを実装する
この動作を実現する Promise クラスを書いてみました。
class Promise
{
// コンストラクタ
constructor(promiseFunc)
{
// 状態の初期化
this.promiseStatus = "pending";
// Promise() の引数で渡されたコールバック関数を即座に呼び出す。
// このとき、第 1 引数に本クラスが持つリゾルブ用関数をセットする。
promiseFunc(this.pmResolve.bind(this));
}
// リゾルブ用関数
pmResolve(param)
{
// リゾルブ状態に遷移
this.promiseStatus = "fulfilled";
// リゾルブ用関数の引数で指定された値を保持する
this.promiseValue = param;
}
then(thenFunc)
{
// then() の引数で渡されたコールバック関数を保持する。
this.thenFunc = thenFunc;
// 「プロミスがリゾルブ状態だったら thenFunc 関数を呼び出す」関数を呼び出す。
// 直接呼び出さず、setTimeout() を介しているのは、
// 現在実行中の関数を抜けた後に呼び出されるようにするため。
setTimeout(() => processPromise(this), 0);
return this;
}
}
function processPromise(p)
{
switch (p.promiseStatus)
{
// プロミスが初期状態だったら、時間をおいて再チェックする。
case "pending":
setTimeout(() => processPromise(p), 1000);
break;
// プロミスがリゾルブ状態だったら、thenFunc 関数を呼び出す。
// このとき、第 1 引数には「リゾルブ用関数の引数で指定された値」をセットする。
case "fulfilled":
p.thenFunc(p.promiseValue);
break;
}
}
new Promise() の引数に指定した関数は、constructor(promiseFunc) によって呼び返されます。このとき、第 1 引数に「リゾルブ用関数へのポインタ」がセットされます。
第 1 引数を介して受け取ったリゾルブ用関数を呼び出すと、pmResolve(param) メソッドが呼び出され、Promise インスタンスはリゾルブ状態に遷移します。このとき、リゾルブ用関数の引数で指定した値が保持されます。
then() メソッドを呼び出すと、引数に指定した関数が保持され、一旦、処理が終了します。その後、Promise インスタンスの状態をチェックし、リゾルブ状態になっていたら、then() の引数に指定した関数が呼び出されます。このとき、第 1 引数にリゾルブ用関数の引数で指定した値がセットされます。
この Promise クラスは簡易的なものであり、then() 内の関数の呼び出しにタイムラグがあったり reject も catch も then の連鎖もなかったりしますが、それでも Promise を利用する側と提供する側の両方を書いてみることで、ある程度 Promise の流れが理解できた気がします。
Promise の動作に対する疑問への回答
最初の疑問への回答です。
- new Promise() の引数内の resolve ってどこから出てきたの?
→ コールバック関数の引数名。名前は何でもよい。
- なぜ引数 resolve を関数名として使っているの?
→ resolve 値は数値や文字列ではなく、いわば関数へのポインタだから。
- resolve 関数の引数ってだれがどこで決めたの?
→ Promise クラスがリゾルブ用関数の引数を決めた。
- then() の引数内の result ってどこから出てきたの?
→ コールバック関数の引数名。名前は何でもよい。
- resolve 関数の引数がなぜ then() の result に入るの?
→ Promise クラスが、resolve 関数の引数を受け取った後、then() の result に受け渡しているから。
- then() 内の「#5」より後ろの「#4」が先に表示されるってどういうこと?
→ then() はコールバック関数を登録しているだけであり、Promise の状態チェックと当該コールバック関数の呼び出しは、実行中の関数が抜けた後に裏で行われるから。