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では、処理の完了を待って次の処理を実行するために、コールバック関数が多用されました。しかし、非同期処理が複数連鎖すると、コールバック関数が深くネストされ、コードの可読性や保守性が著しく低下します。これを「コールバック地獄」と呼びます。
| |
この問題を解決するために、ES2015で Promise が導入されました。
参考
Promiseオブジェクト [ES2015]
Promise は、ES2015(ECMAScript 2015)で導入された、非同期処理の**最終的な完了(または失敗)**とその結果の値を表現するオブジェクトです。非同期処理の状態をラップし、その状態が変化した際に登録されたコールバック関数を呼び出す仕組みを提供します。
Promiseの基本的な使い方
Promise コンストラクタは、resolve と reject という2つの引数を持つ関数を受け取ります。
| |
Promiseの状態
Promise インスタンスは、内部的に以下の3つの状態のいずれかをとります。
- Pending (保留): 非同期処理がまだ完了していない初期状態です。
- Fulfilled (成功): 非同期処理が成功し、結果の値が利用可能になった状態です。
resolve()が呼び出されたときにこの状態になります。 - Rejected (失敗): 非同期処理が失敗し、エラーが発生した状態です。
reject()が呼び出されたときにこの状態になります。
一度 Fulfilled または Rejected になった Promise インスタンスは、それ以降別の状態に変化することはありません。
Promiseチェーン
複数の非同期処理を順番に実行したい場合に、Promiseチェーン を利用します。then() メソッドは常に新しい Promise インスタンスを返すため、その返り値に対してさらに then() や catch() メソッドを連結できます。これにより、コールバック地獄を解消し、コードの可読性を向上させます。
| |
Promiseの課題
Promise はコールバック地獄を解決しましたが、以下のような課題も残っていました。
- 非同期処理間の連携が
then()メソッドの連鎖となり、同期的なコードに比べて依然として特殊な記述スタイルとなる。 - エラーハンドリングが
try...catch構文とは異なるcatch()メソッドで行われるため、直感的に理解しにくい場合があります。 Promiseはあくまでオブジェクトであり、言語の構文レベルでのサポートではないため、より自然な非同期処理の記述が求められた。
参考
Async/Await [ES2017]
async/await は、Promise をベースにした、非同期処理をより同期的なコードのように記述するためのJavaScriptの構文です。
async 関数
関数の前に async キーワードを付けることで、その関数が非同期関数であることを宣言します。async 関数は、常に Promise インスタンスを返します。
| |
await 式
async 関数内でのみ使用できる await キーワードは、Promise の解決(Fulfilled または Rejected になる)を待つ構文です。await 式を使うことで、非同期処理の完了を待ってから次の行のコードを実行できるため、Promise チェーンで実現していた処理の流れを、より直感的で読みやすい同期的なスタイルで記述できます。
| |
エラーハンドリング
await 式は、Promise が Rejected になった場合にエラーを throw します。これにより、非同期処理のエラーを通常の同期処理と同様に try...catch 構文で捕捉できるようになり、エラーハンドリングが非常にシンプルになります。
| |
async/await は、JavaScriptの非同期処理を劇的に改善し、より複雑な非同期ロジックも簡潔に記述することを可能にしました。