なぜループ内の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)に抑える。
/
- @fileoverview イベント委譲によりメモリ消費を最小限に抑えたコンポーネントレンダラー
- @param {HTMLElement} container – 親コンテナ要素
- @param {Array
/
function renderItemsOptimized(container, items) {
// データをData-属性としてDOMに持たせることで、クロージャによるスコープキャプチャを排除
container.innerHTML = items.map((item, index) => `
`).join(”);
// リスナーは親要素に1つだけアタッチ(メモリ消費は常に一定)
container.addEventListener(‘click’, (event) => {
const target = event.target.closest(‘.item-btn’);
if (!target) return;
const index = parseInt(target.dataset.index, 10);
const item = items[index];
if (item) {
processItem(item);
}
});
}
function processItem(item) {
// 実際のビジネスロジック
console.log(‘Processing:’, item.id);
}
パターンB:非同期API連携と大量データ処理のバッチング
数千件のAPIレスポンスを順次、あるいは並行して処理しつつ、それぞれのスコープを安全に保つ必要がある場合の設計。
/
- @fileoverview 大量データの非同期処理をメモリ効率よく安全に行うパターン
- @param {Array
} userIds
/
async function processUsersSequentially(userIds) {
// ループ内のletは安全だが、巨大な配列に対する処理ではスコープの寿命に注意する
for (const [index, userId] of userIds.entries()) {
// 各イテレーションのブロックスコープが独立しているため、
// クロージャや非同期のawait境界をまたいでもuserIdは安全に保たれる
try {
const userData = await fetchUserApi(userId);
// スコープを限定するためのブロック(必要に応じた明示的スコープ)
{
const processed = transformData(userData);
await saveToDatabase(processed);
} // ここで内部の変数(processed等)は直ちにGCの対象になり得る
console.log(`Progress: ${index + 1}/${userIds.length}`);
} catch (error) {
console.error(`Failed for user ${userId}:`, error);
}
}
}
ここで注目してほしいのは、ブロック `{ … }` を明示的にネストしている点だ。これにより、ループ全体のスコープとは別に、一連の処理が終わった瞬間にメモリを解放しやすいスコープの境界をV8に明示できる(V8の最適化フェーズにおいても、変数のライフサイクルが明確になりデッドコードやGCの判断が早まる)。
—
4. チーフアーキテクトからの提言
JavaScriptの `let` がもたらした「ブロックスコープのクローン」という挙動は、開発者から「クロージャの変数が書き換わってしまうバグ」という悪夢を取り去った偉大な仕様変更だ。
しかし、「言語仕様が安全にしてくれたから、何も考えずにループ内で関数や変数を乱立させてよい」ということには断じてならない。
フロントエンドのパフォーマンスチューニングとは、突き詰めれば「V8エンジンのメモリ空間とGCに対する思いやり」に他ならない。
- ループ内で本当にクロージャによるスコープのキャプチャが必要か?
- イベント委譲で代替できないか?
- 不要になった変数のライフサイクルを短く制限できていないか?
これらの問いを常にコードに向けられるエンジニアこそが、プロダクションの荒波を耐え抜く、真に堅牢で美しいシステムを構築できる。次のコードレビューでは、単に「動くコード」だけでなく、「メモリの足音」まで聞こえるコードを書こう。