こんにちは!フロントエンドからNode.jsの内部挙動まで、日夜JavaScriptと向き合っているシニアアーキテクトです。
今回は、JavaScriptの基礎中の基礎でありながら、実はV8エンジンのメモリ管理やガベージコレクション(GC)の挙動に深く関わる「ブロックスコープのクローン生成:forループ内での `let` がメモリ消費に与える影響」について、一歩踏み込んで解説していきますね。
「`var` じゃなくて `let` を使おう」というのは、モダンなJavaScript開発では常識ですよね。でも、「なぜループの中で `let` を使うと、変数の値がループごとに保持されるのか?」、そして「それが裏側のメモリでどう処理されているのか?」を説明できますか?
ここをクリアすると、単に動くコードを書くだけでなく、パフォーマンスの最適化まで見据えた「本物のJavaScript力」が身につきますよ。一緒に優しく、深く紐解いていきましょう!
—
1. そもそも `var` と `let` で何が違うのか?(おさらいと基本)
まずは、ループ処理における変数宣言の挙動の違いを軽くおさらいしておきましょう。他の言語からやってきた方は特に、「スコープ」の概念でハマりがちですよね。
例えば、よくあるこんな非同期処理(`setTimeout`)を思い浮かべてみてください。
// 【アンチパターン】varを使ったループ
function runVarLoop() {
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(`[var] index: ${i}`);
}, 100);
}
}
runVarLoop();
// 期待値: 0, 1, 2 と表示されてほしい
// 実際の出力: 3, 3, 3 と表示されてしまう!
「あれ?なんで全部 `3` になっちゃうの?」と最初は驚きますよね。
これは、`var` が関数スコープを持つため、変数 `i` がループの外(関数全体、あるいはグローバル)にただ一つしか存在せず、ループが高速に回りきったあとの最終値(`3`)をすべてのタイマーコールバックが共有してしまうからなんです。
これを解決するために、ES2015(ES6)で登場したのが `let` です。
// 【モダンな書き方】letを使ったループ
function runLetLoop() {
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(`[let] index: ${i}`);
}, 100);
}
}
runLetLoop();
// 実際の出力: 0, 1, 2 と正しく表示される!
`let` を使うと、期待通りに `0, 1, 2` と出力されます。
「さすが `let` だね、便利!」で終わらせてもいいのですが、ここからが今回の本題です。「なぜ `let` はループの回数分、それぞれの値を覚えていられるのか?」、その裏側のメカニズムをV8エンジンの視点から覗いてみましょう。
—
2. V8エンジンの裏側:ループごとに生成される「レキシカル環境のクローン」
JavaScriptエンジン(V8など)の内部では、変数を管理するために「レキシカル環境(Lexical Environment)」というデータ構造を使っています。
`let` や `const` は、`var` と違ってブロックスコープを持ちます。そして、forループの `for (let i = 0; i …)` という構文が使われた場合、JavaScriptの仕様(ECMAScriptスペック)では特別なルールが適用されます。
なんと、「ループの反復(イテレーション)が1回行われるたびに、そのブロック内のレキシカル環境のインスタンス(クローン)が新しくメモリ上に生成される」ようになっているのです。
イメージ図で表すと、こんな感じです。
[ ループ 0回目 ] -> レキシカル環境A生成 (i = 0) -> クロージャから参照される
[ ループ 1回目 ] -> レキシカル環境B生成 (i = 1) -> クロージャから参照される
[ ループ 2回目 ] -> レキシカル環境C生成 (i = 2) -> クロージャから参照される
ループの中で関数(上記の `setTimeout` の引数など)を定義して変数 `i` をキャプチャ(クロージャとして保持)すると、それぞれのクロージャは、別々に生成された独立したレキシカル環境のメモリ領域を指し示します。 だからこそ、値が上書きされずに `0, 1, 2` が保たれるわけですね。
—
3. メモリ消費とガベージコレクション(GC)への影響
ここで、シニアエンジニアとして知っておくべき「パフォーマンスの裏側」の話をします。
「ループごとに環境がクローンされるってことは……メモリをたくさん消費して、ガベージコレクション(GC)の負担が増えるんじゃないの?」
鋭い疑問ですね!その通りです。
`let` を使ったループ内で、無名関数やクロージャを大量に生成し、それらが外側のスコープから参照され続けるようなコードを書くと、V8のヒープメモリ空間には「ループの回数分のレキシカル環境オブジェクト」が散らばることになります。
簡単なコードでメモリの振る舞いをシミュレートしてみましょう。
// Node.js環境でメモリ使用量を監視するイメージのコード
function heavyMemoryLoop() {
const listeners = [];
for (let i = 0; i < 100000; i++) { // 10万回のループごとに独立したレキシカル環境が生まれ、 // この無名関数がそれを保持(キャプチャ)し続けます listeners.push(function() { return i; }); } return listeners; } const capturedFunctions = heavyMemoryLoop(); // この時点では、10万個のレキシカル環境がV8のヒープに常駐します。 // 不要になればGCの回収対象になりますが、一時的にメモリを圧迫します。
ガベージコレクション(GC)の視点
もし、ループ内で生成した関数がすぐに破棄される(非同期で保持されないなど)場合、V8のJITコンパイラや世代別GCは非常に優秀なので、若い世代(Young Generation / Nursery)のメモリ領域でサクッと短命なオブジェクトとして処理してくれます。
しかし、もしそれらがグローバルな配列にプッシュされたり、DOMのイベントリスナーとして長期間生き残り続ける場合、V8のヒープ領域(Old Space)を圧迫し、マイナーGCやメジャーGCの頻度を高める原因(=アプリケーションの「カクつき」やレイテンシ悪化)になります。
—
4. 陥りやすい文法エラーと、実務での注意点
ここで、初心者がやってしまいがちな、変数宣言にまつわる代表的なエラーも確認しておきましょう。
エラー例:`const` でループを回そうとしてしまう
// 【NGな例】
for (const i = 0; i < 3; i++) {
console.log(i);
}
// TypeError: Assignment to constant variable.
「変数を再代入しないから `const` でいこう!」と思いがちですが、forループのカウンタ(`i++`)は、ループのたびに変数への再代入を行っています。そのため、再代入不可の `const` を使うと、型エラー(TypeError)が発生します。
ループのカウンタには必ず `let`(または古いコードなら `var`)を使いましょう。
—
5. まとめ:ここをクリアすれば、JavaScriptの基本はバッチリ!
いかがでしたでしょうか? 今回のポイントをギュッと凝縮してまとめますね。
1. `let` はブロックスコープを持つ:
`var` のような変数の巻き上げや意図しない値の共有を防ぎ、安全なスコープ管理ができる。
2. ループ内での `let` はクローンを生成する:
反復ごとにレキシカル環境が新しく作られるため、クロージャを使うループ処理でも正しく値が保持される。
3. メモリとGCへの配慮:
非常に多数のループとクロージャを組み合わせる場合、メモリ消費やGCの負荷になり得ることを意識する。
「ただ動くコード」から、「ランタイムの挙動まで見通した美しいコード」へ。この一歩を踏み出せたあなたは、もう初学者を卒業して中級者・上級者への階段を確実に登っていますよ。
ここをクリアできれば、JavaScriptの変数とスコープの挙動で迷うことはもうありません。ぜひ日々の開発やデバッグの際に、頭の中でV8のメモリ空間をイメージしてみてくださいね!