JavaScriptのループと非同期処理の注意点を解説!初心者でもわかる使い方と落とし穴
生徒
「先生、JavaScriptでループを使って非同期の処理をするときに注意することはありますか?」
先生
「はい、JavaScriptのループと非同期処理は一緒に使うとちょっと難しい部分があります。特に順番やタイミングに気をつける必要がありますよ。」
生徒
「具体的にはどんな問題が起きるんですか?」
先生
「それでは、基本の仕組みとよくある注意点をわかりやすく説明しますね!」
1. JavaScriptの非同期処理とは?
非同期処理(ひどうきしょり)とは、「ある処理が終わるのを待たずに、次の処理を進めること」を言います。たとえば、ネットからデータを取ってくる処理は時間がかかるので、待っている間に他の処理を先に進められる仕組みです。
JavaScriptでは、この非同期処理を使ってプログラムの動きをスムーズにしています。
2. ループ処理と非同期処理が一緒に使われるときの問題
普通のループ処理は順番に1つずつ処理をしますが、非同期処理は終わる順番がバラバラになります。これが原因で、思った通りの順番で結果が得られなかったり、変数の値が期待と違ったりすることがあります。
たとえば、forループの中で非同期の関数を使うときに注意が必要です。
3. よくある問題例:変数のスコープと非同期処理
次の例を見てください。ループで変数iを使って非同期処理を呼んでいますが、意図した値が出ません。
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
実行結果は「3」「3」「3」となります。なぜでしょうか?
これは、varで宣言した変数のスコープ(有効範囲)が関係しています。varはループの外でも同じ変数を使うため、ループ終了後の値「3」がすべての非同期処理で使われてしまうのです。
4. 解決方法① letを使う
letはブロックスコープ({}内だけ有効な変数)なので、ループの各回ごとに別の変数として扱われます。これで問題を解決できます。
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
実行結果は「0」「1」「2」となり、期待通りです。
5. 解決方法② 即時関数(IIFE)を使う
古いJavaScriptでも対応できる方法として、即時実行関数(IIFE)があります。ループの中で即座に関数を呼び出して変数の値を閉じ込めます。
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => {
console.log(j);
}, 100);
})(i);
}
これも「0」「1」「2」と表示されます。
6. async/awaitでループの非同期処理を順番に実行する
JavaScriptのasyncとawaitは、非同期処理を順番に書くときに便利です。awaitは非同期処理の完了を待つ意味です。
async function processArray(arr) {
for (const item of arr) {
await waitAndLog(item);
}
}
function waitAndLog(value) {
return new Promise(resolve => {
setTimeout(() => {
console.log(value);
resolve();
}, 100);
});
}
processArray([10, 20, 30]);
この例では、配列の値を順番に1つずつログに出しています。非同期でも順番が守られます。
7. 注意点:forEachはawaitと相性が悪い
forEachは内部で処理を繰り返すため、awaitを使っても全ての非同期処理が同時に走ってしまい、順番に処理できません。
const arr = [1, 2, 3];
arr.forEach(async (num) => {
await waitAndLog(num);
});
順番に実行したい場合は、for...ofループを使いましょう。
8. まとめなしで最後にひとこと
JavaScriptのループと非同期処理は、一緒に使うときに変数のスコープや処理の順番に注意が必要です。letやasync/awaitを上手に使って、正しく動くコードを書きましょう。
まとめ
今回の記事では、JavaScript開発において多くの初心者が、そして時には中級者以上であっても陥りやすい「ループ処理と非同期処理の組み合わせ」について詳しく解説してきました。Webフロントエンド開発やNode.jsを用いたサーバーサイド開発において、APIからのデータ取得やファイルの入出力、タイマー処理といった非同期処理は避けて通れない要素です。それらを繰り返し処理の中で扱う際には、JavaScriptの基本的な動作原理であるイベントループやスコープの概念を正しく理解しておくことが、バグを防ぐための最短ルートとなります。
非同期処理における変数の挙動と最新の対策
かつて主流だったvar宣言による変数管理は、関数スコープという性質上、非同期処理のコールバックが実行される頃にはループが終了し、最終的な値のみが参照されてしまうという深刻な問題を引き起こしていました。しかし、現代のJavaScript(ES6以降)ではletやconstを用いることでブロックスコープが適用され、各ループの反復ごとに新しい変数の状態が保持されるようになっています。これにより、特別な細工をせずとも直感的なコードが書けるようになりました。
また、より高度な制御が必要な場合には、Promiseを同期的な見た目で記述できるasync/awaitが非常に強力です。特に「順番に1つずつ処理を終わらせたい(直列処理)」のか、「一斉に処理を開始して効率を上げたい(並列処理)」のかによって、構文を使い分ける必要があります。
実践的なサンプルプログラム:直列処理と並列処理
ここで、実際に開発現場でよく使われるテクニックをコードで振り返ってみましょう。まずは、配列の要素を1つずつ順番に処理する「直列実行」のパターンです。
/**
* データの配列を1つずつ順番にAPI送信するようなシミュレーション
*/
async function uploadSequentially(dataList) {
console.log("一括アップロードを開始します(直列)...");
for (const item of dataList) {
// 非同期処理の完了を待機する
const result = await simulateApiCall(item);
console.log(`処理完了: ${result}`);
}
console.log("すべてのアップロードが完了しました。");
}
function simulateApiCall(value) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`データ [${value}]`);
}, 500);
});
}
const targetData = ["画像1", "画像2", "画像3"];
uploadSequentially(targetData);
上記のコードを実行すると、各データが0.5秒ごとに1つずつ出力されます。一方で、処理速度を重視して一斉に実行したい場合は、以下のようにPromise.allを活用するのがベストプラクティスです。
/**
* データの配列を同時に全て送信するシミュレーション(並列)
*/
async function uploadInParallel(dataList) {
console.log("一括アップロードを開始します(並列)...");
// mapを使ってPromiseの配列を作成
const promises = dataList.map(item => simulateApiCall(item));
// 全てのPromiseが解決されるのを待つ
const results = await Promise.all(promises);
console.log("実行結果一覧:", results);
console.log("すべてのアップロードが完了しました。");
}
uploadInParallel(targetData);
実行結果は以下のようになります。
一括アップロードを開始します(並列)...
(約0.5秒の待機の後)
実行結果一覧: ["データ [画像1]", "データ [画像2]", "データ [画像3]"]
すべてのアップロードが完了しました。
さらなる応用:TypeScriptでの型定義
実務ではTypeScriptを使用して、より堅牢なコードを書く機会も多いでしょう。非同期ループを関数に切り出す際は、返り値の型をPromise<void>などに指定することを忘れないようにしましょう。
interface UserTask {
id: number;
title: string;
}
async function executeUserTasks(tasks: UserTask[]): Promise<void> {
for (let i: number = 0; i < tasks.length; i++) {
const task: UserTask = tasks[i];
await new Promise<void>((resolve) => {
setTimeout(() => {
console.log(`タスクID ${task.id}: ${task.title} を実行中...`);
resolve();
}, 300);
});
}
}
const myTasks: UserTask[] = [
{ id: 101, title: "メール送信" },
{ id: 102, title: "バックアップ作成" }
];
executeUserTasks(myTasks);
まとめとしての最終アドバイス
JavaScriptにおける非同期処理は、最初は戸惑うことが多いポイントです。しかし、「現在のループの状態を維持したいならletを使う」「順番を守りたいならfor...ofの中でawaitする」「一斉にやりたいならPromise.allを使う」という3つの原則を覚えておくだけでも、多くのトラブルを回避できます。
特にforEach内でのawaitが機能しない(意図せず並列に動いてしまう)という仕様は、現場でもよく見かける落とし穴です。常に「今、自分のコードは待機しているのか、それとも流し読みされているのか」を意識しながらコーディングを進めてみてください。
生徒
「先生、ありがとうございました!varを使っていた頃に起きていた『全部同じ数字になっちゃう現象』の理由がやっと分かりました。スコープって本当に大事なんですね。」
先生
「そうですね。昔のJavaScriptではクロージャや即時関数(IIFE)を使って苦労して解決していたんですよ。今はletがあるおかげで、随分と書きやすくなりましたね。」
生徒
「あと、forEachの中でawaitを使っても順番待ちをしてくれないというのは驚きました。配列を回すときはいつもforEachを使っていたので、これからは使い分けに気をつけます。」
先生
「素晴らしい気づきです!基本的にはfor...ofを使えば間違いありません。もしパフォーマンスを求めて並列で一気に終わらせたいときは、mapとPromise.allを組み合わせてみてください。これで非同期マスターに一歩近づきましたね!」
生徒
「はい!コードの書き方一つで、効率もバグの少なさも変わるんだと実感しました。これからも非同期処理の仕組みを意識して練習してみます!」