【実務・中級編】なぜループ内のlet宣言は「ブロックスコープのクローン」を生成するのか:メモリ消費とガベージコレクションの挙動 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

なぜループ内のlet宣言は「ブロックスコープのクローン」を生成するのか:V8のメモリ空間とGCの深層

コードレビューをしていて、未だに以下のようなコードを見かけることがある。

// よくあるアンチパターン:varによるループとクロージャの組み合わせ
for (var i = 0; i < 3; i++) { setTimeout(() => {
console.log(i); // 何が出力されるか?
}, 100);
}
// 出力: 3, 3, 3

フロントエンド開発に携わるエンジニアであれば、これが`let`の登場によって一掃されたことは知っているはずだ。`let`を使えば、ブロックスコープが生成され、各イテレーション(反復)ごとに変数が「固定」される――。

だが、一歩踏み込んで考えてみてほしい。
「各イテレーションごとに変数が固定される」とは、V8エンジン内部のメモリ空間で一体何が起きているのか?
`let`は魔法のように毎回変数をコピーしているのか? それとも、パフォーマンスやガベージコレクション(GC)の観点から、何か重いコストを支払っているのだろうか?

今回は、V8ランタイムの内部挙動とメモリレイアウトの視点から、ループ内`let`の真実を丸裸にし、プロダクションコードで絶対に押さえておくべき設計思想を伝授しよう。

—

1. 表面的な理解の罠:`var` と `let` のメモリ構造の違い

まず、V8がメモリ上にどのようにスコープ(Lexical Environment)を構築しているかを理解する必要がある。

`var` の世界(関数スコープ)

`var` は関数スコープ(またはグローバルスコープ)にバインドされる。ループ内でどれだけ変数を作ろうとも、実体はただ一つである。

for (var i = 0; i < 3; i++) { // すべての非同期コールバックが「同一の」iへの参照を共有する } V8のヒープ上では、この `i` は親となる実行コンテキストのスコープオブジェクト(Variable Environment)内の単一の슬롯(スロット)として確保される。ループが回るたびに上書きされるため、非同期処理が走る頃には `i` はすでに `3` に達している。

`let` の世界(ブロックスコープのクローンと実体)

では、モダンな `let` では何が起きているのか。ECMAScript仕様書(ES2015以降)では、「forループの各イテレーションごとに、新しいLexical Environment(レキシカル環境)が生成されなければならない」と規定されている。

for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100);
}
// 出力: 0, 1, 2

ここで「クローンが生成される」という表現が使われる所以だ。V8エンジンは、各ループのイテレーションごとに、その時点の `i` の値を保持するための新しいスコープインスタンスをヒープ上にアロケート(確保)する。

しかし、ここでV8の最適化屋としての視点を提供しよう。
「毎回本当にメモリコピーが発生しているのか?」といえば、答えはNOだ。V8は賢い。実際に独立したメモリ領域を物理的に毎回複製するわけではなく、クロージャ等から参照(Captured)されているイテレーション変数についてのみ、独立したスコープオブジェクトをヒープ上に保持し、そうでない場合は最適化によってインライン化やスタック領域での処理に落とし込む。

だが、もしループ内でアロー関数やクロージャを生成し、そのスコープの外(非同期処理やイベントリスナーなど)へ参照を持ち出す場合、V8は「逃げた変数(Escaped Variables)」として扱ざるを得ない。結果として、各イテレーションのレキシカル環境は独立したヒープ上のオブジェクトとして生き残り、それぞれがGCの監視対象となる。

—

2. メモリ消費とガベージコレクション(GC)の隠れたコスト

「`let`を使えば安全」という理由で、巨大な配列を回すループや、高頻度で実行されるアニメーション・スクロールイベントのハンドラ内で無造作に `let` とクロージャを量産していないだろうか?

ここに、パフォーマンス低下の罠がある。

ガベージコレクタへの負荷

各イテレーションで生成されたレキシカル環境(Lexical Environment)は、それぞれが独立したヒープオブジェクトだ。もしループ回数が10,000回であり、その中でクロージャが生成されて外部に参照を持つような設計(例えば、大量のDOM要素に対するイベントリスナーの動的アタッチ)をしていれば、10,000個のスコープオブジェクトがヒープ上に散らばることになる。

これはV8のガベージコレクタ(特にマイナーGC:Scavenger)に深刻なプレッシャーを与える。短命なオブジェクト(Young Generation)の領域が細かなスコープオブジェクトで埋め尽くされ、GCのコンパクション(メモリの寄せ集め)コストや停止時間(Stop-the-world)が増加する原因となる。

—

3. 実務で活かす:堅牢でメモリ効率の高い設計パターン

テクニカルリーダとして、コードレビュー時に「メモリ効率」と「保守性」を両立させるための指針を示そう。

パターンA:高頻度実行コンテキストでのスコープ汚染を防ぐ

例えば、マウスのmousemoveイベントや、リアルタイムのCanvas描画ループなど、毎秒60回以上実行される処理の中で、無駄に `let` のブロックスコープとクロージャを生成するのは自殺行為だ。

【非効率なコード例】

// ❌ 毎フレームごとにレキシカル環境とクロージャがヒープを汚染する
function renderItems(items) {
items.forEach((item, index) => {
const el = document.getElementById(`item-${index}`);
el.addEventListener(‘click’, () => {
// itemやindexをキャプチャするためにスコープが保持される
processItem(item);
});
});
}

【洗練されたプロダクションコード例】
イベント委譲(Event Delegation)を活用し、リスナーの数自体を極限まで減らすことで、クロージャの生成数とメモリ消費をO(1)に抑える。

/