JavaScriptは、非同期処理を得意とする言語です。
非同期処理を理解することで、ネットワーク通信やタイマーなどの複雑な動作を効率よく処理できるようになります。
本記事では、非同期処理の基本から最新の実装方法まで、具体的なコード例を交えながら解説します。
1. 非同期処理の必要性
JavaScriptがシングルスレッドで動作する仕組み
JavaScriptは、シングルスレッドで動作するプログラミング言語です。
これは、同時に1つのタスクしか実行できないという意味です。
この仕組みは「イベントループ」によって制御されています。
イベントループは、実行中のコード(主に同期処理)が完了するのを待ち、それから次のタスクを処理するように設計されています。
この特性はシンプルな動作を保証しますが、次のような問題を引き起こします。
時間のかかる処理によるブロックの問題
もしJavaScriptが長時間実行されるタスク(例: 大量の計算や外部データの取得)に取り組む場合、その間は他のタスクを処理できません。
この状態をブロッキングと呼びます。ユーザーインターフェース(UI)の応答が遅くなったり、アプリケーションがフリーズしたように見える原因になります。
ブロッキングの具体例
function heavyTask() {
// 大量の計算を行う処理
for (let i = 0; i < 1e9; i++) {
// 時間のかかるループ処理
}
console.log("タスクが完了しました");
}
// UI操作
console.log("ボタンがクリックされました");
heavyTask();
console.log("次の処理に進みます");
上記のコードでは、heavyTaskが完了するまで次の処理が実行されません。
この間、UIも応答しなくなり、ユーザーエクスペリエンスが大きく損なわれます。
非同期処理でブロッキングを回避
非同期処理を活用することで、この問題を回避できます。
非同期処理を使うと、長時間かかる処理を「バックグラウンドで実行」する形になります。
JavaScriptはこの間に他のタスク(UIの操作や別の計算処理など)を並行して処理できます。
非同期処理の具体例
以下は、非同期処理を用いてAPIデータを取得するコード例です。
console.log("APIリクエストを送信中...");
setTimeout(() => {
console.log("サーバーからデータを受信しました");
}, 2000); // 2秒間待機
console.log("次の処理を開始します");
出力結果は次のようになります:
APIリクエストを送信中...
次の処理を開始します
サーバーからデータを受信しました
非同期処理を使うと、データの受信を待つ間も他の処理を進められます。
このように、非同期処理はスムーズなユーザー体験を保証する重要な役割を果たします。
実際のユースケース
非同期処理が特に役立つ具体例を挙げてみましょう。
- ネットワーク通信
• サーバーからデータを取得するAPIリクエスト(fetchやXMLHttpRequest)
• クラウドストレージにファイルをアップロード
• WebSocketを使ったリアルタイム通信 - ユーザーインターフェースの更新
• ボタンをクリックした後、ユーザーにフィードバックを表示(例: スピナーやロード中のメッセージ)
• フォーム送信時に入力内容をサーバーへ送信 - 時間がかかる処理の分散
• 動画や画像の加工
• 複雑な計算やシミュレーション
非同期処理がもたらすメリット
- スムーズな操作性:
ユーザーがアプリケーションを操作する際にフリーズすることがない。 - 効率的なリソース使用:
処理が完了するまで待機せず、他のタスクを並行して進められる。 - 優れたユーザーエクスペリエンス:
タスクがバックグラウンドで処理されるため、操作が快適。
2.コールバック関数
コールバック関数とは?
コールバック関数とは、ある処理が完了した後に実行される関数です。
非同期処理を実現するための基本的な仕組みで、処理の流れを柔軟に制御できます。
JavaScriptでは、関数が一級オブジェクト(関数を他の関数に引数として渡せる)であるため、コールバック関数を簡単に実装できます。
コールバック関数の仕組み
- 非同期処理の終了を検知
コールバック関数を指定することで、時間のかかる処理が終わったときにその結果を取得したり、次の処理を実行したりできます。 - 引数として渡す
コールバック関数は、別の関数の引数として渡されることで動作します。
実際の動作例
以下のコードは、非同期処理でコールバック関数を使用するシンプルな例です。
コールバック関数を利用した非同期処理
function fetchData(callback) {
setTimeout(() => {
console.log("データを取得しました");
callback("取得したデータ");
}, 2000); // 2秒後に実行
}
// fetchDataを呼び出し、結果をコールバック関数で処理
fetchData((data) => {
console.log("コールバック内で処理: ", data);
});
実行結果:
データを取得しました
コールバック内で処理: 取得したデータ
- fetchData関数では、2秒後に「データを取得しました」と表示され、次に渡されたコールバック関数が実行されます。
- コールバック関数により、非同期処理の結果を効率よく処理できます。
コールバック関数のメリット
- 柔軟性:
非同期処理の完了タイミングで実行する処理を自由に設定可能。 - 軽量な実装:
特別な構文を必要とせず、シンプルに非同期処理を実現。
コールバック地獄とは?
コールバック関数が複数ネストすると、コードの可読性が低下する問題が生じます。
これを「コールバック地獄」または「ピラミッドコード」と呼びます。
問題例: 多重コールバック
function fetchUserData(userId, callback) {
setTimeout(() => {
console.log(`ユーザー${userId}のデータを取得しました`);
callback({ userId, name: "Alice" });
}, 1000);
}
function fetchUserPosts(user, callback) {
setTimeout(() => {
console.log(`ユーザー${user.name}の投稿を取得しました`);
callback(["投稿1", "投稿2"]);
}, 1000);
}
function fetchComments(post, callback) {
setTimeout(() => {
console.log(`${post}のコメントを取得しました`);
callback(["コメント1", "コメント2"]);
}, 1000);
}
// 多重コールバックの例
fetchUserData(1, (user) => {
fetchUserPosts(user, (posts) => {
fetchComments(posts[0], (comments) => {
console.log("コメント: ", comments);
});
});
});
実行結果:
ユーザー1のデータを取得しました
ユーザーAliceの投稿を取得しました
投稿1のコメントを取得しました
コメント: [ 'コメント1', 'コメント2' ]
- 処理がネストされ、複雑になるにつれてコードが見づらくなります。
- デバッグや保守が難しくなり、エラーハンドリングも複雑になります。
コールバック地獄を解決する方法
この問題を解決するために、JavaScriptには以下の仕組みが導入されました。
- Promise:
コールバックをチェーン式に管理することでコードのネストを減少。 - async/await:
非同期処理を同期処理のように記述でき、さらに可読性が向上。
これらの仕組みについては次のセクションで詳しく解説します。
3.Promise
Promiseとは?
Promiseは、非同期処理の結果を表すJavaScriptのオブジェクトです。
非同期処理の状態や結果を効率よく管理でき、コールバック地獄を解消する手段として登場しました。
Promiseは、次の3つの状態を持ちます:
- Pending(保留中):
非同期処理がまだ完了していない状態。 - Fulfilled(成功):
非同期処理が成功し、結果が得られた状態(resolve関数が呼ばれる)。 - Rejected(失敗):
非同期処理が失敗し、エラーが発生した状態(reject関数が呼ばれる)。
Promiseの基本的な構文
const promise = new Promise((resolve, reject) => {
// 非同期処理を実行
const isSuccess = true; // 処理結果
if (isSuccess) {
resolve("成功しました!"); // 成功時
} else {
reject("失敗しました..."); // 失敗時
}
});
// 成功時、失敗時の処理
promise
.then((result) => {
console.log("成功: ", result);
})
.catch((error) => {
console.error("失敗: ", error);
});
- resolve:
非同期処理が成功したときに呼び出します。 - reject:
非同期処理が失敗したときに呼び出します。 - then:
成功時の処理を定義します。 - catch:
失敗時の処理を定義します。
実例: Promiseを使った非同期処理
以下は、Promiseを使ったAPIデータ取得の例です。
Promiseの利用例
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5; // 成功/失敗をランダムに決定
if (success) {
resolve("データ取得成功!");
} else {
reject("データ取得失敗...");
}
}, 2000); // 2秒後に実行
});
}
// Promiseの実行
fetchData()
.then((data) => {
console.log("成功: ", data);
})
.catch((error) => {
console.error("失敗: ", error);
});
出力例(ランダム):
成功: データ取得成功!
または
失敗: データ取得失敗...
- resolveが呼ばれるとthenの処理が実行されます。
- rejectが呼ばれるとcatchが実行されます。
Promiseのメリット
1. 可読性の向上
- コールバック関数をネストする必要がないため、コードがフラットで読みやすくなります。
- 処理の流れが視覚的に理解しやすくなります。
比較: コールバック関数 vs Promise
コールバック:
function fetchData(callback) {
setTimeout(() => {
callback("データ取得成功");
}, 1000);
}
fetchData((data) => {
console.log(data);
fetchData((data2) => {
console.log(data2);
fetchData((data3) => {
console.log(data3);
});
});
});
Promise:
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve("データ取得成功");
}, 1000);
});
}
fetchData()
.then((data) => {
console.log(data);
return fetchData();
})
.then((data2) => {
console.log(data2);
return fetchData();
})
.then((data3) => {
console.log(data3);
});
2. エラーハンドリングが簡単
- コールバック関数では、エラー処理が分散しやすく複雑になりますが、Promiseではcatchを使って一元的に管理できます。
例: 一括エラーハンドリング
fetchData()
.then((data) => {
console.log("成功1: ", data);
return fetchData();
})
.then((data2) => {
console.log("成功2: ", data2);
return fetchData();
})
.catch((error) => {
console.error("エラー発生: ", error);
});
3. 非同期処理の連鎖が可能
- Promiseはthenをチェーンできるため、複数の非同期処理を直列的に実行するのに適しています。
Promiseの限界と次のステップ
Promiseは便利ですが、次のような場合に冗長になることがあります:
- 複数の非同期処理を並行して行う場合。
- thenのチェーンが長くなった場合。
これを解決するために、JavaScriptにはasync/awaitが導入されました。
次のセクションで詳しく解説します。
4. asyncとawait
async/await とは?
async/await は、Promiseを扱うための構文で、非同期処理を同期処理のように直感的に記述できます。この機能はES2017(ES8)で導入され、JavaScriptでの非同期プログラミングを大幅に簡素化しました。
async と await の基本構文
1. async関数
- asyncキーワードを付けた関数は、常にPromiseを返す関数になります。
- 関数の戻り値がPromiseでなくても、自動的にPromiseでラップされます。
async function example() {
return "結果"; // 自動的に Promise.resolve("結果") に変換される
}
example().then((result) => console.log(result)); // "結果"
2. await
- awaitキーワードは、Promiseが解決するまで処理を一時停止します。
- awaitはasync関数の中でのみ使用可能です。
async function fetchData() {
const result = await Promise.resolve("データ取得成功");
console.log(result);
}
fetchData();
async/await を使った具体例
基本的な例
以下は、async/awaitを使った非同期処理の例です。
async function fetchData() {
return new Promise((resolve) => {
setTimeout(() => resolve("データ取得成功"), 2000); // 2秒後に解決
});
}
async function main() {
try {
console.log("処理を開始します...");
const data = await fetchData(); // Promiseが解決するまで待機
console.log("成功: ", data);
} catch (error) {
console.error("失敗: ", error);
}
}
main();
出力例:
処理を開始します...
成功: データ取得成功
- await fetchData() によって、Promiseが解決されるのを待ってから次の行が実行されます。
- try-catch構文でエラーを簡単にハンドリングできます。
非同期処理の直列実行
複数の非同期処理を直列(順番に)実行する場合、async/awaitを使うと非常にわかりやすく書けます。
async function task1() {
console.log("タスク1を開始");
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("タスク1完了");
}
async function task2() {
console.log("タスク2を開始");
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("タスク2完了");
}
async function main() {
await task1();
await task2();
console.log("全タスク完了");
}
main();
出力例:
タスク1を開始
タスク1完了
タスク2を開始
タスク2完了
全タスク完了
非同期処理の並行実行
直列実行とは異なり、非同期処理を並行して実行したい場合は、Promise.allと組み合わせます。
async function task1() {
console.log("タスク1を開始");
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log("タスク1完了");
}
async function task2() {
console.log("タスク2を開始");
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("タスク2完了");
}
async function main() {
await Promise.all([task1(), task2()]); // 並行して実行
console.log("全タスク完了");
}
main();
出力例:
タスク1を開始
タスク2を開始
タスク2完了
タスク1完了
全タスク完了
- タスク1とタスク2が並行して実行され、最短のタスク(タスク2)が先に完了します。
- Promise.allを使用することで、複数の非同期処理を効率よく実行できます。
async/await のメリット
- コールバック地獄の解消
• 非同期処理がネストすることなく、フラットなコードが書ける。
• コードの可読性が大幅に向上。 - 同期処理に近い書き方
• 非同期処理を同期処理のように記述できるため、処理の流れを直感的に理解できる。 - エラーハンドリングが簡単
• try-catch構文を利用することで、同期処理と同じようにエラーを扱える。
• 個別の処理や全体のエラーを効率よく管理可能。
例: Promiseチェーン vs async/await
Promiseチェーン:
fetchData()
.then((data1) => fetchData())
.then((data2) => fetchData())
.catch((error) => console.error(error));
async/await:
async function main() {
try {
const data1 = await fetchData();
const data2 = await fetchData();
const data3 = await fetchData();
} catch (error) {
console.error(error);
}
}
注意点
- awaitはasync関数の中でのみ使用可能
• awaitはasync関数の外で使うとエラーになります。 - 非同期処理の並列性を意識
• awaitを連続して使うと処理が直列実行されるため、並行して処理したい場合はPromise.allを利用するべきです。
まとめ
非同期処理は、モダンなJavaScript開発において欠かせないスキルです。
- コールバック関数:
非同期処理の基本だが、複雑化しやすい - Promise:
コールバック地獄を解消する手段 - async/await:
Promiseを簡潔に書ける新しい方法
これらを活用することで、効率的で保守性の高いコードを書くことができます。