🐥

Promiseに関して

2022/02/21に公開

どうもフロントエンドエンジニアのoreoです。今回は、Promiseについて再整理します。

1 同期処理と非同期処理

ブラウザにおいて、JavaScriptは主にメインスレッド上で実行されます。処理はコールスタックに積まれ、後入れ先だし(LIFO)で、実行されています。同期処理では、一つの処理が完了するまでは、次の処理に移行しません。

一方、非同期処理は、処理がコールスタックから一時的に切り離され、コールバックキュー(※)に処理が格納されます。コールスタックに処理が積まれている状態では、コールバックキューに格納された処理は待ちの状態になります。コールスタックの処理が空になれば、コールバックキューに格納されている処理が、イベントループによって、先入れ先出し(FIFO)でコールスタックに積まれ処理されます

※コールバックキューについて

コールバックキューにはマクロタスク、マイクロタスクの2種類のキューがあります。

  • マクロタスク
    • タスクキューとも呼ばれる。
    • setTimeoutなど。
    • イベントループで順番が来ると、ひとつずつタスクを実行する。
  • マイクロタスク
    • ジョブキューとも呼ばれる。
    • Promiseなど。
    • イベントループで順番が来ると、全てのジョブを実行する

コールバックキュー、コールスタック、イベントループ等の詳細は下記ご参照ください。

JavaScriptがブラウザでどのように動くのか

2 コールバック地獄について

2-1 コールバック関数を用いて非同期処理の順番を制御する

同期処理のみの場合は、ex1のようにグローバルコンテキストにそって処理が実行され、メッセージが①→②→③の順で出力されます。

【ex1】

/**
 * 以下の順番で出力
 * ①りんごは何個?
 * ②りんごは1個
 * ③わかりました!
 */

function question(){
  console.log("①りんごは何個?");
}
function apple(num) {
  console.log(`②りんごは${num}`);
}
function ok(){
  console.log("③わかりました!");
}

question()
apple(1);
ok()

しかし、ex2のように、非同期処理のsetTimeoutを用いると、第二引数に渡す遅延時間を0としても、メッセージの出力順が①→③→②と、ex1と比べて②と③の順番が入れ替わります。

これは、setTimeoutの第一引数の処理がマクロタスクに積まれ、コールスタックの処理(ここでのquestion()ok())が終了した後に、呼ばれるためです。

【ex2】

/**
 * 以下の順番で出力
 * ①りんごは何個?
 * ③わかりました!    ※②と③の出力が入れ替わった!
 * ②りんごは1個
 */

function question(){
  console.log("①りんごは何個?");
}
function apple(num) {
  setTimeout(function () {      //非同期処理のsetTimeoutを使う
    console.log(`②りんごは${num}`);
  }, 0);                 //setTimeoutの第二引数には遅延時間を0として渡す
}
function ok(){
  console.log("③わかりました!");
}

question()
apple(1);
ok()

ここで、コールバック関数を使用すると、非同期処理setTimeoutを使用しても、ex1のような出力が可能です。具体的には、ex3のように、apple関数の第一引数にコールバック関数を設定し、コールバック関数にok関数を渡して実行すると、ex1と同じくメッセージが①→②→③の順番で出力されます。

このように、コールバック関数を用いると、非同期処理が絡んだ実装において、処理順を制御すること可能です

【ex3】

/**
 * 以下の順番で出力
 * ①りんごは何個?
 * ②りんごは1個
 * ③わかりました!
 */

function question() {
  console.log("りんごは何個?");
}
function apple(callback, num) {   //appleの第一引数にコールバック関数を設定
  setTimeout(function () {
    console.log(`りんごは${num}`);
    callback();            //appleの第一引数に渡したコールバック関数をここで実行
  }, 0);
}
function ok() {
  console.log("わかりました!");
}

question();
apple(ok, 1);              //okをappleの第一引数に渡して実行

2-2 コールバック関数を用いて、複数の非同期処理の順番を制御する

次に、コールバック関数を用いて、複数の非同期処理を順番に実行する場合を考えます。

ex4のように、apple関数を宣言したとします。apple関数では、第一引数にコールバック関数、第二引数にりんごの個数numをとります。apple(function (num) {},1); を実行すると、1秒後に「りんごは1個」と出力されます

【ex4】

/**
 * 以下が出力
 * りんごは1個
 */

function apple(callback, num) {
  setTimeout(function () {
    console.log(`りんごは${num}`);
    num += 1;
    callback(num);
  }, 1000);
}

apple(function (num) {}, 1);

「りんごは2個」までと出力させたい場合は、ex5のようにapple関数の第一引数に、apple(function (num) {},num); を与えると、1秒後に「りんごは1個」2秒後に「りんごは2個」と出力されます。この程度なら読み解くことが可能です。

【ex5】

/**
 * 1秒毎に以下の順番で出力
 * りんごは1個
 * りんごは2個
 */

//「りんごは2個」と出力させる
apple(function (num) {
  apple(function (num) {}, num);
}, 1);

しかし、「りんごは10個」まで出力させたい場合は、ex6のように冗長なコードとなり、パッと見で読み解くことが難しくなります

このようにコールバック関数を用いて非同期処理のチェーンを実装した場合、ネストが深くなり可読性が下がります。この現象がコールバック地獄と呼ばれるものです。

【ex6】

/**
 * 1秒毎に以下の順番で出力。「りんごは10個」まで出力される
 * りんごは1個
 *  ・
 *  ・
 * りんごは10個
 */
apple(function (num) {
  apple(function (num) {
    apple(function (num) {
      apple(function (num) {
        apple(function (num) {
          apple(function (num) {
            apple(function (num) {
              apple(function (num) {
                apple(function (num) {
                  apple(function (num) {}, num);
                }, num);
              }, num);
            }, num);
          }, num);
        }, num);
      }, num);
    }, num);
  }, num);
}, 1);

3 Promiseオブジェクトで非同期処理を制御する

Promiseを使用して非同期処理を実装すると、コールバック関数よりも、簡単でわかりやすく記載することができます。

3-1 Promiseの基本構文

new Promise(function(resolve,reject){})のように、二つの引数resolverejectを持ったコールバック関数function(resolve,reject){}new Promise()に渡して、Promiseオブジェクトをインスタンス化します。

Promiseオブジェクトでは、主に.thenメソッド、.catchメソッド、.finallyメソッドを使います(ex7~ex10)。

3-1-1 .thenメソッド

ex7のようにnew Promiseのコールバック関数の中で、resolveが実行されると、.thenメソッドに渡したコールバック関数が実行されます。ここで.thenメソッドに渡したコールバック関数では、resolveに渡した引数(ここでは「resolveです!」)を取得することができます。

【ex7】

/**
 * 以下が出力
 * resolveです!
 * 処理は終了です!
 */

new Promise(function (resolve, reject) {
  resolve("resolveです!");
})
  .then(function (data) {
    console.log(data);
  })
  .catch()
  .finally(function () {
    console.log("処理は終了です!");
  });

.thenメソッドのチェーンを実装したい場合、一つ目の.thenメソッド内で二つ目の.thenメソッドに渡したいデータをreturnすると、二つ目の.thenメソッドでそれを受け取ることができます(ex8)。

【ex8】

/**
 * この場合以下が出力される。
 * resolveです!
 * resolveです!
 * 処理は終了です!
 */

new Promise(function (resolve, reject) {
  resolve("resolveです!");
})
  .then(function (data) {
    console.log(data);
    return data          //ここでデータをreturnする。
  })
  .then(function (data) {
    console.log(data);  //データを取得できる
  })
  .catch()
  .finally(function () {
    console.log("処理は終了です!");
  });

3-1-2 .catchメソッド

また、ex9のようにnew Promise()のコールバック関数の中で、rejectが実行されると、.catchメソッドに渡したコールバック関数が実行されます。.catchメソッドに渡したコールバック関数では、rejectに渡した引数(ここでは「rejectです!」)を取得することができます。rejectは、なんらかのエラーが発生したときに、それをPromiseに通知します。

【ex9】


/**
 * 以下が出力
 * rejectです!
 * 処理は終了です!
 */

new Promise(function (resolve, reject) {
  reject("rejectです!");
})
  .then()
  .catch(function (data) {
    console.log(data);
  })
  .finally(function () {
    console.log("処理は終了です!");
  });

.thenメソッドの中で、エラーを検知し、.catchメソッドに処理を移行したい場合は、ex10のように.thenメソッドの中で、throw new Errorを実行します。そうすると.catchメソッドに処理を移行できます。

【ex10】

/**
 * この場合以下が出力される。
 * resolveです!
 * throw new Errorでエラーを検知!
 * 処理は終了です!
 */

new Promise(function (resolve, reject) {
  resolve("resolveです!");
})
  .then(function (data) {
    console.log(data);
    throw new Error();
  })
  .catch(function (val) {
    console.log("throw new Errorでエラーを検知!");
  })
  .finally(function () {
    console.log("処理は終了です!");
  });

3-1-3 .finallyメソッド

.finallyメソッドのコールバック関数に記載した処理は、.thenメソッド、.catchメソッドのどちらが呼ばれたかに関わらず実施されるので、共通の終了処理を記載します。.finallyメソッドのコールバック関数には引数を渡すことができないので注意が必要です。

尚、new Promise()のコールバック関数は同期的に処理を行い、.then()メソッド、.catch()メソッド、.finally()メソッドは、非同期的に処理が行われます。

3-2 Promiseチェーン

それではex4~ex6でコールバック関数を用いて実装した非同期処理のチェーンをPromiseを用いて置き換えてみます。

ex11のように、apple関数内で、Promiseのインスタンスをreturnします。new Promiseに渡すコールバック関数内では、setTimeoutの処理を書き、setTimeout内で、resolveします。

【ex11】

function apple(num) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`りんごは${num}`);
      num += 1;
      resolve(num);
    }, 1000);
  });
}

ex11で宣言したapple関数に対して、ex12のように.thenメソッドを繋げ、.thenメソッド内でPromiseオブジェクトをreturnすると、非同期処理のチェーンを実装できます。

ex12の出力結果は、ex6と同じですが、Promiseを用いた方が、直感的でわかりやすくなったかと思います。

【ex12】

/**
 * 1秒毎に以下の順番で出力。「りんごは10個」まで出力される
 * りんごは1個
 *  ・
 *  ・
 * りんごは10個
 */

apple(1)
  .then(function (num) {
    return apple(num);
  })
  .then(function (num) {
    return apple(num);
  })
  .then(function (num) {
    return apple(num);
  })
  .then(function (num) {
    return apple(num);
  })
  .then(function (num) {
    return apple(num);
  })
  .then(function (num) {
    return apple(num);
  })
  .then(function (num) {
    return apple(num);
  })
  .then(function (num) {
    return apple(num);
  })
  .then(function (num) {
    return apple(num);
  })

また、ex13のように、無名関数を簡略化できるアロー関数を用いて、ex12を置き換えるとさらに簡略化できます。

【ex13】

/**
 * アロー関数を使用するとよりわかりやすく「りんごは10個」まで出力できる
 * 以下の実装は、ex12と同じ出力結果になる
 */
apple(1)
  .then((num) => apple(num))
  .then((num) => apple(num))
  .then((num) => apple(num))
  .then((num) => apple(num))
  .then((num) => apple(num))
  .then((num) => apple(num))
  .then((num) => apple(num))
  .then((num) => apple(num))
  .then((num) => apple(num))

3-3 その他、Promiseで使うメソッド

3-3-1 .allメソッド

.allメソッドを使うと、非同期処置を並列で実装することが可能です。

ex14のように、.allメソッドの引数にPromiseインスタンスを含んだ反復可能オブジェクトを渡します(この場合は、配列を渡しています)。配列の中にあるapple(1)apple(2)apple(3)の処理が全て終了すると、.thenのメソッドが実行されます。因みに、allメソッドの引数に渡したPromiseインスタンスのどれか一つがrejectされた場合は、.catchの処理が実行されます。

尚、.thenのコールバック関数の引数には、.allメソッドの反復可能オブジェクト内に渡したPromiseインスタンスのresolveの実引数が配列として渡されます。

【ex14】

/**
 * 以下のように出力される
 * りんごは1個
 * りんごは2個
 * りんごは3個
 * 渡ってきたvalは  [1, 2, 3]
 * 並列処理終わり
 */

function apple(num) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`りんごは${num}`);
      resolve(num);
    }, 1000 * num);
  });
}

Promise.all([apple(1), apple(2), apple(3)])
	.then((val) => {
  console.log("渡ってきたvalは", val);    //このvalは[1, 2, 3]になる
  console.log("並列処理終わり");
});

3-3-2 .raceメソッド

.raceメソッドでは、.allメソッドと同様に、引数にPromiseインスタンスを含んだ反復可能オブジェクトを渡します。しかし、.allメソッドとは異なり、引数に渡したPromiseインスタンスのどれか一つの処理が終了すれば、次の.thenメソッドに処理が移行します。

ex15では、.raceメソッドに、apple(1)apple(2)apple(3)を含む配列を渡します。この場合、apple(1)の処理が一番早く終了するので、処理が終了した段階で、次の.thenメソッドに移行します。因みに、一番早く終了した処理がrejectされた場合は、.catchの処理が実行されます。

尚、.thenのコールバック関数の引数には、.raceメソッドの反復可能オブジェクト内に渡したPromiseインスタンスで一番早く処理が終わったresolveの実引数が渡されます。

【ex15】

/**
 * 以下のように出力される
 * りんごは1個
 * 渡ってきたvalは1
 * 並列処理終わり
 * りんごは2個
 * りんごは3個
 */

function apple(num) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log(`りんごは${num}`);
      resolve(num);
    }, 1000 * num);
  });
}

Promise.race([apple(1), apple(2), apple(3)])
	.then((val) => {
  console.log("渡ってきたvalは", val);
  console.log("並列処理終わり");
});

3-3-3 その他メソッド

この他に、.allSettledメソッド、anyメソッドがありますが、詳細は👇をご覧ください。

Promise - JavaScript | MDN

4 最後に

あまり馴染みが無かったコールバックキュー、イベントループなどに関して調べることができ有意義でした。次回は、Promiseをさらに直感的に記述できるasyncawaitや例外処理について記載します。

5 参考

JavaScriptがブラウザでどのように動くのか

Promise - JavaScript | MDN

JavaScript Promiseの本

Discussion