コードレビューの場において、若手エンジニアから最も頻繁に提出されるバグの一つが「非同期処理を含むループ処理」の誤謬だ。
// 【アンチパターン】よくある事故の原形
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(`Index: ${i}`);
}, 1000);
}
// 期待値: 0, 1, 2
// 実際の出力: 3, 3, 3
なぜ、1秒後にコンソールへ出力される値がすべて「3」になってしまうのか。JavaScriptのランタイムを知る者にとって、これは自明の理である。しかし、表面的な「書き方のコツ」として暗記しているだけでは、実務の複雑な非同期パイプライン(Promiseチェーンや async/await、イベントリスナーの動的登録)に直面した途端、手も足も出なくなる。
今回は、V8エンジンのメモリモデルとスコープチェーンの挙動に踏み込み、なぜこの現象が起きるのか、そして`let`やモダンな設計パターンが如何にしてこの問題を根絶するのかをロジカルに解説しよう。
—
1. なぜ `var` は裏切るのか? —— V8のメモリ空間とスコープの正体
まず、根本的な原因から紐解く。JavaScriptにおいて、`var` キーワードは関数スコープを持つ。つまり、`for` ループのブロック(`{}`)は `var` にとってスコープの境界とはならない。
上記のコードで何が起きているか、V8の内部挙動を脳内トレースしてみよう。
1. グローバル実行コンテキスト(または関数実行コンテキスト)のメモリ空間に、変数 `i` が1つだけ生成される。
2. `for` ループが同期的に回る。
- `i = 0` の状態で `setTimeout` が登録され、コールバック関数は外部の変数 `i` への参照(Reference)を保持する。
- `i = 1` に更新される。
- `i = 2` に更新される。
- `i = 3` になり、ループ条件を満たしたためループが終了する。この瞬間、変数 `i` の値は `3` に確定する。
3. 1秒後、タイマーイベントが発火し、`setTimeout` のコールバックがタスクキューからコールスタックへプッシュされ実行される。
4. コールバック内の `console.log` は変数 `i` を評価するが、その時点ですでにメモリ上の `i` は `3` であるため、すべて `3` が出力される。
つまり、非同期処理が実行されるタイミング(未来)と、変数が更新されるタイミング(過去の同期処理)が完全に分離しているにもかかわらず、「ただ一つの変数を全員で共有している(Mutableな参照を握りしめている)」ことが諸悪の根源なのだ。
—
2. `let` がもたらす革命:レキシカル環境と「シャドウイング」のメカニズム
ES2015(ES6)で導入された `let`(および `const`)は、ブロックレベルスコープをもたらした。これによって、V8エンジンの裏側では何が変わるのだろうか?
// 【堅牢な実装】letによる解決
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(`Index: ${i}`);
}, 1000);
}
// 実際の出力: 0, 1, 2
このコードが期待通りに動く理由は、「`let` だからマジックでよしなにやってくれる」のではない。V8のレキシカル環境(Lexical Environment)の厳密な仕様に基づいている。
ECMAScriptの仕様上、`for` ループの頭で `let i` が宣言された場合、各イテレーション(ループの周回)ごとに、まったく別個の新しい変数 `i`(レキシカル環境)がメモリ上に生成される。
- 1周目 (`i = 0`): 環境Aが生成され、そこに `i = 0` が束縛される。コールバックは環境Aの `i` を参照する。
- 2周目 (`i = 1`): 環境Bが新しく生成され、そこに `i = 1` が束縛される。コールバックは環境Bの `i` を参照する。
- 3周目 (`i = 2`): 環境Cが新しく生成され、そこに `i = 2` が束縛される。コールバックは環境Cの `i` を参照する。
各非同期コールバックは、それぞれが生成された瞬間の「そのループ専用の独立した変数(環境)」をクロージャとしてキャプチャする。そのため、後から外側で変数が書き換えられようとも、影響を受けないのだ。
—
3. 実務で遭遇する「罠のバリエーション」とプロダクションコード設計
この問題は、単なる `setTimeout` だけではなく、実務のフロントエンド開発において様々な形で牙をむく。特に、DOMの動的生成や、配列に対する非同期マッピング処理での事故が多い。
悪い例:イベントリスナーの動的アタッチ(よくあるUIバグ)
// 【アンチパターン】リストアイテムにクリックイベントを付与するつもりが生むバグ
const buttons = document.querySelectorAll(‘.dynamic-btn’);
for (var i = 0; i < buttons.length; i++) { buttons[i].addEventListener('click', function() { // ユーザーがどのボタンをクリックしても、ログには最後のインデックス(例: 5)が出力される console.log(`Clicked button index: ${i}`); }); }
解決策:`let` への置き換え、または `forEach` / `for…of` の活用
モダンなJavaScript開発において、そもそもクラシックな `for (var i = …)` を書く理由はもはや存在しない。配列操作であればイテレータブルなメソッドや `for…of` を使うべきだ。
// 【プロダクションコード例】配列の非同期処理とDOM操作の堅牢な設計
const listItems = document.querySelectorAll(‘.async-item’);
// NodeListをスプレッド構文で配列化し、forEachで安全にスコープを閉じる
[…listItems].forEach((item, index) => {
item.addEventListener(‘click’, async (event) => {
try {
// イベントドリブンな非同期API連携の例
const data = await fetchItemData(index);
updateUI(item, data);
} catch (error) {
console.error(`Failed to process item at index ${index}:`, error);
}
});
});
/
- モックAPI関数
/
async function fetchItemData(index) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id: index, timestamp: Date.now() });
}, 500);
});
}
function updateUI(element, data) {
element.dataset.loaded = ‘true’;
element.textContent = `Loaded ID: ${data.id}`;
}
`forEach` のコールバック関数は、呼び出しごとに新しいスコープ(関数スコープ)を生成するため、各コールバック内の `index` や `item` は完全にカプセル化され、変数の共有によるバグは構造的に発生しなくなる。
—
4. パフォーマンスとメモリ管理の視点:クロージャの副作用を理解する
チーフアーキテクトとして、ここで一歩踏み込んだパフォーマンスの話をしておこう。
「`let` を使えばループごとに変数が作られるなら、メモリ消費が増えてパフォーマンスが落ちるのではないか?」という懸念を持つ鋭いエンジニアもいるだろう。
結論から言えば、現代のV8エンジン(IgnitionインタープリタとTurboFanコンパイラ)の最適化能力は非常に高く、このレベルのスコープ生成が顕著なパフォーマンスボトルネックになることは稀だ。V8は、クロージャ内部から参照されなくなったレキシカル環境をガベージコレクタ(GC)によって適切に回収する。
ただし、意図せず巨大なオブジェクトやDOM要素への参照をクロージャ内に長期間保持し続ける設計にしてしまうと、メモリリークの温床になる。
// 【パフォーマンス上の注意点】メモリリークを誘発する悪例
const heavyDataList = loadHugeDataset(); // 数万件の巨大なデータ
const handlers = [];
for (let i = 0; i < heavyDataList.length; i++) {
// クロージャが heavyDataList 全体への参照を暗黙的に保持してしまうケース
handlers.push(() => {
console.log(heavyDataList[i].name);
});
}
もし `heavyDataList` 全体が不要になったとしても、個別のクロージャが配列全体(あるいはスコープ内の環境)への参照を保持し続けると、GCが動作せずヒープメモリを圧迫し続けることがある。
アーキテクトからの提言:
1. 変数のスコープは必要最小限に狭める: `var` はコードベースから完全に駆逐し、再代入が不要な変数はすべて `const`、ループカウンタなどやむを得ない場合のみ `let` を使う。
2. 非同期処理を伴うループには `Array.prototype.map` や `for…of` + `Promise.all` を活用する:
// 並行処理の美しい設計例
const promises = […listItems].map(async (item, index) => {
const data = await fetchItemData(index);
return { item, data };
});
const results = await Promise.all(promises);
results.forEach(({ item, data }) => updateUI(item, data));
—
結びにかえて
コードレビューで `for (var i = …)` が非同期処理の中で使われているのを見かけたら、それは単なる「書き方のミス」ではない。JavaScriptのメモリモデルとスコープのライフサイクルに対する理解の欠如を意味している。
`let` への書き換えは単なる文法上のシュガーコートではなく、V8エンジンにおけるレキシカル環境の分離という極めて物理的なメモリ管理の変更なのだ。このメカニズムを腹落ちさせていれば、複雑な非同期パイプラインを設計する際も、バグの影に怯えることはなくなるはずだ。
堅牢なコードとは、言語のランタイム仕様と対話しながら構築されたものだけが持つ美しさを備えている。日々の実装において、その一手間を惜しまないでほしい。