フロントエンドを学び直す#6 (非同期のJavaScript)
今回は非同期通信について学んでいきます。
コールスタック
コールスタックとは、JavaScriptエンジンが関数呼び出しの順序と実行状態を管理するためのデータ構造で、関数が呼び出されるごとにその関数の実行コンテキストをスタックに積み上げ、関数が終了するごとにそれをポップして処理します。これにより、どの関数が実行中で、その関数が呼び出した他の関数の順序を追跡できます。コールスタックは同期処理に使われるため、非同期タスクはコールスタックの外で管理され、コールバックキューとイベントループを通じて処理されます。
コールスタックは、LIFO(Last In, First Out)方式で動作します。
シングルスレッドと非同期処理
JavaScriptはシングルスレッドの非同期プログラミング言語で、1つのスレッド上でコードを順次実行します。実行するタスクは一度に1つしか処理されないということです。しかし、JavaScriptが非同期処理をサポートするためには、メインのスレッドとは独立して処理を管理するメカニズムが必要です。
非同期処理の背後には、実際にはJavaScriptエンジン以外の仕組み(例えばブラウザのAPIやNode.jsのバックエンド)を使って、非同期的なタスクを処理している部分が存在します。これが重要な点です。非同期処理はJavaScriptの実行スレッドで直接行われるのではなく、外部の仕組み(例えばブラウザやOS)によって処理される場合が多いのです。
具体的な非同期処理の流れを説明すると:
- ブラウザやNode.jsのAPIが関与します。
setTimeout
やHTTPリクエスト
などの非同期操作が発生すると、その処理はJavaScriptのエンジン外部で実行されます。例えば、setTimeout
のカウントダウンはブラウザやOSが担当しています。
- 非同期処理が完了すると、ブラウザやNode.jsのバックエンドがコールバック関数をコールバックキューにプッシュします。
- つまり、非同期タスクの実行中、JavaScriptのメインスレッドはその処理を待たずに他の同期処理を続けることができます。
- イベントループがコールバックキューをチェックします。
- メインのコールスタックが空になった時点で、イベントループがコールバックキューに溜まったタスクを1つずつコールスタックに送り込んで処理を再開します。
イベントループの役割
イベントループの役割は、コールスタックが空いた瞬間に、コールバックキューから次のタスクをスタックに積み込むことです。この仕組みのおかげで、JavaScriptのエンジンは1つのスレッドで複数のタスクを管理できますが、同時並行的に複数のタスクを実行するわけではありません。全てのタスクは順次(シリアルに)実行されます。
コールバック地獄
コールバック地獄(Callback Hell)は、JavaScriptで非同期処理を扱う際に、多数のネストされたコールバック関数が使われることで、コードが非常に複雑かつ可読性が低くなる状態を指します。別名「ピラミッドオブデス」(Pyramid of Doom)とも呼ばれ、コードが深く右にネストしていく形状からこの名前がつけられています。
doSomething(function(result1) {
doSomethingElse(result1, function(result2) {
doMoreStuff(result2, function(result3) {
doFinalThing(result3, function(finalResult) {
console.log('All done!');
});
});
});
});
Promise
Promise は、JavaScriptで非同期処理を扱うためのオブジェクトです。非同期処理が成功(resolved)したか失敗(rejected)したかを追跡し、その結果を簡単にハンドリングできるようにする仕組みです。
Promiseを使うことで、従来のコールバック関数を使った非同期処理よりもコードが明確になり、エラーハンドリングや複数の非同期処理の連携が簡単になります。
Promiseの基本的な構造
Promiseは以下の3つの状態を持ちます:
- Pending(保留中):非同期処理がまだ完了していない初期状態。
- Fulfilled(解決済み):非同期処理が正常に完了した状態。
- Rejected(拒否済み):非同期処理が失敗した状態。
let promise = new Promise(function(resolve, reject) {
// 非同期処理をここで実行
let success = true;
if (success) {
resolve("Success!"); // 成功時
} else {
reject("Error!"); // 失敗時
}
});
Promiseは、非同期処理が完了した際に resolve
または reject
が呼ばれ、Promiseの状態が fulfilled
または rejected
に変わります。
Promiseを使った非同期処理のハンドリング
then
メソッドを使って、Promiseが解決した(fulfilled)ときの処理を行い、catch
メソッドでエラーハンドリングを行います。
promise
.then(result => {
console.log(result); // "Success!" が表示される
})
.catch(error => {
console.error(error); // エラー時の処理
});
コールバックとPromiseの比較
Promiseのメリット
- コードがフラットで読みやすい:
- コールバックの場合、ネストが深くなるほどコードの可読性が低下しますが、Promiseは
then
メソッドをチェーンして使うことで、コードがフラットで直線的になります。 - 例えば、複数の非同期処理をPromiseで書くと、次のように記述できます。
- コールバックの場合、ネストが深くなるほどコードの可読性が低下しますが、Promiseは
doSomething()
.then(result => doSomethingElse(result))
.then(result => doMoreStuff(result))
.then(result => doFinalThing(result))
.catch(error => console.error(error));
2. 非同期処理の連鎖が簡単:
- コールバック関数の場合、次の非同期処理に進むためにコールバック内で別の非同期関数を呼び出さなければならず、順序管理が難しくなります。Promiseでは、
then
メソッドで次の処理を簡単に連鎖させることができ、シンプルに記述できます
async / await
async
キーワードを付けた関数は、Promise
を返し、関数内部で await
を使用することで、非同期的な処理を待機することができます。await
を使っている部分は他のコードの実行をブロックせず、バックグラウンドで動きます。
特徴
- ノンブロッキング:時間のかかる処理(たとえば、APIリクエストやファイル読み込み)があっても、それが完了するまで他の処理をブロックしません。
Promise
オブジェクトを返す:関数の結果は通常の値ではなく、Promise
として返されます。await
を使って結果を待つか、.then()
や.catch()
を使ってハンドリングします。await
は非同期関数内でのみ使うことができ、非同期関数は最終的にPromise
を返します。
async function asyncFunction() {
console.log("1. 非同期処理開始");
await new Promise(resolve => setTimeout(resolve, 2000)); // 2秒待機
console.log("2. 非同期処理完了");
}
asyncFunction();
console.log("3. 次の処理");
この場合の出力は以下のようになります。
1. 非同期処理開始
3. 次の処理
2. 非同期処理完了
また下記のように同期処理と同じようなエラーハンドリングを書くことができます。
async function getUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("ユーザーデータ取得完了");
resolve({ id: userId, name: "John" });
}, 1000);
});
}
async function getOrders(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("注文履歴取得完了");
resolve([{ orderId: 101 }, { orderId: 102 }]);
}, 1000);
});
}
async function getOrderDetails(orderId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`注文詳細取得完了: ${orderId}`);
resolve({ orderId: orderId, product: "Book" });
}, 1000);
});
}
// 使用例
async function fetchUserOrderDetails() {
try {
const userData = await getUserData(1);
const orders = await getOrders(userData.id);
const orderDetails = await Promise.all(
orders.map(order => getOrderDetails(order.orderId))
);
console.log(orderDetails);
} catch (error) {
console.error(error);
}
}
fetchUserOrderDetails();
メリット
- 可読性が向上: 非同期処理が同期処理のように書けるため、コードの流れが自然で直感的です。
- エラーハンドリングがシンプル:
try/catch
を使うことで、通常の同期処理と同様の形式でエラー処理ができます。 - Promiseチェーンを回避:
await
を使うことで、.then()
を連続で書く必要がなくなります。
以上です。