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 の状態チェックと当該コールバック関数の呼び出しは、実行中の関数が抜けた後に裏で行われるから。