JavaScriptの非同期処理を理解する その2 〜Promise編〜

こんにちは!小田島です。前回の「コールバック編」を納稿したとき、アイキャッチ画像はJavaScriptのロゴにタイトルの文字を入れただけというとんでもないやっつけ仕事だったのですが、さくナレ編集部の方に「シリーズで理解が深まる」というすごい煽り文句がついたとてもカッコいい画像に差し替えていただきました。アイキャッチ詐欺にならないようにがんばります。

非同期処理シリーズの2回目はPromiseについての説明です。前回はコールバックについて説明しましたが、Promiseを使うことでどのように便利になったのでしょうか。それでは見ていきましょう!今回はコードが多いのでかなり分量が多く感じられるかもしれませんが、実際はあまり大したことはありません。

今回の目的

今回説明するのはPromiseです。これはFutureパターン(Promiseパターン)というデザインパターンの一種で、ECMAScript6(ES2015)で標準化された組み込みのクラスです。このPromiseを使うことで、前回のコールバック方式で説明した以下のような問題が解決されます。

  • ネストが深くなる問題(コールバック地獄)
  • 複数の非同期処理の待ち合わせの問題
  • 非同期処理で例外を投げるときの問題

今回はPromiseを理解するために、Promiseの使い方や仕組みを見ながら基本的なオレオレPromiseを作ってみます。名前は・・・そうですね、Yakusokuとしましょうか。

目的はあくまでPromiseの理解なので、ES2015に規定されているPromiseの完全な実装は目指しません。実際に作るのは雰囲気が掴める程度のなんちゃってPromiseなので、未実装のメソッドや規格に厳密に従っていないメソッドがあるのはご了承ください。ソースコードはこちらにあります。

また、わかりやすさを優先するためにあえて冗長な書き方をしたり、非効率な書き方をしている場所もあります。大事な事なのでもう一度言いますが、あえてやってます。決してクソコードの言い訳じゃないです。そこんとこ勘違いしないように。

余談ですが、昔から音楽が好きで、小学生の時に「約束」という名前の曲を作ったことがあります。世にあふれている同名の曲とは一切関係ありません。今まで作った曲の中でも1、2を争うくらいになかなかの自信作なんですが、残念ながらコンピューターミュージックのスキルがないので一切世に出ることなく今でも脳内で眠っています。その曲が本記事の執筆中に脳内でリピート再生されています。

え?技術関係ない?だから余談と言ったでしょう。

Promiseオブジェクト

Promiseを使った非同期処理の関数は、コールバック関数を引数に取るのではなくPromiseオブジェクトを返します。「今は値を返せないけどあとでちゃんと返すよ」と約束するオブジェクトのことです。

返された値の取り出し方は後ほど説明します。

状態

Promiseオブジェクトは3つの内部状態を持ちます。

  • pending(保留): まだ非同期処理は終わっていない(成功も失敗もしていない)
  • fulfilled(成功): 非同期処理が正常に終了した
  • rejected(拒否): 非同期処理が失敗した

初期状態はpendingで、一度fulfilledまたはrejectedになったらそれ以降は状態は変わらず、非同期処理の終了時に返す値もそれ以降は変わりません。これはとても重要なので、しっかり理解してください。

早速ここまでの仕様を実装してみましょう。

class Yakusoku {
  constructor() {
    this.state = "pending"; // 内部状態; pending / fulfilled / rejected
  }
}

わざわざ書くほどのことじゃありませんでしたね。

コンストラクター

Promiseのコンストラクターは、関数を引数に取ります。この関数は以下の特徴があります。

  • その関数は2つの関数resolve, reject)を引数に取る
    • 1番目の関数(resolve)に引数を渡して実行すると状態がfulfilledになり、引数の値がPromiseオブジェクトが保持する値になる
    • 2番目の関数(reject)に引数を渡して実行すると状態がrejectedになり、引数の値がPromiseオブジェクトが保持する値になる
  • 関数が例外を投げた場合も状態がrejectedになり、投げた値がPromiseオブジェクトが保持する値になる
    • throwする値をrejectedに渡して実行した時と同じ

コンストラクターが関数を引数に取って、その関数がさらに2つの関数を引数に取るので少しややこしいですね。例えば、指定したファイルの中身を読み取る関数fs.readFile()をPromise化する場合は以下のようになります。

function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, (err, data) => {
      if (err) {
        reject(err); // 失敗: 内部状態をrejectedにする
      }
      else {
        resolve(data); // 成功: 内部状態をfulfilledにする
      }
    });
  });
}

コンストラクター部分の実装は以下のような感じでしょうか。前回との差分はこちらです。

class Yakusoku {
  constructor(func) {
    this.state = "pending"; // 内部状態; pending / fulfilled / rejected
    this.resolvedValue = null; // resolve()で渡された値を保持
    this.rejectedValue = null; // reject()で渡された値を保持

    const resolve = (resolvedValue) => {
      if (this.state !== "pending") {
        return; // 内部状態の変更は一度だけ
      }
      this.state = "fulfilled";
      this.resolvedValue = resolvedValue;
    };
    const reject = (rejectedValue) => {
      if (this.state !== "pending") {
        return; // 内部状態の変更は一度だけ
      }
      this.state = "rejected";
      this.rejectedValue = rejectedValue;
    };

    try {
      func(resolve, reject);
    }
    catch (err) {
      // 例外が発生したらrejectedにする
      reject(err);
    }
  }
}

新しく追加されたプロパティーのresolvedValuerejectedValueは後で使います。内部状態は一度確定したらそれ以降変化しないことから、実際にはこれらのプロパティーはどちらか1つしか使われないので1つのプロパティーでカバーできるのですが、1つのプロパティーに複数の意味があるとややこしくなるので、わかりやすさを優先して2つに分けています。

then(), catch()

いよいよ非同期処理の結果を取り出す段階です。Promiseオブジェクトのキモであるthen()catch()という2つのメソッドについて説明します。

概要

then()2つの関数を引数に取ります。Promiseの状態がfulfilledになったら1番目の関数が、rejectedになったら2番目の関数が実行されます。

先ほどのreadFilePromise()は次のように使います。

readFilePromise("/etc/passwd")
  .then(
    (data) => {
      // 読み出しに成功したらresolve()に渡した値が引数として渡される
      console.log("OK", data);
    },
    (err) => {
      // 読み出しに失敗するか fs.readFile() 自体が例外を投げたら
      // reject()に渡した値が引数として渡される
      console.log("error", err);
    }
  );

実行すると、/etc/passwdの中身が16進数で表示されます。Windowsにはこのファイルはないので、このコードをWindows上で動かす場合はWSLを使うか、別の適当なファイル名に置き換えてください。

then()の1番目の引数が関数でなければidentity function(入力値をそのまま返す関数)が、2番目の引数が関数でなければthrower function(入力値を例外として投げる関数)が代わりに使われます。そしてcatch()は1番目の引数にidentity functionを指定したthen()と同じです。

実装はこんな感じでしょうか(コンストラクター部分は省略します)。前回との差分はこちらです。

class Yakusoku {
  then(onFulfilled, onRejected) {
    if (typeof onFulfilled !== "function") {
      onFulfilled = identity;
    }
    if (typeof onRejected !== "function") {
      onRejected = thrower;
    }

    if (this.state === "fulfilled") {
      onFulfilled(this.resolvedValue);
    }
    if (this.state === "rejected") {
      onRejected(this.rejectedValue);
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected); // 1番目の引数をidentity functionにする
  }
}

function identity(value) { // identity function
  return value;
}

function thrower(err) { // thrower function
  throw err;
}

では、これまで作ったYakusokuクラスを合体させて、先ほどのreadFilePromise()内のPromiseをYakusokuに置き換えてみましょう。

class Yakusoku {
  constructor(func) {
    this.state = "pending"; // 内部状態; pending / fulfilled / rejected
    this.resolvedValue = null; // resolve()で渡された値を保持
    this.rejectedValue = null; // reject()で渡された値を保持

    const resolve = (resolvedValue) => {
      if (this.state !== "pending") {
        return; // 内部状態の変更は一度だけ
      }
      this.state = "fulfilled";
      this.resolvedValue = resolvedValue;
    };
    const reject = (rejectedValue) => {
      if (this.state !== "pending") {
        return; // 内部状態の変更は一度だけ
      }
      this.state = "rejected";
      this.rejectedValue = rejectedValue;
    };

    try {
      func(resolve, reject);
    }
    catch (err) {
      // 例外が発生したらrejectedにする
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    if (typeof onFulfilled !== "function") {
      onFulfilled = identity;
    }
    if (typeof onRejected !== "function") {
      onRejected = thrower;
    }

    if (this.state === "fulfilled") {
      onFulfilled(this.resolvedValue);
    }
    if (this.state === "rejected") {
      onRejected(this.rejectedValue);
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected); // 1番目の引数をidentity functionにする
  }
}

function identity(value) { // identity function
  return value;
}

function thrower(err) { // thrower function
  throw err;
}


// 以下テスト
const fs = require("fs");

function readFileYakusoku(path) {
  return new Yakusoku((resolve, reject) => {
    fs.readFile(path, (err, data) => {
      if (err) {
        reject(err); // 失敗: 内部状態をrejectedにする
      }
      else {
        resolve(data); // 成功: 内部状態をfulfilledにする
      }
    });
  });
}

readFileYakusoku("/etc/passwd")
  .then(
    (data) => {
      // 読み出しに成功したらresolve()に渡した値が引数として渡される
      console.log("OK", data);
    },
    (err) => {
      // 読み出しに失敗するか fs.readFile() 自体が例外を投げたら
      // reject()に渡した値が引数として渡される
      console.log("error", err);
    }
  );

これを実行してみると・・・あれ?何も表示されずに終了してしまいます。どこがおかしいのでしょうか。

then()を呼び出した時点でまだpending状態だった場合

上の実装のthen()メソッドをよく見るとわかりますが、この実装ではthen()呼び出した時点で状態がfulfilledかrejectedでないと渡した関数が一切実行されません。

つまり、then()を呼び出した時点でpendingだった場合、状態が変わった時点で関数を実行するように書き換える必要があります。渡された関数を記録しておいて、状態が変わったときに実行しましょう。前回との差分はこちらです。

class Yakusoku {
  constructor(func) {
    this.state = "pending"; // 内部状態; pending / fulfilled / rejected
    this.resolvedValue = null; // resolve()で渡された値を保持
    this.rejectedValue = null; // reject()で渡された値を保持
    this.thenFunctions = []; // then()に渡された関数を保持

    const resolve = (resolvedValue) => {
      if (this.state !== "pending") {
        return; // 内部状態の変更は一度だけ
      }
      this.state = "fulfilled";
      this.resolvedValue = resolvedValue;

      // then()に渡された関数を全て実行
      for (const thenFunction of this.thenFunctions) {
        thenFunction.onFulfilled(resolvedValue);
      }
    };
    const reject = (rejectedValue) => {
      if (this.state !== "pending") {
        return; // 内部状態の変更は一度だけ
      }
      this.state = "rejected";
      this.rejectedValue = rejectedValue;

      // then()に渡された関数を全て実行
      for (const thenFunction of this.thenFunctions) {
        thenFunction.onRejected(rejectedValue);
      }
    };

    try {
      func(resolve, reject);
    }
    catch (err) {
      // 例外が発生したらrejectedにする
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    if (typeof onFulfilled !== "function") {
      onFulfilled = identity;
    }
    if (typeof onRejected !== "function") {
      onRejected = thrower;
    }

    if (this.state === "pending") {
      // pendingなら後で呼び出すので関数を記録しておく
      this.thenFunctions.push({ onFulfilled, onRejected });
    }
    if (this.state === "fulfilled") {
      onFulfilled(this.resolvedValue);
    }
    if (this.state === "rejected") {
      onRejected(this.rejectedValue);
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected); // 1番目の引数をidentity functionにする
  }
}

function identity(value) { // identity function
  return value;
}

function thrower(err) { // thrower function
  throw err;
}

そろそろコード量が多くなってきたので変更部分が分かりづらいですが、前回との差分で確認してください。

これで、時間がかかる処理でも状態が変更された時点で関数が実行されるようになりました。そして/etc/shadowのようにアクセス権のないファイルを読み出そうとすると、then()の2番目の関数が実行されるのがわかると思います。

この実装を見てわかるとおり、then()メソッドを複数回呼ぶと、状態がfulfilledになったときに渡した関数が全て実行されます。例えば以下のようなコードを書くと、ファイルの内容が2回コンソールに出力されます。

const p = readFileYakusoku("/etc/passwd");
p.then((data) => console.log(data));
p.then((data) => console.log(data));

 

「うーん、仕組みと使い方はなんとなくわかったけど、コールバックと比べて何が便利になったの?むしろルールが増えて分かりにくくなってるんだけど?」

実はまだ大事なところを説明していません。then()の最大の特徴を次に説明します。

メソッドチェーン

実はthen()/ catch()は、引数で渡された関数の戻り値から新たにPromiseオブジェクトを作り、そのオブジェクトを返します。そのため以下のようなメソッドチェーンが可能です。

readFilePromise("/etc/passwd")
  .then((data) => { // ①
    console.log("OK", data);
    return readFilePromise("/etc/group");
  })
  .then((data) => { // ②
    console.log("OK", data);
    return readFilePromise("/etc/shadow"); // アクセス権のないファイルを指定
  })
  .catch((err) => { // ③
    console.log("error", err);
  });

これを実行すると、最初に①で/etc/passwdの内容が、次に②で/etc/groupの内容が、最後に③で/etc/shadowを読めないというエラーが表示されます。

処理の流れは以下のとおりです。

Promiseメソッドチェーン

このコードは非同期を直列に繋げているのにネストが深くなっていないことがお分かりいただけますか?前回のコールバックを使ったコードと見比べてください。コールバック方式は直列に繋げれば繋げるほどネストが深くなりますが、Promiseを使ったコードはどれだけ繋げてもこれ以上ネストは深くなりません。これがPromiseを使う1つ目の利点です。

エラー処理

では次に、最初のreadFilePromise()の引数を/etc/passwdから/etc/shadowに変えてみるとどうなるでしょうか。

readFilePromise("/etc/shadow") // アクセス権のないファイルを指定
  .then((data) => { // ①
    console.log("OK", data);
    return readFilePromise("/etc/group");
  })
  .then((data) => { // ②
    console.log("OK", data);
    return readFilePromise("/etc/passwd");
  })
  .catch((err) => { // ③
    console.log("error", err);
  });

実行結果は以下のとおりです。

error { [Error: ENOENT: no such file or directory, open '/etc/shadow']
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: '/etc/shadow' }

最初の時点でエラーが出ると、2つあるthen()(①②)をすっ飛ばして③のcatch()が実行されました。

つまりこういう挙動です。

  • 非同期処理が正常に終わったら、次のthen()に処理が移る
  • 非同期処理が失敗したら、catch()が見つかるまでthen()の処理を飛ばす

Promiseメソッドチェーン: エラーが発生すると、ハンドラーで処理されるまでthen()がパススルーされる

この挙動、どこかで見覚えがありませんか?そう、これはまさにtry-catch構文の挙動です。try-catch構文は、throwに遭遇すると残りの処理をすっ飛ばしてcatchに処理が移ります。それと同じように、Promiseの内部状態がrejectedになったらcatch()が見つかるまでthen()の処理をすっ飛ばします。

さらに、then()catch()のコールバック関数から例外を投げた場合catch()が見つかるまでthen()の処理を飛ばします。つまり以下のコードを実行すると、2番目のthen()が飛ばされてcatch()に処理が移ります。ますますtry-catchに近い挙動ですね。

readFilePromise("/etc/passwd")
  .then((data) => { // ①
    console.log("OK", data);
    throw new Error("░▒▓▇▅▂\(‘ω’)/▂▅▇▓▒░ うおあああああああああああ!!!!");
  })
  .then((data) => { // ②
    console.log("OK", data);
    return readFilePromise("/etc/shadow"); // アクセス権のないファイルを指定
  })
  .catch((err) => { // ③
    console.log("error", err);
  });

Promiseメソッドチェーン: コールバック関数から例外が投げられたらreject扱いになる

ちなみに上の文章で「then()が飛ばされる」という表現がありますが、この表現に疑問を持った方は鋭い。

そう、正確にはthen()実行自体が飛ばされているわけではありません。途中のthen()は常に実行されていますが、引数に渡した関数が実行されていない(then()が「これは呼び出す必要がない」と判断している)だけです。細かい表現ですが、Promiseの仕組みを理解するときには大事なので誤解をなさらぬよう。

もう少し詳しいthen()の説明

先ほど、then()について以下のように説明しました。

引数で渡された関数の戻り値から新たにPromiseオブジェクトを作り、そのオブジェクトを返します

もう少し正確に説明すると、こういう挙動です。

  • 引数に渡した関数の戻り値がPromiseオブジェクトの場合はそのオブジェクトをそのまま返す
  • そうでなければ戻り値をPromiseで包んで返す

これを実装するために、以下のようなヘルパー関数を作ります。

function isThenable(value) {
  if (value === null || value === undefined) {
    return false;
  }
  return typeof value.then === "function";
}

function wrapWithYakusoku(value) {
  if (isThenable(value)) {
    return value;
  }
  return new Yakusoku((resolve) => {
    resolve(value);
  });
}

thenableという概念が出てきました。難しそうに見えますが、単に「thenできる」、つまり「then()メソッドを持っている」という意味で、「Promiseオブジェクトである」とほぼ同義です。

wrapWithYakusoku()では、与えられた引数がthenable(≒Promiseオブジェクト)なら引数をそのまま返し、そうでなければYakusokuオブジェクトで包んで返します。

これらのヘルパー関数を使ってYakusokuクラスを書き直すと、最終的には以下のようになります。前回との差分はこちらです。

class Yakusoku {
  constructor(func) {
    this.state = "pending"; // 内部状態; pending / fulfilled / rejected
    this.resolvedValue = null; // resolve()で渡された値を保持
    this.rejectedValue = null; // reject()で渡された値を保持
    this.thenFunctions = []; // then()に渡された関数を保持

    const resolve = (resolvedValue) => {
      if (this.state !== "pending") {
        return; // 内部状態の変更は一度だけ
      }
      this.state = "fulfilled";
      this.resolvedValue = resolvedValue;

      // then()に渡された関数を全て実行
      for (const thenFunction of this.thenFunctions) {
        try {
          const ret = thenFunction.onFulfilled(resolvedValue);
          if (isThenable(ret)) {
            // 戻り値がYakusokuオブジェクトならthen()に渡す
            ret.then(thenFunction.resolve, thenFunction.reject);
          }
          else {
            // Yakusokuオブジェクトでなければfulfilled
            thenFunction.resolve(ret);
          }
        }
        catch (err) {
          // 例外が発生したらrejected
          thenFunction.reject(err);
        }
      }
    };
    const reject = (rejectedValue) => {
      if (this.state !== "pending") {
        return; // 内部状態の変更は一度だけ
      }
      this.state = "rejected";
      this.rejectedValue = rejectedValue;

      // then()に渡された関数を全て実行
      for (const thenFunction of this.thenFunctions) {
        try {
          const ret = thenFunction.onRejected(rejectedValue);
          if (isThenable(ret)) {
            // 戻り値がYakusokuオブジェクトならthen()に渡す
            ret.then(thenFunction.resolve, thenFunction.reject);
          }
          else {
            // Yakusokuオブジェクトでなければfulfilled
            thenFunction.resolve(ret);
          }
        }
        catch (err) {
          // 例外が発生したらrejected
          thenFunction.reject(err);
        }
      }
    };

    try {
      func(resolve, reject);
    }
    catch (err) {
      // 例外が発生したらrejectedにする
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    if (typeof onFulfilled !== "function") {
      onFulfilled = identity;
    }
    if (typeof onRejected !== "function") {
      onRejected = thrower;
    }

    if (this.state === "pending") {
      // pendingなら後で呼び出すので関数を記録しておく
      return new Yakusoku((resolve, reject) => {
        this.thenFunctions.push({ onFulfilled, onRejected, resolve, reject });
      });
    }
    if (this.state === "fulfilled") {
      return wrapWithYakusoku(onFulfilled(this.resolvedValue));
    }
    if (this.state === "rejected") {
      return wrapWithYakusoku(onRejected(this.rejectedValue));
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected); // 1番目の引数をidentity functionにする
  }
}

function identity(value) { // identity function
  return value;
}

function thrower(err) { // thrower function
  throw err;
}

function isThenable(value) {
  if (value === null || value === undefined) {
    return false;
  }
  return typeof value.then === "function";
}

function wrapWithYakusoku(value) {
  if (isThenable(value)) {
    return value;
  }
  return new Yakusoku((resolve) => {
    resolve(value);
  });
}

then()に渡された関数(onFulfilled / onRejected)が例外を投げた場合にも対応するため、コンストラクター内でthenFunctionsを実行するときにtry-catchで囲っています。

これで、readFilePromise()を使ったサンプルコードをreadFileYakusoku()に置き換えてもきちんと動くはずです。

複数の非同期関数の待ち合わせ

Promiseを使うと、非同期処理の直列実行やエラー処理をわかりやすく書けることがお分かりいただけたかと思います。でもまだ終わりではありません。Promiseを使うと前回は解決できなかったこんなこともできるようになります。

  • 2つ以上のファイルの読み込み処理を並列で実行して、どれか1つでも処理が終わったらコールバックを呼びたい
  • 2つ以上のファイルの読み込み処理を並列で実行して、全ての処理が終わったらコールバックを呼びたい

Promise.race()

まずは「どれか1つでも終わったら」から行きましょう。例えば、データベースからデータを取得するときに複数のレプリケーション先に一度にクエリーを投げて、最初に応答があったものを使う、という時に使います。

そんな時に便利なのがPromise.race()です。まさにレースですね。

こんな状況を仮定します。

  • レプリケーション先が3つある
  • それぞれのレプリケーション先からfetchData1(), fetchData2(), fetchData3()という関数でデータを取得できる
  • 上記の関数は全てPromiseオブジェクトを返す

関数名が投げやりですがここには突っ込まないでください。3つの関数のうち最初に応答があったものを使いたい場合は、Promise.race()を使って以下のように書けます。

Promise.race([fetchData1(), fetchData2(), fetchData3()])
  .then((data) => {
    // 最初に取得したものがdataに渡される
  })
  .catch((err) => {
    // エラーが発生したらこちら
  });

簡単ですね。

実装も意外と簡単です。一度確定した状態はそれ以降変わらないというPromiseの仕様を利用すれば、以下のようなシンプルなコードで実装できます。

class Yakusoku {
  static race(iterable) {
    return new Yakusoku((resolve, reject) => {
      for (const y of iterable) {
        // 最初に状態が変わったものを採用
        y.then(resolve, reject);
      }
    });
  }
}

これをコールバック方式で実装すると、協調用の変数を使うなど色々面倒なことになると思います。

このように便利なPromise.race()ですが、注意点が2つあります。

1つ目は、どの関数から結果が返されたのかがわからないという点です。上記のコードでは、fetchData1〜3のどれから返されたのかわかりません。そのため、どれから返されても問題ないように、全て同じ値を返す関数を指定してください。

2つ目は、最初に状態が変わったものが採用されるという点です。言い換えれば、どれか1つでもネットワークエラーなどで最初にrejectedになってしまうと、他の関数がfulfilledになってもPromise.race()の結果がrejectedになってしまいます。そのため、rejectedになる可能性を極力減らしたいような状況ではあまり使えません。

どれか1つでも最初にrejectedになると、他がfulfilledになってもPromise.race()はrejectedのまま

Promiseの最新の仕様では、最初にfulfilledになったものを採用するPromise.any()が規定されているので上記のような状況にも使えそうですね。この実装はPromise.race()より複雑です。各自で考えてみてください。決して実装方法が思いつかないからぶん投げているわけではありません。勘違いしないように。

Promise.all()

続いて「全ての処理が終わったら」を解説します。複数の非同期処理(DBアクセスやファイルアクセスなど)を並列で実行して全ての処理が終わった時点で次に移るので、うまく使えばアプリケーションの応答が速くなります。こちらのほうがPromise.race()より使う機会は多いと思います。

例えば先ほどのfetchData1()fetchData3()を並列に実行して全てのデータを取得できたら次の処理に移るには、次のようにします。

Promise.all([fetchData1(), fetchData2(), fetchData3()])
  .then(([data1, data2, data3]) => {
    // fetchData1()の結果がdata1に、fetchData2()の結果がdata2に、fetchData3()の結果がdata3に渡される
  })
  .catch((err) => {
    // エラーが発生したらこちら
  });

呼び出し方は先ほどのPromise.race()と似ていますね。非同期処理を並列に実行できるので、1つの処理が終わってから次の処理を実行するより速度の向上が期待できます。

図解すると以下のようになります。渡したPromiseオブジェクトがすべてfulfilledになった時点でPromise.all()の結果もfulfilledになり、それぞれのオブジェクトが返した結果を配列にまとめて返します。

すべてfulfilledになったらPromise.all()もfulfilledになる

一方、どれか1つでもrejectedになってしまうとPromise.all()の結果もrejectedになります。Promise.race()とは異なり、順序に関係なく1つでもrejectedになってしまったらアウトです。

どれか1つでもrejectedになったらPromise.all()もrejectedになる。これは順序に関係ない。

実装は、「全て成功する or 1つでも失敗するまで待つ」「then()に渡される値の順番はPromiseオブジェクトを渡した順番と同じ」の2点を実現するためにPromise.race()よりちょっと複雑になりました。

class Yakusoku {
  static all(iterable) {
    return new Yakusoku((resolve, reject) => {
      const resolvedValues = Array(iterable.length); // 結果を格納する配列
      let iterableCounter = 0; // 渡されたPromiseオブジェクトのカウンター
      let fulfilledCounter = 0; // fulfilledになった数のカウンター

      for (const p of iterable) {
        const i = iterableCounter++;
        p.then((value) => {
          resolvedValues[i] = value;
          fulfilledCounter++;
          if (fulfilledCounter >= iterable.length) {
            // 全て解決
            resolve(resolvedValues);
          }
        }, reject); // 1つでもrejectedになったら全体をrejectedにする
      }
    });
  }
}

Promise.any()の実装は、これより少し簡単になるはずです。試してませんが

以上の内容を全てまとめたものがyakusoku.jsです。

まとめ

Promiseを使うと、コールバックでは難しかった以下のような処理を簡単に実現できるのがお分かりいただけたと思います。

  • ネストが深くなる問題(コールバック地獄)
  • 複数の非同期処理の待ち合わせの問題
  • 非同期処理で例外を投げるときの問題

Promiseの仕様や内部実装を理解できれば、JavaScriptの非同期処理を8割がた理解できたも同然です。

次回は、いよいよ最後のasync/awaitを解説します。お楽しみに!