非同期処理:コールバック/Promise/Async Function

この章ではJavaScriptの非同期処理について学んで行きます。 非同期処理はJavaScriptにおいてはとても重要な概念です。 また、JavaScriptを扱うブラウザやNode.jsなどは非同期処理のみのAPIも多いため、非同期処理を避けることはできません。 そのため、非同期処理をあつかうためのパターンやPromiseというビルトインオブジェクト、さらにはAsync Functionとよばれる構文的なサポートがあります。

この章では非同期処理とはどのようなものかという話から、非同期処理での例外処理、非同期処理の扱い方を見ていきます。

同期処理

多くのプログラミング言語ではコードの評価の仕方として同期処理(sync)と非同期処理(async)という大きな分類があります。

今まで書いていたコードは同期処理と呼ばれているものです。 同期処理ではコードを順番に処理していき、ひとつの処理が終わるまで次の処理は行いません。 同期処理では実行している処理はひとつだけとなるため、とても直感的な動作となります。

一方、同期的にブロックする処理が行われていた場合には問題があります。 同期処理ではひとつの処理が終わるまで、次の処理へ進むことができないためです。

次のコードのblockTime関数は指定したtimeoutミリ秒だけ無限ループを行い同期的にブロックする処理です。 このblockTime関数を呼び出すと、指定時間が経過するまで次の処理(次の行)は呼ばれません。

// 指定した`timeout`ミリ秒経過するまで同期的にブロックする関数
function blockTime(timeout) { 
    const startTime = Date.now();
    // `timeout`ミリ秒経過するまで無限ループをする
    while (true) {
        const diffTime = Date.now() - startTime;
        if (diffTime >= timeout) {
            return; // 指定時間経過したら関数の実行を終了
        }
    }
}
console.log("処理を開始");
blockTime(1000); // 他の処理を1000ミリ秒(1秒間)ブロックする
console.log("この行が呼ばれるまで処理が1秒間ブロックされる");

同期的にブロックする処理があると、ブラウザでは大きな問題となります。 なぜなら、JavaScriptは基本的にブラウザのメインスレッド(UIスレッドとも呼ばれる)で実行されるためです。 メインスレッドは表示の更新といったUIに関する処理も行っています。 そのため、メインスレッドが他の処理で専有されると、表示が更新されなくなりフリーズしたような体感となります。

さきほどの例では1秒間も処理をブロックしているため、1秒間スクロールなどの操作が効かないといった悪影響がでます。

非同期処理

非同期処理はコードを順番に処理していきますが、ひとつの非同期処理が終わるのを待たずに次の処理を評価します。 つまり、非同期処理では同時に実行している処理が複数あります。

JavaScriptにおいて非同期処理をする代表的な関数としてsetTimeout関数があります。 setTimeout関数はdelayミリ秒後に、コールバック関数を呼び出すようにタイマーへ登録する非同期処理です。

setTimeout(コールバック関数, delay);

次のコードではsetTimeout関数を使い10ミリ秒後に、1秒間ブロックする処理を実行しています。 setTimeout関数でタイマーに登録したコールバック関数は非同期的なタイミングで呼ばれます。 そのためsetTimeout関数の次の行に書かれている同期的処理は、非同期処理よりも先に実行されます。

// 指定した`timeout`ミリ秒経過するまで同期的にブロックする関数
function blockTime(timeout) { 
    const startTime = Date.now();
    while (true) {
        const diffTime = Date.now() - startTime;
        if (diffTime >= timeout) {
            return; // 指定時間経過したら関数の実行を終了
        }
    }
}

console.log("1. setTimeoutのコールバック関数を10ミリ秒後に実行します");
setTimeout(() => {
    console.log("3. ブロックする処理を開始します");
    blockTime(1000); // 他の処理を1秒間ブロックする
    console.log("4. ブロックする処理が完了しました");
}, 10);
// ブロックする処理は非同期なタイミングで呼び出されるので、次の行が先に実行される
console.log("2. 同期的な処理を実行します");

このコードを実行した結果のコンソールログは次のようになります。

  1. setTimeoutのコールバック関数を10ミリ秒後に実行します
  2. 同期的な処理を実行します
  3. ブロックする処理を開始します
  4. ブロックする処理が完了しました

このように、非同期処理(setTimeoutのコールバック関数)は、コードの見た目上の並びとは異なる順番で実行されることがわかります。

非同期処理はメインスレッドで実行される

JavaScriptにおいて多くの非同期処理はメインスレッドで実行されます。 メインスレッドはUIスレッドとも呼ばれ、重たいJavaScriptの処理はメインスレッドで実行する他の処理(画面の更新など)をブロックする問題について紹介しました。(ECMAScriptの仕様として規定されているわけではないため、すべてがメインスレッドで実行されているわけではありません)

非同期処理は名前から考えるとメインスレッド以外で実行されるように見えますが、 基本的には非同期処理も同期処理と同じようにメインスレッドで実行されます。 このセクションでは非同期処理がどのようにメインスレッドで実行されているかを簡潔に見ていきます。

次のコードは、setTimeout関数でタイマーに登録したコールバック関数が呼ばれるまで、実際にどの程度の時間がかかったかを計測しています。 また、setTimeout関数でタイマーに登録した次の行で、同期的にブロックする処理を実行しています。

非同期処理(コールバック関数)がメインスレッド以外のスレッドで実行されるならば、 この非同期処理はメインスレッドでの同期的にブロックする処理の影響を受けないはずです。 しかし、実際にはこの非同期処理もメインスレッドで実行された同期的にブロックする処理の影響を受けます。

次のコードを実行するとsetTimeout関数で登録したコールバック関数は、タイマーに登録した時間(10ミリ秒後)よりも大きく遅れて呼び出されます。

// 指定した`timeout`ミリ秒経過するまで同期的にブロックする関数
function blockTime(timeout) { 
    const startTime = Date.now();
    while (true) {
        const diffTime = Date.now() - startTime;
        if (diffTime >= timeout) {
            return; // 指定時間経過したら関数の実行を終了
        }
    }
}

const startTime = Date.now();
// 10ミリ秒後にコールバック関数を呼び出すようにタイマーに登録する
setTimeout(() => {
    const endTime = Date.now();
    console.log(`非同期処理のコールバックが呼ばれるまで${endTime - startTime}ミリ秒かかりました`);
}, 10);
console.log("ブロックする処理を開始します");
blockTime(1000); // 1秒間処理をブロックする
console.log("ブロックする処理が完了しました");

多くの環境では、このときの非同期処理のコールバックが呼ばれるまでは1000ミリ秒以上かかります。 このように非同期処理同期処理の影響を受けることから、同じスレッドで実行されていることがわかります。

JavaScriptでは一部の例外を除き非同期処理が並行処理(concurrent)として扱われます。 並行処理とは、処理を一定の単位ごとに分けて処理を切り替えながら実行することです。 そのため非同期処理の実行中にとても重たい処理があると、非同期処理の切り替えが遅れるという現象を引き起こします。

このようにJavaScriptの非同期処理も基本的には1つのメインスレッドで処理されています。 これはsetTimeout関数のコールバック関数から外側のスコープのデータへのアクセス方法に制限がないことからもわかります。 もし非同期処理が別スレッドで行われるならば、自由なデータへのアクセスは競合状態(レースコンディション)を引き起こしてしまうためです。

ただし、非同期処理の中にもメインスレッドとは別のスレッドで実行できるAPIが実行環境によっては存在します。 たとえばブラウザではWeb Worker APIを使い、メインスレッド以外でJavaScriptを実行できます。 このWeb Workerにおける非同期処理は並列処理(Parallel)です。 並列処理とは、排他的に複数の処理を同時に実行することです。

Web Workerではメインスレッドとは異なるWorkerスレッドで実行されるため、メインスレッドはWorkerスレッドの同期的にブロックする処理の影響を受けにくくなります。 ただし、Web Workerとメインスレッドでのデータのやり取りにはpostMessageというメソッドを利用する必要があります。 そのため、setTimeout関数のコールバック関数とは異なりデータへのアクセス方法にも制限がつきます。

非同期処理のすべてをひとくくりにはできませんが、基本的な非同期処理(タイマーなど)はメインスレッドで実行されているという性質を知ることは大切です。JavaScriptの大部分の非同期処理非同期的なタイミングで実行される処理であると理解しておく必要があります。

非同期処理と例外処理

非同期処理は処理の流れが同期処理とは異なることについて紹介しました。 これは非同期処理における例外処理においても大きな影響を与えます。

同期処理では、try...catch構文を使うことで同期的に発生した例外はキャッチできます。(詳細は「例外処理」の章を参照)

try {
    throw new Error("同期的なエラー");
} catch (error) {
    console.log("同期的なエラーをキャッチできる");
}
console.log("この行は実行されます");

非同期処理では、try...catch構文を使っても非同期的に発生した例外をキャッチできません。 次のコードでは、10ミリ秒後に非同期的なエラーを発生させています。 しかし、try...catch構文では次のような非同期エラーをキャッチできません。

try {
    setTimeout(() => {
        throw new Error("非同期的なエラー");
    }, 10);
} catch (error) {
    // この行は実行されません
}
console.log("この行は実行されます");

tryブロックはそのブロック内で発生した例外をキャッチする構文です。 しかし、setTimeout関数で登録されたコールバック関数が実際に実行され例外を投げるのは、すべての同期処理が終わった後となります。 つまり、tryブロックで例外が発生しうるとマークした範囲外で例外が発生します。

そのため、setTimeout関数のコールバック関数における例外は、次のようにコールバック関数内で同期的なエラーとしてキャッチする必要があります。

// 非同期処理の外
setTimeout(() => {
    // 非同期処理の中
    try {
        throw new Error("エラー");
    } catch (error) {
        console.log("エラーをキャッチできる");
    }
}, 10);
console.log("この行は実行されます");

このようにコールバック関数内でエラーをキャッチはできますが、非同期処理の外からは非同期処理の中で例外が発生したかが分かりません。 非同期処理の外から例外が起きたのか知るためには、非同期処理の中で例外が発生したことを非同期処理の外へ伝える方法が必要です。

この非同期処理で発生した例外の扱い方についてはさまざまなパターンがあります。 この章では主要な非同期処理と例外の扱い方としてエラーファーストコールバック、Promise、Async Functionの3つを見ていきます。 現実のコードではすべてのパターンが使われています。そのため、非同期処理の選択肢を増やす意味でもそれぞれを理解することは重要です。

エラーファーストコールバック

ECMAScript 2015(ES2015)でPromiseが仕様へ入るまで、非同期処理中に発生した例外を扱う仕様はありませんでした。 ES2015より前までは、エラーファーストコールバックという非同期処理中に発生した例外を扱う方法を決めたルールが広く使われていました。

エラーファーストコールバックとは、次のような非同期処理におけるコールバック関数の呼び出し方を決めたルールです。

  • 処理が失敗した場合は、コールバック関数の1番目の引数にエラーオブジェクトを渡して呼び出す
  • 処理が成功した場合は、コールバック関数の1番目の引数にはnullを渡し、2番目以降の引数に成功時の結果を渡して呼び出す

つまり、ひとつのコールバック関数で失敗した場合と成功した場合の両方を扱うルールとなります。

たとえば、Node.jsではfs.readFile関数というファイルシステムからファイルをロードする非同期処理の関数があります。 指定したパスのデータを読むため、ファイルが存在しない場合やアクセス権限の問題から読み取りに失敗することがあります。 そのため、fs.readFile関数の第2引数にわたすコールバック関数にはエラーファーストコールバックスタイルの関数を渡します。

ファイルを読み込むことに失敗した場合は、コールバック関数の1番目の引数にErrorオブジェクトが渡されます。 ファイルを読み込むことに成功した場合は、コールバック関数の1番目の引数にnull、2番目の引数に読み込んだデータを渡します。

fs.readFile("./example.txt", (error, data) => {
    if (error) {
        // 読み込み中にエラーが発生しました
    } else {
        // データを読み込むことができた
    }
});

このエラーファーストコールバックはNode.jsでは広く使われ、Node.jsの標準APIでも利用されています。 詳しい扱い方については「ユースケース: Node.jsでCLIアプリケーション」の章にて紹介します。

実際にエラーファーストコールバックで非同期な例外処理を扱うコードを書いてみましょう。

次のコードのdummyFetch関数は、擬似的なリソースの取得をする非同期な処理です。 第1引数に任意のパスを受け取り、第2引数にエラーファーストコールバックスタイルの関数を受けとります。

このdummyFetch関数は、任意のパスにマッチするリソースがある場合には、第2引数のコールバック関数にnullとレスポンスオブジェクトを渡して呼び出します。 一方、任意のパスにマッチするリソースがない場合には、第2引数のコールバック関数にはエラーオブジェクトを渡して呼び出します。

/**
 * 1000ミリ秒未満のランダムなタイミングでレスポンスを擬似的にデータ取得する関数
 * 指定した`path`にデータがある場合は`callback(null, レスポンス)`を呼ぶ
 * 指定した`path`にデータがない場合は`callback(エラー)`を呼ぶ
 */
function dummyFetch(path, callback) {
    setTimeout(() => {
        // /success から始まるパスにはリソースがあるという設定
        if (path.startsWith("/success")) {
            callback(null, { body: `Response body of ${path}` });
        } else {
            callback(new Error("NOT FOUND"));
        }
    }, 1000 * Math.random());
}
// /success/data にリソースが存在するので、`response`にはデータが入る
dummyFetch("/success/data", (error, response) => {
    if (error) {
        // この行は実行されません
    } else {
        console.log(response); // => { body: "Response body of /success/data" }
    }
});
// /failure/data にリソースは存在しないので、`error`にはエラーオブジェクトが入る
dummyFetch("/failure/data", (error, response) => {
    if (error) {
        console.log(error.message); // => "NOT FOUND"
    } else {
        // この行は実行されません
    }
});

このようにコールバック関数の1番目の引数にはエラーオブジェクトまたはnullを入れ、それ以降の引数にデータを渡すというルール化したものをエラーファーストコールバックと呼びます。

非同期処理中に例外が発生して生じたエラーをコールバック関数で受け取る方法は他にもやり方があります。 たとえば、成功したときに呼び出すコールバック関数と失敗したときに呼び出すコールバック関数の2つを受け取る方法があります。 さきほどのdummyFetch関数を2種類のコールバック関数を受け取る形に変更すると次のような実装になります。

/**
 * リソースの取得に成功した場合は`successCallback(レスポンス)`を呼び出す
 * リソースの取得に失敗した場合は`failureCallback(エラー)`を呼び出す
 */
function dummyFetch(path, successCallback, failureCallback) {
    setTimeout(() => {
        if (path.startsWith("/success")) {
            successCallback({ body: `Response body of ${path}` });
        } else {
            failureCallback(new Error("NOT FOUND"));
        }
    }, 1000 * Math.random());
}

このように非同期処理の中で例外が発生した場合に、その例外を非同期処理の外へ伝える方法はさまざまな手段が考えられます。 エラーファーストコールバックはその形を決めたただの共通のルールの1つです。 ルールを決めることのメリットとして、エラーハンドリングのパターン化ができます。

しかし、エラーファーストコールバックは非同期処理におけるエラーハンドリングの書き方を決めたただのルールであるため仕様ではありません。 そのため、エラーファーストコールバックというルールを破っても、問題があるわけではありません。

しかしながら、最初に書いたようにJavaScriptでは非同期処理を扱うケースが多いです。 そのため、ただのルールではなくECMAScriptの仕様として非同期処理を扱う方法が求められていました。 そこで、ES2015ではPromiseという非同期処理を扱うビルトインオブジェクトが導入されました。

次のセクションでは、ES2015で導入されたPromiseについて見ていきます。

[ES2015] Promise

PromiseはES2015で導入された非同期処理の結果を表現するビルトインオブジェクトです。

エラーファーストコールバックは非同期処理を扱うコールバック関数の最初の引数にエラーオブジェクトを渡すというルールでした。 Promiseはこれを発展させたもので、単なるルールではなくオブジェクトという形にして非同期処理を統一的なインタフェースで扱うことを目的にしています。

Promiseはビルトインオブジェクトであるためさまざまなメソッドを持ちますが、 まずはエラーファーストコールバックとPromiseでの非同期処理のコード例を比較してみます。

次のコードのasyncTask関数はエラーファーストコールバックを受け取る非同期処理の例です。

エラーファーストコールバックは次のようなルールでした。

  • 非同期処理が成功した場合は、1番目の引数にnullを渡し2番目以降の引数に結果を渡す
  • 非同期処理が失敗した場合は、1番目の引数にエラーオブジェクトを渡す
// asyncTask関数はエラーファーストコールバックを受け取る
asyncTask((error, result) => {
    if (error) {
        // 非同期処理が失敗したときの処理
    } else {
        // 非同期処理が成功したときの処理
    }
});

次のコードのasyncTask関数はPromiseインスタンスを返す非同期処理の例です。 Promiseでは、非同期処理に成功したときの処理をコールバック関数としてthenメソッドへ渡し、 失敗したときの処理を同じくコールバック関数としてcatchメソッドへ渡します。

エラーファーストコールバックとはことなり、非同期処理(asyncTask関数)はPromiseインスタンスを返しています。 その返されたPromiseインスタンスに対して、成功と失敗時の処理をそれぞれコールバック関数として渡すという形になります。

// asyncTask関数はPromiseインスタンスを返す
asyncTask().then(()=> {
    // 非同期処理が成功したときの処理
}).catch(() => {
    // 非同期処理が失敗したときの処理
});

Promiseインスタンスのメソッドによって引数に渡せるものが決められているため、非同期処理の流れも一定のやり方に統一されます。また非同期処理(asyncTask関数)はコールバック関数を受け取るのではなく、Promiseインスタンスを返すという形に変わっています。このPromiseという統一されたインターフェースがあることで、 さまざまな非同期処理のパターンを形成できます。

つまり、複雑な非同期処理を上手くパターン化できるというのがPromiseの役割であり、 Promiseを使う理由のひとつであるといえるでしょう。 このセクションでは、非同期処理を扱うビルトインオブジェクトであるPromiseを見ていきます。

Promiseインスタンスの作成

Promiseはnew演算子でPromiseのインスタンスを作成して利用します。 このときのコンストラクタにはresolverejectの2つの引数を取るexecutorとよばれる関数を渡します。 executor関数の中で非同期処理を行い、非同期処理が成功した場合はresolve関数を呼び、失敗した場合はreject関数を呼び出します。

const executor = (resolve, reject) => {
    // 非同期の処理が成功したときはresolveを呼ぶ
    // または、非同期の処理が失敗したときはrejectを呼ぶ
};
const promise = new Promise(executor);

このPromiseインスタンスのPromise#thenメソッドで、Promiseがresolve(成功)、reject(失敗)したときに呼ばれるコールバック関数を登録します。 thenメソッドの第一引数にはresolve(成功)時に呼ばれるコールバック関数、第二引数にはreject(失敗)時に呼ばれるコールバック関数を渡します。

// `Promise`インスタンスを作成
const promise = new Promise((resolve, reject) => {
    // 非同期の処理が成功したときはresolve()を呼ぶ
    // 非同期の処理が失敗したときにはreject()を呼ぶ
});
const onFulfilled = () => {
    console.log("resolveされたときに呼ばれる");
};
const onRejected = () => {
    console.log("rejectされたときに呼ばれる");
};
// `then`メソッドで成功時と失敗時に呼ばれるコールバック関数を登録
promise.then(onFulfilled, onRejected);

PromiseコンストラクタのresolverejectthenメソッドのonFulfilledonRejectedは次のような関係となります。

  • resolve(成功)した時
    • onFulfilledが呼ばれる
  • reject(失敗)した時
    • onRejected が呼ばれる

Promise#thenPromise#catch

Promiseのようにコンストラクタに関数を渡すパターンは今までなかったので、thenメソッドの使い方について具体的な例を紹介します。 また、thenメソッドのエイリアスでもあるcatchメソッドについても見ていきます。

次のコードのdummyFetch関数はPromiseのインスタンスを作成して返します。 dummyFetch関数はリソースの取得に成功した場合はresolve関数を呼び、失敗した場合はreject関数を呼びます。

resolveに渡した値は、thenメソッドの1番目のコールバック関数(onFulfilled)に渡されます。 rejectに渡したエラーオブジェクトは、thenメソッドの2番目のコールバック関数(onRejected)に渡されます。

/**
 * 1000ミリ秒未満のランダムなタイミングでレスポンスを擬似的にデータ取得する関数
 * 指定した`path`にデータがある場合、成功として**Resolved**状態のPromiseオブジェクトを返す
 * 指定した`path`にデータがない場合、失敗として**Rejected**状態のPromiseオブジェクトを返す
 */
function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/success")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}
// `then`メソッドで成功時と失敗時に呼ばれるコールバック関数を登録
// /success/data のリソースは存在するので成功しonFulfilledが呼ばれる
dummyFetch("/success/data").then(function onFulfilled(response) {
    console.log(response); // => { body: "Response body of /success/data" }
}, function onRejected(error) {
    // この行は実行されません
});
// /failure/data のリソースは存在しないのでonRejectedが呼ばれる
dummyFetch("/failure/data").then(function onFulfilled(response) {
    // この行は実行されません
}, function onRejected(error) {
    console.log(error); // Error: "NOT FOUND"
});

Promise#thenメソッドは成功(onFulfilled)と失敗(onRejected)のコールバック関数の2つを受け取りますが、どちらの引数も省略できます。

次のコードのdelay関数は一定時間後に解決(resolve)されるPromiseインスタンスを返します。 このPromiseインスタンスに対してthenメソッドで成功時のコールバック関数だけを登録しています。

function delay(timeoutMs) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve();
        }, timeoutMs);
    });
}
// `then`メソッドで成功時のコールバック関数だけを登録
delay(10).then(() => {
    console.log("10ミリ秒後に呼ばれる");
});

一方、thenメソッドでは失敗時のコールバック関数だけの登録もできます。 このときthen(undefined, onRejected)のように第1引数にはundefinedを渡す必要があります。 then(undefined, onRejected)と同様のことを行う方法としてPromise#catchメソッドが用意されています。

次のコードではthenメソッドとcatchメソッドで失敗時のエラー処理をしていますが、どちらも同じ意味となります。 thenメソッドにundefinedを渡すのはわかりにくいため、失敗時の処理だけを登録する場合はcatchメソッドの利用を推奨しています。

function errorPromise(message) {
    return new Promise((resolve, reject) => {
        reject(new Error(message));
    });
}
// 非推奨: `then`メソッドで失敗時のコールバック関数だけを登録
errorPromise("thenでエラーハンドリング").then(undefined, (error) => {
    console.log(error.message); // => "thenでエラーハンドリング"
});
// 推奨: `catch`メソッドで失敗時のコールバック関数を登録
errorPromise("catchでエラーハンドリング").catch(error => {
    console.log(error.message); // => "catchでエラーハンドリング"
});

Promiseと例外

Promiseではコンストラクタの処理で例外が発生した場合に自動的に例外がキャッチされます。 例外が発生したPromiseインスタンスはreject関数を呼びだしたのと同じように失敗したものとして扱われます。 そのため、Promise内で例外が発生するとthenメソッドの第二引数やcatchメソッドで登録したエラー時のコールバック関数が呼び出されます。

function throwPromise() {
    return new Promise((resolve, reject) => {
        // Promiseコンストラクタの中で例外は自動的にキャッチされrejectを呼ぶ
        throw new Error("例外が発生");
        // 例外が発生すると、これ以降のコンストラクタの処理は実行されません
    });
}

throwPromise().catch(error => {
    console.log(error.message); // => "例外が発生"
});

このようにPromiseにおける処理ではtry...catch構文を使わなくても、自動的に例外がキャッチされます。

Promiseの状態

Promiseのthenメソッドやcatchメソッドによる処理がわかったところで、Promiseインスタンスの状態について整理していきます。

Promiseインスタンスには、内部的に次の3つの状態が存在します。

  • Fulfilled
    • resolve(成功)したときの状態。このときonFulfilledが呼ばれる
  • Rejected
    • reject(失敗)または例外が発生したときの状態。このときonRejectedが呼ばれる
  • Pending
    • FulfilledまたはRejectedではない状態
    • new Promiseでインスタンスを作成したときの初期状態

これらの状態はECMAScriptの仕様として決められている内部的な状態です。 しかし、この状態をPromiseのインスタンスから取り出す方法はありません。 そのためAPIとしてこの状態を直接扱うことはできませんが、Promiseについて理解するのに役に立ちます。

Promiseインスタンスの状態は作成時にPendingとなり、一度でもFulfilledまたはRejectedへ変化すると、それ以降状態は変化しなくなります。 そのため、FulfilledまたはRejectedの状態であることをSettled(不変)と呼びます。

一度でもSettledFulfilledまたはRejected)となったPromiseインスタンスは、それ以降別の状態には変化しません。 そのため、resolveを呼び出した後にrejectを呼び出しても、そのPromiseインスタンスは最初に呼び出したresolveによってFulfilledのままとなります。

次のコードでは、rejectを呼び出しても状態が変化しないため、thenで登録したonRejectedのコールバック関数は呼び出されません。 thenメソッドで登録したコールバック関数は、状態が変化した場合に一度だけ呼び出されます。

const promise = new Promise((resolve, reject) => {
    // 非同期でresolveする
    setTimeout(() => {
        resolve();
        // 既にresolveされているため無視される
        reject(new Error("エラー"));
    }, 16);
});
promise.then(() => {
    console.log("Fulfilledとなった");
}, (error) => {
    // この行は呼び出されない
});

同じように、Promiseコンストラクタ内でresolveを何度呼び出しても、そのPromiseインスタンスの状態は一度しか変化しません。 そのため、次のようにresolveを何度呼び出しても、thenで登録したコールバック関数は一度しか呼び出されません。

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve();
        resolve(); // 2度目以降のresolveやrejectは無視される
    }, 16);
});
promise.then(() => {
    console.log("最初のresolve時に一度だけ呼ばれる");
}, (error) => {
    // この行は呼び出されない
});

このようにPromiseインスタンスの状態が変化した時に、一度だけ呼ばれるコールバック関数を登録するのがthencatchメソッドとなります。

またthencatchメソッドはすでにSettledへと状態が変化済みのPromiseインスタンスに対してもコールバック関数を登録できます。 状態が変化済みのPromiseインスタンスを作成する方法としてPromise.resolvePromise.rejectメソッドがあります。

Promise.resolve

Promise.resolveメソッドはFulfilledの状態となったPromiseインスタンスを作成します。

const fulFilledPromise = Promise.resolve();

Promise.resolveメソッドはnew Promiseの糖衣構文(シンタックスシュガー)です。 糖衣構文とは、同じ意味の処理を元の構文よりシンプルに書ける別の書き方のことです。 Promise.resolveメソッドは次のコードの糖衣構文です。

// const fulFilledPromise = Promise.resolve(); と同じ意味
const fulFilledPromise = new Promise((resolve) => {
    resolve();
});

Promise.resolveメソッドは引数にresolveされる値を渡すこともできます。

// `resolve(42)`された`Promise`インスタンスを作成する
const fulFilledPromise = Promise.resolve(42);
fulFilledPromise.then(value => {
    console.log(value); // => 42
});

Promise.resolveメソッドで作成したFulfilledの状態となったPromiseインスタンスに対してもthenメソッドでコールバック関数を登録できます。 状態が変化済みのPromiseインスタンスにthenメソッドで登録したコールバック関数は、常に非同期なタイミングで実行されます。

const promise = Promise.resolve();
promise.then(() => {
    console.log("2. コールバック関数が実行されました");
});
console.log("1. 同期的な処理が実行されました");

このコードを実行すると、すべての同期的な処理が実行された後に、thenメソッドのコールバック関数が非同期なタイミングで実行されることがわかります。

Promise.resolveメソッドはnew Promiseの糖衣構文であるため、この実行順序はnew Promiseを使った場合も同じです。 次のコードは、さきほどのPromise.resolveメソッドを使ったものと同じ動作になります。

const promise = new Promise((resolve) => {
    console.log("1. resolveします");
    resolve();
});
promise.then(() => {
    console.log("3. コールバック関数が実行されました");
});
console.log("2. 同期的な処理が実行されました");

このコードを実行すると、まずPromiseのコンストラクタ関数が実行され、続いて同期的な処理が実行されます。最後にthenメソッドで登録していたコールバック関数が非同期的に呼ばれることがわかります。

Promise.reject

Promise.rejectメソッドは Rejectedの状態となったPromiseインスタンスを作成します。

const rejectedPromise = Promise.reject(new Error("エラー"));

Promise.rejectメソッドはnew Promiseの糖衣構文(シンタックスシュガー)です。 そのため、Promise.rejectメソッドは次のコードと同じ意味になります。

const rejectedPromise = new Promise((resolve, reject) => {
    reject(new Error("エラー"));
});

Promise.rejectメソッドで作成したRejected状態のPromiseインスタンスに対してもthencatchメソッドでコールバック関数を登録できます。 Rejected状態へ変化済みのPromiseインスタンスに登録したコールバック関数は、常に非同期なタイミングで実行されます。これはFulfilledの場合と同様です。

Promise.reject(new Error("エラー")).catch(() => {
    console.log("2. コールバック関数が実行されました");
});
console.log("1. 同期的な処理が実行されました");

Promise.resolvePromise.rejectは短くかけるため、テストコードなどで利用されることがあります。 また、Promise.rejectは次に解説するPromiseチェーンにおいて、Promiseの状態を操作することに利用できます。

Promiseチェーン

Promiseは非同期処理における統一的なインターフェイスを提供するビルトインオブジェクトです。 Promiseによる統一的な処理方法は複数の非同期処理を扱う場合に特に効力を発揮します。 これまでは、1つのPromiseインスタンスに対してthencatchメソッドで1組のコールバック処理を登録するだけでした。

非同期処理が終わったら次の非同期処理というように、複数の非同期処理を順番に扱いたい場合もあります。 Promiseではこのような複数の非同期処理からなる一連の非同期処理を簡単に書く方法が用意されています。

この仕組みのキーとなるのがthencatchメソッドは常に新しいPromiseインスタンスを作成して返すという仕様です。 そのためthenメソッドの返り値であるPromiseインスタンスにさらにthenメソッドで処理を登録できます。 これはメソッドチェーンと呼ばれる仕組みですが、この書籍ではPromiseをメソッドチェーンでつなぐことをPromiseチェーンと呼びます(詳細は「配列」の章を参照)。

次のコードでは、thenメソッドでPromiseチェーンをしています。 Promiseチェーンでは、Promiseが失敗(Rejectedな状態)しない限り、順番にthenメソッドで登録した成功時のコールバック関数を呼び出します。 そのため、次のコードでは、12と順番にコンソールへログが出力されます。

// Promiseインスタンスでメソッドチェーン
Promise.resolve()
    // thenメソッドは新しい`Promise`インスタンスを返す
    .then(() => {
        console.log(1);
    })
    .then(() => {
        console.log(2);
    });

このPromiseチェーンは、次のコードのように毎回新しい変数に入れて処理をつなげるのと結果的には同じ意味となります。

// Promiseチェーンを変数に入れた場合
const firstPromise = Promise.resolve();
const secondPromise = firstPromise.then(() => {
    console.log(1);
});
const thridPromise = secondPromise.then(() => {
    console.log(2);
});
// それぞれ新しいPromiseインスタンスが作成される
console.log(firstPromise === secondPromise); // => false
console.log(secondPromise === thridPromise); // => false

もう少し具体的なPromiseチェーンの例を見ていきましょう。

次のコードのasyncTask関数はランダムでFulfilledまたはRejected状態のPromiseインスタンスを返します。 この関数が返すPromiseインスタンスに対して、thenメソッドで成功時の処理を書いています。 thenメソッドの返り値は新しいPromiseインスタンスであるため、続けてcatchメソッドで失敗時の処理を書けます。

// ランダムでFulfilledまたはRejectedの`Promise`インスタンスを返す関数
function asyncTask() {
    return Math.random() > 0.5 
        ? Promise.resolve("成功")
        : Promise.reject(new Error("失敗"));
}

// asyncTask関数は新しい`Promise`インスタンスを返す
asyncTask()
    // thenメソッドは新しい`Promise`インスタンスを返す
    .then(function onFulfilled(value) { 
        console.log(value); // => "成功"
    })
    // catchメソッドは新しい`Promise`インスタンスを返す
    .catch(function onRejected(error) {
        console.log(error.message); // => "失敗"
    });

asyncTask関数が成功(resolve)した場合はthenメソッドで登録した成功時の処理だけが呼び出され、catchメソッドで登録した失敗時の処理は呼び出されません。 一方、asyncTask関数が失敗(reject)した場合はthenメソッドで登録した成功時の処理は呼び出されずに、catchメソッドで登録した失敗時の処理だけが呼び出されます。

先ほどのコードにおけるPromiseの状態とコールバック関数は次のような処理の流れとなります。

promise-chain

Promiseの状態がRejectedとなった場合は、もっとも近い失敗時の処理(catchまたはthenの第二引数)が呼び出されます。 このとき間にある成功時の処理(thenの第一引数)はスキップされます。

次のコードでは、RejectedのPromiseに対してthen -> then -> catchとPromiseチェーンで処理を記述しています。 このときもっとも近い失敗時の処理(catch)が呼び出されますが、間にある2つの成功時の処理(then)は実行されません。

// RejectedなPromiseは次の失敗時の処理までスキップする
const rejectedPromise = Promise.reject(new Error("失敗"));
rejectedPromise.then(() => {
    // このthenのコールバック関数は呼び出されません
}).then(() => {
    // このthenのコールバック関数は呼び出されません
}).catch(error => {
    console.log(error.message); // => "失敗"
});

Promiseのコンストラクタの処理の場合と同様に、thencatchのコールバック関数内で発生した例外は自動的にキャッチされます。 例外が発生したとき、thencatchメソッドはRejectedPromiseインスタンスを返します。 そのため、例外が発生するともっとも近くの失敗時の処理(catchまたはthenの第二引数)が呼び出されます。

Promise.resolve().then(() => { 
    // 例外が発生すると、thenメソッドはRejectedなPromiseを返す
    throw new Error("例外");
}).then(() => {
    // このthenのコールバック関数は呼び出されません
}).catch(error => {
    console.log(error.message); // => "例外"
});

また、Promiseチェーンで失敗をcatchメソッドなどで一度キャッチすると、次に呼ばれるのは成功時の処理です。 これは、thencatchメソッドはFulfilled状態のPromiseインスタンスを作成して返すためです。 そのため、一度キャッチするとそこからはもとのthenで登録した処理が呼ばれるPromiseチェーンに戻ります。

Promise.reject(new Error("エラー")).catch(error => {
    console.log(error); // Error: エラー
}).then(() => {
    console.log("thenのコールバック関数が呼び出される");
});

このようにPromise#thenメソッドやPromise#catchメソッドをつないで、成功時や失敗時の処理を書いていくことをPromiseチェーンと呼びます。

Promiseチェーンで値を返す

Promiseチェーンではコールバックで返した値を次のコールバックへ引数として渡せます。

thencatchメソッドのコールバック関数は数値、文字列、オブジェクトなどの任意の値を返せます。 このコールバック関数が返した値は、次のthenのコールバック関数へ引数として渡されます。

Promise.resolve(1).then((value) => {
    console.log(value); // => 1
    return value * 2;
}).then(value => {
    console.log(value); // => 2
    return value * 2;
}).then(value => {
    console.log(value); // => 4
    // 値を返さない場合は undefined を返すのと同じ
}).then(value => {
    console.log(value); // => undefined
});

ここではthenメソッドを元に解説しますが、catchメソッドはthenメソッドの糖衣構文であるため同じ動作となります。 Promiseチェーンで一度キャッチすると、次に呼ばれるのは成功時の処理となります。 そのため、catchメソッドで返した値は次のthenメソッドのコールバック関数に引数として渡されます。

Promise.reject(new Error("失敗")).catch(error => { 
    // 一度catchすれば、次に呼ばれるのは成功時のコールバック
    return 1;
}).then(value => {
    console.log(value); // => 1
    return value * 2;
}).then(value => {
    console.log(value); // => 2
});

コールバック関数でPromiseインスタンスを返す

Promiseチェーンで一度キャッチすると、次に呼ばれるのは成功時の処理(thenメソッド)でした。 これは、コールバック関数で任意の値を返すと、その値でresolveされたFulfilled状態のPromiseインスタンスを作成するためです。 しかし、コールバック関数でPromiseインスタンスを返した場合は例外的に異なります。

コールバック関数でPromiseインスタンスを返した場合は、同じ状態をもつPromiseインスタンスがthencatchメソッドの返り値となります。 つまりthenメソッドでRejected状態のPromiseインスタンスを返した場合は、次に呼ばれるのは失敗時の処理となります。

次のコードでは、thenメソッドのコールバック関数でPromise.rejectメソッドを使いRejectedPromiseインスタンスを返しています。 RejectedPromiseインスタンスは、次のcatchメソッドで登録した失敗時の処理を呼び出すまで、thenメソッドの成功時の処理をスキップします。

Promise.resolve().then(function onFulfilledA() {
    return Promise.reject(new Error("失敗"));
}).then(function onFulfilledB() {
    console.log("onFulfilledBは呼び出されません");
}).catch(function onRejected(error) {
    console.log(error.message); // => "失敗"
}).then(function onFulfilledC() {
    console.log("onFulfilledCは呼び出されます");
});

このコードにおけるPromiseの状態とコールバック関数は次のような処理の流れとなります。

then-rejected-promise.png

通常は一度catchすると次に呼び出されるのは成功時の処理でした。 このPromiseインスタンスを返す仕組みを使うことで、catchしてもそのままRejectedな状態を継続するために利用できます。

次のコードではcatchメソッドでログを出力しつつPromise.rejectメソッドを使ってRejectedPromiseインスタンスを返しています。 これによって、asyncFunctionで発生したエラーのログを取りながら、Promiseチェーンはエラーのまま処理を継続できます。

function main() {
    return Promise.reject(new Error("エラー"));
}
// mainはRejectedなPromiseを返す
main().catch(error => {
    // asyncFunctionで発生したエラーのログを出力する
    console.log(error);
    // Promiseチェーンはそのままエラーを継続させる
    return Promise.reject(error);
}).then(() => {
    // 前のcatchでRejectedなPromiseが返されたため、この行は実行されません
}).catch(error => {
    console.log("メインの処理が失敗した");
});

[ES2018] Promiseチェーンの最後に処理を書く

Promise#finallyメソッドは成功時、失敗時どちらの場合でも呼び出すコールバック関数を登録できます。 try...catch...finally構文のfinally節と同様の役割をもつメソッドです。

次のコードでは、リソースを取得してthenで成功時の処理、catchで失敗時の処理を登録しています。 また、リソースを取得中かどうかを判定するためのフラグをisLoadingという変数で管理しています。 成功失敗どちらにもかかわらず、取得が終わったらisLoadingfalseにします。 thencatchの両方でisLoadingfalseを代入できますが、Promise#finallyメソッドを使うことで代入を一箇所にまとめられます。

function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/resource")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}
// リソースを取得中かどうかのフラグ
let isLoading = true;
dummyFetch("/resource/A").then(response => {
    console.log(response);
}).catch(error => {
    console.log(error);
}).finally(() => {
    isLoading = false;
    console.log("Promise#finally");
});

Promiseチェーンで直列処理

Promiseチェーンで非同期処理の流れを書く大きなメリットは、非同期処理のさまざまなパターンに対応できることです。

ここでは、典型的な例として複数の非同期処理を順番に処理していく直列処理を考えていきましょう。 Promiseで直列的な処理と言っても難しいことはなく、単純にthenで非同期処理をつないでいくだけです。

次のコードでは、Resource AとResource Bを順番に取得しています。 それぞれ取得したリソースを変数resultsに追加し、すべて取得し終わったらコンソールに出力します。

function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/resource")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}

const results = [];
// Resource Aを取得する
dummyFetch("/resource/A").then(response => {
    results.push(response.body);
    // Resource Bを取得する
    return dummyFetch("/resource/B");
}).then(response => {
    results.push(response.body);
}).then(() => {
    console.log(results); // => ["Response body of /resource/A", "Response body of /resource/B"]
});

Promise.allで複数のPromiseをまとめる

Promise.allを使うことで複数のPromiseを使った非同期処理をひとつのPromiseとして扱えます。

Promise.allメソッドは Promiseインスタンスの配列を受け取り、新しいPromiseインスタンスを返します。 その配列のすべてのPromiseインスタンスがFulfilledとなった場合は、返り値のPromiseインスタンスもFulfilledとなります。 一方で、ひとつでもRejectedとなった場合は、返り値のPromiseインスタンスもRejectedとなります。

返り値のPromiseインスタンスにthenメソッドで登録したコールバック関数には、Promiseの結果をまとめた配列が渡されます。 このときの配列の要素の順番はPromise.allメソッドに渡した配列のPromiseの要素の順番と同じになります。

// `timeoutMs`ミリ秒後にresolveする
function delay(timeoutMs) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(timeoutMs);
        }, timeoutMs);
    });
}
const promise1 = delay(1);
const promise2 = delay(2);
const promise3 = delay(3);

Promise.all([promise1, promise2, promise3]).then(function(values) {
    console.log(values); // => [1, 2, 3]
});

先程のPromiseチェーンでリソースを取得する例では、Resource Aを取得し終わってからResource Bを取得というように逐次的でした。 しかし、Resource AとBどちらを先に取得しても問題ない場合は、Promise.allメソッドを使い2つのPromiseを1つのPromiseとしてまとめられます。 また、Resource AとBを同時に取得すればより早い時間で処理が完了します。

次のコードでは、Resource AとBを同時に取得開始しています。 両方のリソースの取得が完了すると、thenのコールバック関数にはAとBの結果が配列として渡されます。

function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/resource")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}

const fetchedPromise = Promise.all([
    dummyFetch("/resource/A"),
    dummyFetch("/resource/B")
]);
fetchedPromise.then(([responseA, responseB]) => {
    console.log(responseA.body); // => "Response body of /resource/A"
    console.log(responseB.body); // => "Response body of /resource/B"
});

渡したPromiseがひとつでもRejectedとなった場合は、失敗時の処理が呼び出されます。

function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/resource")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}

const fetchedPromise = Promise.all([
    dummyFetch("/resource/A"),
    dummyFetch("/not_found/B") // Bは存在しないため失敗する
]);
fetchedPromise.then(([responseA, responseB]) => {
    // この行は実行されません
}).catch(error => {
    console.log(error); // Error: NOT FOUND
});

Promise.race

Promise.allメソッドは複数のPromiseがすべて完了するまで待つ処理でした。 Promise.raceメソッドでは複数のPromiseを受け取りますが、Promiseが1つでも完了した(Settle状態となった)時点で次の処理を実行します。

Promise.raceメソッドはPromiseインスタンスの配列を受け取り、新しいPromiseインスタンスを返します。 この新しいPromiseインスタンスは、配列のなかで一番最初にSettle状態へとなったPromiseインスタンスと同じ状態になります。

  • 配列の中で一番最初にSettleとなったPromiseがFulfilledの場合は、新しいPromiseインスタンスもFulfilledになる
  • 配列の中で一番最初にSettleとなったPromiseがRejectedの場合は、新しいPromiseインスタンスも Rejectedになる

つまり、複数のPromiseによる非同期処理を同時に実行して競争(race)させて、一番最初に完了したPromiseインスタンスに対する次の処理を呼び出します。

次のコードでは、delay関数というtimeoutMsミリ秒後にFulfilledとなるPromiseインスタンスを返す関数を定義しています。 Promise.raceメソッドは1ミリ秒、32ミリ秒、64ミリ秒、128ミリ秒後に完了するPromiseインスタンスの配列を受け取っています。 この配列の中でも一番最初に完了するのは、1ミリ秒後にFulfilledとなるPromiseインスタンスです。

// `timeoutMs`ミリ秒後にresolveする
function delay(timeoutMs) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(timeoutMs);
        }, timeoutMs);
    });
}
// 一つでもresolveまたはrejectした時点で次の処理を呼び出す
const racePromise = Promise.race([
    delay(1),
    delay(32),
    delay(64),
    delay(128)
]);
racePromise.then(value => {
    // もっとも早く完了するのは1ミリ秒
    console.log(value); // => 1
});

このときに、一番最初にresolveされた値でracePromiseresolveされます。 そのため、thenメソッドのコールバック関数に1という値が渡されます。

他のtimeout関数が作成したPromiseインスタンスも32ミリ秒、64ミリ秒、128ミリ秒後にresolveされます。 しかし、Promiseインスタンスは一度SettledFulfilledまたはRejected)となると、それ以降は状態も変化せずthenのコールバック関数も呼び出しません。 そのため、racePromiseは何度もresolveされますが、初回以外は無視されるためthenのコールバック関数は一度しか呼び出されません。

Promise.raceメソッドを使うことでPromiseを使った非同期処理のタイムアウトが実装できます。 タイムアウトとは、一定時間経過しても処理が終わっていないならエラーとして扱う処理のことを言います。

次のコードではtimeout関数とdummyFetch関数が返すPromiseインスタンスをPromise.raceメソッドで競争させています。 dummyFetch関数のランダムな時間をかけてリソースを取得しresolveするPromiseインスタンスを返します。 timeout関数は指定ミリ秒経過するとrejectするPromiseインスタンスを返します。

この2つのPromiseインスタンスを競争させて、dummyFetchが先に完了すれば処理は成功、timeoutが先に完了すれば処理は失敗というタイムアウト処理が実現できます。

// `timeoutMs`ミリ秒後にresolveする
function timeout(timeoutMs) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(new Error(`Timeout: ${timeoutMs}ミリ秒経過`));
        }, timeoutMs);
    });
}
function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/resource")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}
// 500ミリ秒以内に取得できなければ失敗時の処理が呼ばれる
Promise.race([
    dummyFetch("/resource/data"),
    timeout(500),
]).then(response => {
    console.log(response.body); // => "Response body of /resource/data"
}).catch(error => {
    console.log(error.message); // => "Timeout: 500ミリ秒経過"
});

このようにPromiseを使うことで非同期処理のさまざまなパターンが形成できます。 より詳しいPromiseの使い方については「JavaScript Promiseの本」というオンラインで公開されている文書にまとめられています。

一方で、Promiseはただのビルトインオブジェクトであるため、非同期処理間の連携をするにはPromiseチェーンのように少し特殊な書き方や見た目になります。 また、エラーハンドリングについてもPromise#catchメソッドやPromise#finallyメソッドなどtry...catch構文とよく似た名前を使います。 しかし、Promiseは構文ではなくただのオブジェクトであるため、それらをメソッドチェーンとして実現しないといけないといった制限があります。

ES2017ではこのPromiseチェーンの不格好な見た目を解決するためにAsync Functionと呼ばれる構文が導入されました。

[ES2017] Async Function

ES2017では、Async Functionという非同期処理を行う関数を定義する構文が導入されました。 Async Functionは通常の関数とは異なり、必ずPromiseインスタンスを返す関数を定義する構文です。

Async Functionは次のように関数の前にasyncをつけることで定義できます。 このdoAsync関数は常にPromiseインスタンスを返します。

async function doAsync() {
    return "値";
}
// doAsync関数はPromiseを返す
doAsync().then(value => {
    console.log(value); // => "値"
});

このAsync Functionは次のように書いた場合と同じ意味になります。 Async Functionではreturnした値の代わりに、Promise.resolve(返り値)のように返り値をラップしたPromiseインスタンスを返します。

// 通常の関数でPromiseインスタンスを返している
function doAsync() {
    return Promise.resolve("値");
}
doAsync().then(value => {
    console.log(value); // => "値"
});

重要なこととしてAsync FunctionはPromiseの上に作られた構文です。 そのためAsync Functionを理解するには、Promiseを理解する必要があることに注意してください。

またAsync Function内ではawait式というPromiseの非同期処理が完了するまで待つ構文が利用できます。 await式を使うことで非同期処理を同期処理のように扱えるため、Promiseチェーンで実現していた処理の流れを読みやすくかけます。

このセクションではAsync Functionとawait式について見ていきます。

Async Functionの定義

Async Functionは関数の定義にasyncキーワードをつけることで定義できます。 JavaScriptの関数定義には関数宣言や関数式、Arrow Function、メソッドの短縮記法などがあります。 どの定義方法でもasyncキーワードを前につけるだけでAsync Functionとして定義できます。

// 関数宣言のAsync Function版
async function fn1() {}
// 関数式のAsync Function版
const fn2 = async function() {};
// Arrow FunctionのAsync Function版
const fn3 = async() => {};
// メソッドの短縮記法のAsync Function版
const obj = { async method() {} };

これらのAsync Functionは、必ずPromiseを返すことや関数中ではawait式が利用できること以外は、通常の関数と同じ性質を持ちます。

Async FunctionはPromiseを返す

Async Functionとして定義した関数は必ずPromiseインスタンスを返します。 具体的にはAsync Functionが返す値は次の3つのケースが考えられます。

  1. Async Functionは値をreturnした場合、その返り値をもつFulfilledなPromiseを返す
  2. Async FunctionがPromiseをreturnした場合、その返り値のPromiseをそのまま返す
  3. Async Function内で例外が発生した場合は、そのエラーをもつRejectedなPromiseを返す

次のコードでは、Async Functionがそれぞれの返り値によってどのようなPromiseインスタンスを返すかを確認できます。 この1から3の挙動はPromise#thenメソッドの返り値とそのコールバック関数の関係とほぼ同じです。

// 1. resolveFnは値を返している
// 何もreturnしていない場合はundefinedを返したのと同じ扱いとなる
async function resolveFn() {
    return "返り値";
}
resolveFn().then(value => {
    console.log(value); // => "返り値"
});

// 2. rejectFnはPromiseインスタンスを返している
async function rejectFn() {
    return Promise.reject(new Error("エラーメッセージ"));
}

// rejectFnはRejectedなPromiseを返すのでcatchできる
rejectFn().catch(error => {
    console.log(error.message); // => "エラーメッセージ"
});

// 3. exceptionFnは例外を投げている
async function exceptionFn() {
    throw new Error("例外が発生しました");
    // 例外が発生したため、この行は実行されません
}

// Async Functionで例外が発生するとRejectedなPromiseが返される
exceptionFn().catch(error => {
    console.log(error.message); // => "例外が発生しました"
});

どの場合でもAsync Functionは必ずPromiseを返すことがわかります。 このようにAsync Functionを呼び出す側から見れば、Async FunctionはPromiseを返すただの関数と何も変わりません。

await

Async Functionの関数内ではawait式を利用できます。 await式は右辺のPromiseインスタンスがFulfilledまたはRejectedになるまでその場で非同期処理の完了を待ちます。 そしてPromiseインスタンスの状態が変わると、次の行の処理を再開します。

async function asyncMain() {
    // PromiseがFulfilledまたはRejectedとなるまで待つ
    await Promiseインスタンス;
    // Promiseインスタンスの状態が変わったら処理を再開する
}

普通の処理の流れでは、非同期処理を実行した場合にその非同期処理の完了を待つことなく、次の行(次の文)を実行します。 しかしawait式では非同期処理を実行し完了するまで、次の行(次の文)を実行しません。 そのためawait式を使うことで非同期処理が同期処理のように上から下へと順番に実行するような処理順で書けます。

// async functionは必ずPromiseを返す
async function doAsync() {
    // 非同期処理
}
async function asyncMain() {
    // doAsyncの非同期処理が完了するまでまつ
    await doAsync();
    // 次の行はdoAsyncの非同期処理が完了されるまで実行されない
    console.log("この行は非同期処理が完了後に実行される");
}

await式はであるため右辺(Promiseインスタンス)の評価結果を値として返します(については「文と式」の章を参照)。 このawait式の評価方法は評価するPromiseの状態(FulfilledまたはRejected)によって異なります。

await式の右辺のPromiseがFulfilledとなった場合は、resolveされた値がawait式の返り値となります。

次のコードでは、await式の右辺にあるPromiseインスタンスは42という値でresolveされています。 そのためawait式の返り値は42となり、value変数にもその値が入ります。

async function asyncMain() {
    const value = await Promise.resolve(42);
    console.log(value); // => 42
}
asyncMain(); // Promiseインスタンスを返す

これはPromiseを使って書くと次のコードと同様の意味となります。 await式を使うことでコールバック関数を使わずに非同期処理の流れを表現できていることがわかります。

function asyncMain() {
    return Promise.resolve(42).then(value => {
        console.log(value); // => 42
    });
}
asyncMain(); // Promiseインスタンスを返す

await式の右辺のPromiseがRejectedとなった場合は、その場でエラーをthrowします。 またAsync Function内で発生した例外は自動的にキャッチされます。 そのためawait式でPromiseがRejectedとなった場合は、そのAsync FunctionがRejectedなPromiseを返すことになります。

次のコードでは、await式の右辺にあるPromiseインスタンスがRejectedの状態になっています。 そのためawait式はエラーthrowするため、asyncMain関数はRejectedなPromiseを返します。

async function asyncMain() {
    const value = await Promise.reject(new Error("エラーメッセージ"));
    // await式で例外が発生したため、この行は実行されません
}
// Async Functionは自動的に例外をキャッチできる
asyncMain().catch(error => {
    console.log(error.message); // => "エラーメッセージ"
});

await式がエラーをthrowするということは、そのエラーはtry...catch構文でキャッチできます(詳細は「try...catch構文」の章を参照)。 通常の非同期処理では完了する前に次の行が実行されてしまうためtry...catch構文ではエラーをキャッチできませんでした。そのためPromiseではcatchメソッドを使いPromise内で発生したエラーをキャッチしていました。

次のコードでは、await式で発生した例外をtry...catch構文でキャッチしています。

async function asyncMain() {
    // await式のエラーはtry...catchできる
    try {
        const value = await Promise.reject(new Error("エラーメッセージ"));
        // await式で例外が発生したため、この行は実行されません
    } catch (error) {
        console.log(error.message); // => "エラーメッセージ"
    }
}
asyncMain().catch(error => {
    // すでにtry...catchされているため、この行は実行されません
});

このようにawait式を使うことで、try...catch構文のように非同期処理を同期処理と同じ構文を使って扱えます。 またコードの見た目も同期処理と同じように、その行(その文)の処理が完了するまで次の行を評価しないという分かりやすい形になるのは大きな利点です。

await式はAsync Functionの中でのみ利用可能

await式はAsync Functionの直下でのみで利用可能です。 なぜこのような仕様になっているのかを確認していきます。

まず、Async Functionではない通常の関数でawait式を使うとSyntax Errorとなります。 これは、間違ったawait式の使い方を防止するための仕様です。

function main(){
    // Syntax Error
    await Promise.resolve();
}

次に、Async Function内でawait式を使って処理を待っている間も、関数の外側では通常通り処理が進みます。 次のコードを実行してみると、Async Function内でawaitしても、Async Function外の処理は停止していないことがわかります。

async function asyncMain() {
    // 中でawaitしても、Async Functionの外側の処理まで止まるわけではない
    await new Promise((resolve) => {
        setTimeout(resolve, 16);
    });
};
console.log("1. asyncMain関数を呼び出します");
// Async Functionは外から見れば単なるPromiseを返す関数
asyncMain().then(() => {
    console.log("3. asyncMain関数が完了しました");
});
// Async Functionの外側の処理はそのまま進む
console.log("2. asyncMain関数外では、次の行が同期的に呼び出される");

このようにawait式を非同期処理を一時停止しても、Async Function外の処理が停止するわけではありません。 Async Function外の処理も停止できてしまうと、JavaScriptでは基本的にメインスレッドで多くの処理をするためのUIを含めた他の処理が止まってしまいます。 これがawait式がAsync Functionの範囲外で利用できない理由の一つです。

await式はAsync Functionの中でのみ利用可能なため、次のようなコールバック関数ではawait式が利用できないことに注意してください。

次のコードではawait式はasyncMain関数の直下ではなく、forEachメソッドのコールバック関数に書かれているためSyntax Errorとなります。

// コールバック関数で構文エラーとなる例
async function asyncMain(){
    const promises = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)];
    promises.forEach(promise => {
        // Syntax Error
        await promise;
    });
}

このコードを正しく書くには、次のようにコールバック関数に対してasyncキーワードをつける必要があります。 この場合は、コールバック関数がAsync Functionとなるため、コールバック関数内でawait式が利用できます。

// 正しいAsync Functionとコールバック関数の書き方
async function asyncMain() {
    const promises = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)];
    promises.forEach(async promise => {
        await promise;
    });
}

Promiseチェーンをawait式で表現する

Async Functionとawait式を使うことでPromiseチェーンとして表現していた非同期処理を同期処理のような見た目でかけます。

たとえば、次のようなリソースAとリソースBを順番に取得する処理をPromiseチェーンで書くと次のようになります。

function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/resource")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}
// リソースAとリソースBを順番に取得する
function fetchResources() {
    const results = [];
    return dummyFetch("/resource/A").then(response => {
        results.push(response.body);
        return dummyFetch("/resource/B");
    }).then(response => {
        results.push(response.body);
    }).then(() => {
        return results;
    });
}
// リソースを取得して出力する
fetchResources().then((results) => {
    console.log(results); // => ["Response body of /resource/A", "Response body of /resource/B"]
});

このコードと同じ処理をAsync Functionとawait式で書くと次のように書けます。

function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/resource")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}
// リソースAとリソースBを順番に取得する
async function fetchResources() {
    const results = [];
    const responseA = await dummyFetch("/resource/A");
    results.push(responseA.body);
    const responseB = await dummyFetch("/resource/B");
    results.push(responseB.body);
    return results;
}
// リソースを取得して出力する
fetchResources().then((results) => {
    console.log(results); // => ["Response body of /resource/A", "Response body of /resource/B"]
});

PromiseチェーンでfetchResources関数書いた場合は、コールバックの中で処理するためややこしい見た目になりがちです。 一方、Async Functionとawait式で書いた場合は、取得と追加を順番に行うだけとなりネストがなく見た目はシンプルです。

PromiseとAsync Functionは共存する

Async Functionとawait式でも非同期処理を同期処理のような見た目で書けます。 一方で同期処理のような見た目となるため、複数の非同期処理を順番に行うようなケースでは無駄な待ち時間を作ってしまうコードを書きやすいです。

先ほどfetchResources関数ではリソースAを取得し終わってから、リソースBを取得していました。 このとき、取得順が関係無い場合はリソースAとリソースBを同時に取得できます。

PromiseチェーンではPromise.allメソッドを使い、リソースAとリソースBを取得する非同期処理を1つのPromiseインスタンスにまとめることで同時に取得していました。 await式が評価するのはPromiseインスタンスであるため、await式はPromise.allメソッドなどPromiseインスタンスを返す処理と組み合わせて利用できます。

そのため、先ほどfetchResources関数でリソースを同時に取得する場合は、次のように書けます。 Promise.allメソッドは複数のPromiseを配列で受け取り、それを1つのPromiseとしてまとめたものを返す関数です。 Promise.allメソッドの返すPromiseインスタンスをawaitすることで、非同期処理の結果を配列としてまとめて取得できます。

function dummyFetch(path) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (path.startsWith("/resource")) {
                resolve({ body: `Response body of ${path}` });
            } else {
                reject(new Error("NOT FOUND"));
            }
        }, 1000 * Math.random());
    });
}
// リソースAとリソースBを同時に取得する
async function fetchResources() {
    // Promise.allは [ResponseA, ResponseB] のように結果を配列にしたPromiseインスタンスを返す
    const responses = await Promise.all([
        dummyFetch("/resource/A"),
        dummyFetch("/resource/B")
    ]);
    return responses.map(response => {
        return response.body;
    });
}
// リソースを取得して出力する
fetchResources().then((results) => {
    console.log(results); // => ["Response body of /resource/A", "Response body of /resource/B"]
});

このようにAsync Functionやawait式は既存のPromiseと組み合わせて利用できます。 Async Functionも内部的にPromiseの仕組みを利用しているため、両者は対立関係ではなく共存関係です。

コールバック関数とAsync Function

Async Functionとawait式はPromiseチェーンに比べてコードを読みやすくしますが、すべてのケースでそうとはいえません。 ここではawait式とコールバック関数の組み合わせにおいての直感的ではない動作を紹介します。

次のコードはAsyncStorageという擬似的に非同期で読み書きするストレージクラスを使う例です。 main関数ではsaveUsers関数でユーザーデータを保存し、保存が完了後にユーザーデータが読み出せるかをチェックしています。

class AsyncStorage {
    constructor() {
        this.dataMap = new Map();
    }
    async save(key, value) {
        return new Promise(resolve => {
            setTimeout(() => {
                this.dataMap.set(key, value);
                resolve();
            }, 100);
        });
    }
    async load(key) {
        return new Promise(resolve => {
            setTimeout(() => {
                resolve(this.dataMap.get(key));
            }, 50);
        });
    }
}
// Async Storageを作成する
const storage = new AsyncStorage();
// 1. AsyncStorageにデータを保存する
async function saveUsers(users) {
    users.forEach(async(user) => {
        await storage.save(user.id, user);
    });
}
// 2. AsyncStorageからデータを読み取る
async function loadUser(userId) {
    return storage.load(userId);
}
async function main() {
    const users = [{ id: 1, name: "John" }, { id: 5, name: "Smith" }, { id: 7, name: "Ayo" }];
    await saveUsers(users);
    // idが5のユーザーデータを取り出す
    const user = await loadUser(5);
    // しかしまだ保存が完了していないためundeinedとなる
    console.log(user); // => undefined
}
main();

このコードはusers(ユーザーデータ)をストレージに保存し、保存が完了後にすぐ読み出せることを意図しています。 しかし、saveUsers関数を呼び出し後にloadUserでユーザーデータを読み出してもundefinedが返されます。 これは保存が完了する前に、ユーザーデータを読み取ろうとしてしまったため空の値であるundefinedが返されています。

なぜ意図したように動いていないかというとsaveUsers関数の実装に問題があるためです。

saveUsers関数を詳しく見ていきます。 forEachメソッドのコールバック関数としてAsync Functionを渡しています。 Async Functionの中でawait式を利用して非同期処理の完了を待っています。 しかし、この非同期処理の完了を待つのはAsync Functionの中だけで、外側ではsaveメソッドの完了を待つことなく進みます。

次のようにsaveUsers関数にコンソール出力を入れてみると動作が分かりやすいでしょう。 forEachメソッドのコールバック関数が完了するのは、saveUsers関数の呼び出しがすべて終わった後になります。 そのためawait式でsaveUsers関数の完了を待ったつもりでも、その時点ではStorageに値が保存されていません。

async function saveUsers(users) {
    console.log("1. saveUsers関数開始");
    users.forEach(async(user) => {
        // 非同期処理が完了するまで待つ
        await storage.save(user.id, user);
        console.log(`3. UserId:${user.id}を保存しました`);
    });
    console.log("2. saveUsers関数終了");
}

この問題を修正する方法はいくつかありますが、ここでは2種類の方法を見ていきます。

1つめの方法は、Async Functionをコールバック関数に利用しない方法です。 次のコードのようにforEachメソッドではなくforループを利用すれば、特別な工夫をせずにユーザーデータを保存できます。

async function saveUsers(users) {
    console.log("1. saveUsers関数開始");
    for (let i = 0; i < user.length; i++) {
        const user = users[i];
        // コールバック関数ではないので、`saveUsers`関数の処理もここで一時停止する
        await storage.save(user.id, user);
        console.log(`2. UserId:${user.id}を保存しました`);
    };
    console.log("3. saveUsers関数終了");
}

2つめの方法は、Async Functionを使ったコールバック関数の結果のPromiseがすべて完了するのを待つ方法です。 Async FunctionはそれぞれPromiseを返すため、すべてのPromiseの完了を明示的に待てばよいはずです。 複数のPromiseの完了を待つにはPromise.allメソッドで1つのPromiseにまとめてawait式でそのPromiseの完了を待てばよいだけです。

次のコードはArray#forEachメソッドではなく、Array#mapメソッドを使いコールバック関数の結果を集めています。 その集めたPromiseをPromise.allメソッドで1つのPromiseにして、await式で完了するまで待つだけです。

async function saveUsers(users) {
    console.log("1. saveUsers関数開始");
    const promises = users.map(async user => {
        await storage.save(user.id, user);
        console.log(`2. UserId:${user.id}を保存しました`);
    });
    // すべての保存処理のPromiseを完了を待つ
    await Promise.all(promises);
    console.log("3. saveUsers関数終了");
}

AsyncStorage#saveメソッドはもともとPromiseを返すため、次のようにAsync Functionをコールバック関数にしなくても動作は同じです。

async function saveUsers(users) {
    const promises = users.map(user => {
        return storage.save(user.id, user);
    });
    await Promise.all(promises);
}

最後にもともとのコードのsaveUsers関数を修正し、動作を確認してみます。

次のようにsaveUsers関数の問題を修正することで、saveUsers関数の完了する前にユーザーデータがストレージに保存できます。 await saveUsers(ユーザーデータ)で完了を待つことで、次の行でストレージからデータを取り出したときに意図したようにデータを取得できます。

class AsyncStorage {
    constructor() {
        this.dataMap = new Map();
    }
    async save(key, value) {
        return new Promise(resolve => {
            setTimeout(() => {
                this.dataMap.set(key, value);
                resolve();
            }, 100);
        });
    }
    async load(key) {
        return new Promise(resolve => {
            setTimeout(() => {
                resolve(this.dataMap.get(key));
            }, 50);
        });
    }
}
// Async Storageを作成する
const storage = new AsyncStorage();
// 1. AsyncStorageにデータを保存する
async function saveUsers(users) {
    // Storege#saveはそれぞれPromiseを返すためAsync Functionをコールバックにしなくても良い
    const promises = users.map(user => storage.save(user.id, user));
    await Promise.all(promises);
    return; // 返り値は明示的になしにしているため、undefinedでresolveされるPromiseを返す
}
// 2. AsyncStorageからデータを読み取る
async function loadUser(userId) {
    return storage.load(userId);
}
async function main() {
    const users = [{ id: 1, name: "John" }, { id: 5, name: "Smith" }, { id: 7, name: "Ayo" }];
    await saveUsers(users);
    // idが5のユーザーデータを取り出す
    const user = await loadUser(5);
    console.log(user); // => { id: 5, name: "Smith" }
}
main();

Async Functionは非同期処理のコードフローを分かりやすくしますが、コールバック関数に利用した際には分かりにくくする場合もあります。 そのため、無理してすべてをAsync Functionで書く必要はなく、Promiseの仕組みそのものを利用することも重要です。 Async FunctionはPromiseの上に作られた仕組みであるため、両者を一緒に利用することも考えてみてください。