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エンジンとは別のスレッドで動作します。
- ブラウザが提供するAPI群(DOM操作、Ajaxリクエスト、
- イベントキュー / タスクキュー (Event Queue / Task Queue):
- Web APIから受け取ったコールバック関数がFIFO(先入れ先出し)形式で格納されるキューです。
イベントループは、以下の流れで非同期処理を調整します。
- JavaScriptコードが実行され、関数呼び出しがコールスタックに積まれます。
setTimeout
やfetch
などの非同期関数が呼び出されると、そのタスクはWeb APIに渡され、Web APIの実行環境で処理が開始されます。- Web APIでの処理が完了すると、その結果(またはコールバック関数)がイベントキューに格納されます。
- イベントループは、コールスタックが空になる(メインスレッドで実行中のタスクがなくなる)のを常に監視しています。
- コールスタックが空になると、イベントループはイベントキューから最初のコールバック関数を取り出し、コールスタックに積んで実行します。
参考
- 非同期処理 (1):Javascriptの動作の流れ (JS エンジン/Call Stack/Event Queue) | Zenn
- JavaScriptのイベントループを理解する | Qiita
- JavaScriptがブラウザでどのように動くのか | mercari engineering
コールバック地獄 (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
コンストラクタは、resolve
と reject
という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
式は、Promise
が Rejected
になった場合にエラーを 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の非同期処理を劇的に改善し、より複雑な非同期ロジックも簡潔に記述することを可能にしました。