JavaScriptの非同期処理:コールバックからPromise、Async/Awaitへ

JavaScriptで非同期処理が重要な理由

シングルスレッド

JavaScriptは基本的にシングルスレッドで動作します。これは、一度に1つのタスクしか実行できないことを意味します。Webブラウザ環境では、JavaScriptの実行スレッドは、ページのレイアウト、再描画(リフロー)、ガベージコレクションといったブラウザのUIレンダリング処理と同じスレッドを共有しています。

そのため、時間のかかるJavaScript処理がスレッドを占有してしまうと、ページの応答性が悪くなり、ユーザーインターフェースがフリーズしたように見えてしまう問題が発生します。この問題を解決するために、非同期処理が不可欠となります。

参考

非同期処理の仕組み

非同期処理は、時間のかかるタスク(例: ネットワークリクエスト、タイマー)をメインスレッドから切り離して実行し、そのタスクが完了するのを待たずに次の処理へ進むことを可能にします。これにより、UIのフリーズを防ぎ、複数の処理を並行して実行しているかのように見せることができます。

イベントループ (Event Loop)

JavaScriptの非同期処理は、イベントループという仕組みによって実現されています。JavaScriptエンジン(V8など)は、以下の主要なコンポーネントと連携して動作します。

  • JavaScriptエンジン:
    • ヒープ (Heap): オブジェクトや変数が格納されるメモリ領域です。
    • コールスタック (Call Stack): 実行中の関数呼び出しをLIFO(後入れ先出し)形式で管理する領域です。関数が呼び出されるとスタックに積まれ、実行が完了するとスタックから取り除かれます。
  • Web APIs (ブラウザ環境):
    • ブラウザが提供するAPI群(DOM操作、Ajaxリクエスト、setTimeout などのタイマー機能など)です。これらはJavaScriptエンジンとは別のスレッドで動作します。
  • イベントキュー / タスクキュー (Event Queue / Task Queue):
    • Web APIから受け取ったコールバック関数がFIFO(先入れ先出し)形式で格納されるキューです。

イベントループは、以下の流れで非同期処理を調整します。

  1. JavaScriptコードが実行され、関数呼び出しがコールスタックに積まれます。
  2. setTimeoutfetch などの非同期関数が呼び出されると、そのタスクはWeb APIに渡され、Web APIの実行環境で処理が開始されます。
  3. Web APIでの処理が完了すると、その結果(またはコールバック関数)がイベントキューに格納されます。
  4. イベントループは、コールスタックが空になる(メインスレッドで実行中のタスクがなくなる)のを常に監視しています。
  5. コールスタックが空になると、イベントループはイベントキューから最初のコールバック関数を取り出し、コールスタックに積んで実行します。

参考

コールバック地獄 (Callback Hell)

非同期処理の初期のJavaScriptでは、処理の完了を待って次の処理を実行するために、コールバック関数が多用されました。しかし、非同期処理が複数連鎖すると、コールバック関数が深くネストされ、コードの可読性や保守性が著しく低下します。これを「コールバック地獄」と呼びます。

// コールバック地獄の例
setTimeout(() => {
    console.log(1);
    setTimeout(() => {
        console.log(2);
        setTimeout(() => {
            console.log(3);
        }, 300); // 300ms後に実行
    }, 200); // 200ms後に実行
}, 100); // 100ms後に実行

この問題を解決するために、ES2015で Promise が導入されました。

参考

Promiseオブジェクト [ES2015]

Promise は、ES2015(ECMAScript 2015)で導入された、非同期処理の**最終的な完了(または失敗)**とその結果の値を表現するオブジェクトです。非同期処理の状態をラップし、その状態が変化した際に登録されたコールバック関数を呼び出す仕組みを提供します。

Promiseの基本的な使い方

Promise コンストラクタは、resolvereject という2つの引数を持つ関数を受け取ります。

// asyncPromiseTask関数は、Promiseインスタンスを返す
function asyncPromiseTask() {
    return new Promise((resolve, reject) => {
        // ここに非同期処理を記述
        // 成功した場合は resolve() を呼び出す
        // 失敗した場合は reject(エラーオブジェクト) を呼び出す
        const success = Math.random() > 0.5; // 例: 50%の確率で成功
        setTimeout(() => {
            if (success) {
                resolve("成功しました!");
            } else {
                reject(new Error("失敗しました..."));
            }
        }, 1000);
    });
}

// then() メソッドで、Promiseがresolve(成功)またはreject(失敗)したときに呼ばれるコールバック関数を登録
asyncPromiseTask()
    .then((result) => {
        console.log("成功時の処理:", result);
    })
    .catch((error) => { // catch() メソッドで失敗時の処理を登録 (推奨)
        console.error("失敗時の処理:", error.message);
    })
    .finally(() => { // finally() メソッドで成功/失敗に関わらず実行される処理を登録
        console.log("処理が完了しました。");
    });

Promiseの状態

Promise インスタンスは、内部的に以下の3つの状態のいずれかをとります。

  • Pending (保留): 非同期処理がまだ完了していない初期状態です。
  • Fulfilled (成功): 非同期処理が成功し、結果の値が利用可能になった状態です。resolve() が呼び出されたときにこの状態になります。
  • Rejected (失敗): 非同期処理が失敗し、エラーが発生した状態です。reject() が呼び出されたときにこの状態になります。

一度 Fulfilled または Rejected になった Promise インスタンスは、それ以降別の状態に変化することはありません。

Promiseチェーン

複数の非同期処理を順番に実行したい場合に、Promiseチェーン を利用します。then() メソッドは常に新しい Promise インスタンスを返すため、その返り値に対してさらに then()catch() メソッドを連結できます。これにより、コールバック地獄を解消し、コードの可読性を向上させます。

Promise.resolve()
    .then(() => {
        console.log("ステップ1");
        return asyncPromiseTask(); // Promiseを返す
    })
    .then((result) => {
        console.log("ステップ2:", result);
        return "次のデータ"; // 値を返す (自動的にPromise.resolveでラップされる)
    })
    .then((data) => {
        console.log("ステップ3:", data);
    })
    .catch((error) => {
        console.error("エラーが発生しました:", error.message);
    });

Promiseの課題

Promise はコールバック地獄を解決しましたが、以下のような課題も残っていました。

  • 非同期処理間の連携が then() メソッドの連鎖となり、同期的なコードに比べて依然として特殊な記述スタイルとなる。
  • エラーハンドリングが try...catch 構文とは異なる catch() メソッドで行われるため、直感的に理解しにくい場合があります。
  • Promise はあくまでオブジェクトであり、言語の構文レベルでのサポートではないため、より自然な非同期処理の記述が求められた。

参考

Async/Await [ES2017]

async/await は、Promise をベースにした、非同期処理をより同期的なコードのように記述するためのJavaScriptの構文です。

async 関数

関数の前に async キーワードを付けることで、その関数が非同期関数であることを宣言します。async 関数は、常に Promise インスタンスを返します。

async function doAsync() {
    return "値"; // この値は Promise.resolve("値") としてラップされる
}

// 上記は以下とほぼ同義
function doAsyncLegacy() {
    return Promise.resolve("値");
}

await

async 関数内でのみ使用できる await キーワードは、Promise の解決(Fulfilled または Rejected になる)を待つ構文です。await 式を使うことで、非同期処理の完了を待ってから次の行のコードを実行できるため、Promise チェーンで実現していた処理の流れを、より直感的で読みやすい同期的なスタイルで記述できます。

async function asyncMain() {
    console.log("処理開始");
    // Promiseが解決されるまで待機
    const result = await asyncPromiseTask(); // asyncPromiseTaskはPromiseを返す関数
    console.log("Promiseが解決されました:", result);
    console.log("処理終了");
}

asyncMain();

エラーハンドリング

await 式は、PromiseRejected になった場合にエラーを throw します。これにより、非同期処理のエラーを通常の同期処理と同様に try...catch 構文で捕捉できるようになり、エラーハンドリングが非常にシンプルになります。

async function asyncMainWithErrorHandling() {
    try {
        console.log("エラー発生の可能性のある処理開始");
        const value = await asyncPromiseTask(); // 失敗する可能性のあるPromise
        console.log("成功:", value);
    } catch (error) {
        console.error("エラーを捕捉しました:", error.message);
    } finally {
        console.log("finallyブロックが実行されました。");
    }
}

asyncMainWithErrorHandling();

async/await は、JavaScriptの非同期処理を劇的に改善し、より複雑な非同期ロジックも簡潔に記述することを可能にしました。