【実務・中級編】forループと非同期処理:varとletで挙動が激変する理由とクロージャの生成タイミング – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

コードレビューをしていて、未だに最も多く遭遇するアンチパターンの一つが「`for`ループと`setTimeout`の組み合わせによる非同期処理のバグ」だ。

「なぜかコンソールに配列の長さと同じ数値(例えば `5` や `10`)がループ回数分だけ出力されてしまう」
「APIリクエストをループ内で飛ばしたのに、すべて最後のインデックスを参照している」

原因は決まって、`var`の仕様を理解せずにクロージャとスコープの罠を踏み抜いていることだ。ネットの海を検索すれば「`let`を使いましょう」という浅い結論だけが転がっているが、テクニカルリードたる者、なぜそんな挙動になるのかをV8エンジンの実行コンテキスト(Execution Context)とメモリ上のLexical Environmentの変遷まで解像度高く語れなければならない。

今回は、JavaScriptの変数の寿命と非同期処理のランタイム挙動の核心に迫り、実務で絶対にバグを生まないための堅牢な設計パターンを伝授する。

—

1. なぜ `var` ではループ内の非同期処理が破綻するのか?

まずは、以下のコードを見てほしい。コードレビューでこれを提出されたら、即座に修正を要求するレベルの典型的なバグだ。

// 【アンチパターン】varを使ったループと非同期処理
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(`現在のインデックス: ${i}`); }, 1000); } このコードを実行すると、1秒後に以下のような出力が得られることを期待するかもしれない。 現在のインデックス: 0 現在のインデックス: 1 現在のインデックス: 2 しかし、実際にコンソールに出力されるのはこうだ。 現在のインデックス: 3 現在のインデックス: 3 現在のインデックス: 3

V8エンジンの内部挙動:なぜ `3` が3回出力されるのか?

この現象を紐解く鍵は、「変数の巻き上げ(Hoisting)」 と 「関数スコープの特性」、そして 「イベントループと非同期タスクの実行タイミング」 にある。

1. 関数スコープと単一の変数バインディング
`var i` は関数スコープ(またはグローバルスコープ)を持つ。`for`ループのブロックはスコープを作らないため、変数 `i` はループの外側(この場合はグローバル、または囲む関数スコープ)にたった1つだけ作成される。
2. ループの高速な完了
JavaScriptのメインスレッドは同期処理を高速に実行し、あっという間に `i = 3` までループを回し終える。この時点で、共有されている唯一の変数 `i` の値は `3` に固定される。
3. 後から実行される非同期コールバック
1秒後にタイマーが発火し、`setTimeout`に渡されたコールバック関数が実行される。このときコールバック関数は、クロージャを通じて外側のスコープにある変数 `i` を参照しに行く。しかし、その時にはすでに `i` の値は `3` に書き換わっているため、どのコールバックが実行されても `3` を参照してしまうのだ。

—

2. `let` がもたらす革命:ブロックスコープとシャドーイング

では、これを `let` に書き換えたらどうなるだろうか。

// 【モダンなアプローチ】letを使ったループ
for (let i = 0; i < 3; i++) { setTimeout(function() { console.log(`現在のインデックス: ${i}`); }, 1000); } 期待通り、1秒後に以下のように出力される。 現在のインデックス: 0 現在のインデックス: 1 現在のインデックス: 2

なぜ `let` だと正しく動くのか?

ES6で導入された `let`(および `const`)は、ブロックスコープ(Block Scope) を提供する。さらに、V8エンジンの内部仕様において、`for` ループのヘッダー内で `let` が宣言された場合、JavaScriptエンジンはループの各イテレーション(反復)ごとに、新しい変数バインディング(Lexical Environment)を裏側で自動生成する。

つまり、概念的には以下のようなコードがエンジン内部でエミュレートされている。

// V8の内部挙動の概念イメージ
{
let i = 0; // イテレーション0用の独立した i
setTimeout(() => console.log(i), 1000);
}
{
let i = 1; // イテレーション1用の独立した i
setTimeout(() => console.log(i), 1000);
}
{
let i = 2; // イテレーション2用の独立した i
setTimeout(() => console.log(i), 1000);
}

それぞれの非同期コールバックは、そのイテレーション専用に生成されたメモリ空間(Lexical Environment)をクロージャとしてキャプチャするため、他のループ回に干渉されることなく、自身の世代の `i` の値(0, 1, 2)を保持し続けることができる。これが、`let` によって挙動が激変するメカニズムの正体だ。

—

3. 実務で遭遇する「API並行リクエスト」の現実と堅牢な設計パターン

フロントエンド開発やNode.jsでのバックエンド連携において、ループ内で非同期処理(`fetch` やデータベースクエリなど)を実行し、その結果を順序を保証してハンドリングしたいシーンは多々ある。

ここで、パフォーマンスと保守性を両立させたプロダクションコードの設計パターンを見ていこう。

パターンA: `Promise.all` と `map` を活用した並行処理(最も推奨)

配列を加工して非同期処理を行う場合、`for` ループを回すよりも `Array.prototype.map` と `Promise.all` を組み合わせる方が、宣言的で可読性が高く、V8の最適化エンジンにとっても効率的だ。

/

  • 複数のユーザーIDに対して並行してAPIリクエストを送信し、結果をまとめて取得する関数
  • @param {string[]} userIds – ユーザーIDの配列
  • @returns {Promise} ユーザーデータの配列

/
async function fetchUsersConcurrently(userIds) {
try {
// mapを使って非同期処理(Promise)の配列を生成し、Promise.allで一括解決する
// ここでも各コールバックの引数やスコープは完全に分離されているため、バグが入り込む余地はない
const fetchPromises = userIds.map(async (userId, index) => {
console.log(`[${index}] リクエスト送信開始: ID ${userId}`);

const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${userId}`);
}

return await response.json();
});

// すべてのリクエストが完了するのを待つ(並行処理)
const users = await Promise.all(fetchPromises);
return users;

} catch (error) {
console.error(‘ユーザーデータの取得中にエラーが発生しました:’, error);
throw error;
}
}

パターンB: 非同期処理を直列(シーケンシャル)に実行したい場合

「前のリクエストの結果を次のリクエストで使いたい」あるいは「サーバーに負荷をかけないように1件ずつ順番に処理したい」という場合は、`for…of` ループを使用する。

ここでも `let`(または `for…of` が自動生成するブロックスコープ)のおかげで変数のリークやクロージャのバグを防ぐことができる。

/

  • 複数のタスクを直列(順番に)実行する堅牢なプロセス
  • @param {string[]} taskIds – タスクIDの配列

/
async function processTasksSequentially(taskIds) {
for (const [index, taskId] of taskIds.entries()) {
try {
console.log(`タスク ${index + 1}/${taskIds.length} 処理中: ID ${taskId}`);

// awaitを使うことで、次のループへの進行をブロックし、直列実行を担保
await executeTaskAPI(taskId);

} catch (error) {
console.error(`タスク ID ${taskId} で処理が中断されました:`, error.message);
// 必要に応じてループを抜ける(break)か、処理を継続するかをビジネスロジックに応じて判断
break;
}
}
}

// ダミーの非同期タスク関数
async function executeTaskAPI(taskId) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`-> タスク ${taskId} 完了`);
resolve();
}, 500);
});
}

—

4. チーフアーキテクトからの教訓:パフォーマンスとメモリ管理の注意点

最後に、ランタイムのパフォーマンスとメモリの観点から、非同期処理とループを扱う際の重要な注意点を述べておきたい。

1. レガシーな `var` はコードベースから完全放逐せよ
現代のモダンJavaScript開発(ES2015以降)において、`var` を使う正当な理由はもはや存在しない。変数宣言は常に `const` をデフォルトとし、再代入が必要な場合のみ `let` を使用する。これだけで、今回解説したようなスコープ起因の非同期バグは100%コンパイル時・ランタイム手前で防ぐことができる。
2. クロージャによるメモリリークへの警戒
ループ内で生成される非同期コールバック(クロージャ)は、そのスコープ内の変数をメモリ上に保持し続ける(Garbage Collectorの回収対象から外れる)。もしループの回数が数万回に及び、かつ長寿命のオブジェクトを参照し続けるような設計にすると、V8ヒープのメモリ使用量が肥大化し、ガベージコレクション(GC)の停止時間(Stop-The-World)が増加してUIのジャーキネス(カクつき)に直結する。非同期処理内で必要なデータは、必要な最小限のプリミティブ値だけをキャプチャするように意識しよう。

言語の仕様とランタイムの挙動を深く理解していれば、バグは「偶然起きるもの」ではなく、「予見し防ぐもの」に変わる。日々のコードレビューから `var` を駆逐し、美しく堅牢な非同期アーキテクチャを構築していってほしい。

タイトルとURLをコピーしました