コードレビューをしていて、未だに「`var`の代わりに`let`を使えばとりあえず安全」という浅い理解でコードを書いているエンジニアを見かけるたびに、私はエンジニアリングの解像度の低さを痛感する。
フロントエンドのパフォーマンスを極限までチューニングし、V8エンジンのメモリ挙動まで見通すテックリードであれば、`let`が単なる「ブロック版`var`」ではないことを知っているはずだ。特に、`for`ループの頭で宣言される`let`が、V8のヒープ上で毎イテレーションごとに新しいレキシカル環境(Lexical Environment)のインスタンスを生み出しているという事実を。
今回は、この「見えないメモリ消費」の正体をV8のランタイムレベルで解剖し、実務の現場でメモリリークやガベージコレクション(GC)のスパイクを引き起こさないための堅牢な設計パターンを伝授する。
—
1. V8エンジンの内部構造:なぜ `for (let i = …)` はメモリを消費するのか
JavaScriptの変数は、実行コンテキスト(Execution Context)に紐づく「レキシカル環境」というオブジェクト内に保持される。
ES2015(ES6)以前の`var`は関数スコープであったため、ループ変数は1つしか生成されず、関数コンテキストの環境レコードが使い回されていた。
しかし、`let`および`const`が導入されたことで、言語仕様(ECMAScript Specification)は厳格になった。
> “For each iteration of a `for` statement, creation of a new lexical environment is specified.”
つまり、`for (let i = 0; i < 100000; i++)` というコードを書いた場合、V8は10万回ものイテレーションそれぞれに対して、独立したレキシカル環境(Lexical Environment)のインスタンスをヒープ上に生成している。
なぜこれが問題になるのか?
もしループ内部でクロージャ(非同期処理やイベントリスナーなど)が生成され、そのスコープ内の変数を参照し続けた場合、それぞれのイテレーションで生成されたレキシカル環境がGCされずにヒープ上に残存する。
従来の`var`であれば、変数はループの外(関数スコープ)に1つ存在するだけなので、クロージャが参照しようとも共有の1つが維持されるだけだった。しかし`let`では、クロージャがキャプチャするたびに「その瞬間固有の環境のクローン(正確にはチェインされた独立した環境)」がメモリ上に保持され続けることになる。
これが、何も考えずに高頻度・大量のループ内で`let`を使い、さらに非同期処理を絡めたときに、V8のヒープ使用量が跳ね上がり、GCの頻発(Jankの原因)を引き起こすメカニズムだ。
—
2. 【実証】コードとメモリプロファイリングの現実
以下のコードを見てほしい。大量のデータ処理と非同期コールバックを内包するコンポーネントの初期化処理を模したシミュレーションだ。
/
- 【アンチパターン】
- 大量ループと非同期処理(クロージャ)の組み合わせによるレキシカル環境の乱造
/
function processItemsWithHeavyMemoryFootprint(items) {
console.time(‘Memory-Heavy Loop’);
// 10万件のデータ処理を想定
for (let i = 0; i < items.length; i++) {
// ループ変数 'i' とローカル変数 'itemData' をキャプチャするクロージャを生成
const itemData = items[i];
// 非同期タスクのスケジュール(マイクロタスク / マクロタスク)
setTimeout(() => {
// この無名関数は、各イテレーションごとに生成された
// 独立したレキシカル環境(i と itemData を保持)への参照を保持し続ける
executeAsyncOperation(itemData, i);
}, 1000);
}
console.timeEnd(‘Memory-Heavy Loop’);
}
function executeAsyncOperation(data, index) {
// ダミーの非同期処理
// ここでクロージャが破棄されるまでレキシカル環境はV8ヒープに居座る
}
このコードを実行すると、V8は10万個の独立した環境レコードをメモリ上にアロケートし、`setTimeout`のタイマーが発火するまでの間、それらを保持し続ける。モバイル端末やメモリの限られた環境では、これだけでタブ全体のパフォーマンス劣化や、最悪の場合クラッシュ(Out of Memory)を引き起こす。
—
3. プロダクションコードにおける堅牢な設計パターン
では、私たちはどのようにコードを書くべきか。「`let`を使うな」と言っているわけではない。スコープのライフサイクルとメモリの寿命をコントロールすればよいのだ。
パターンA:ループ変数をスコープの外に追い出す(イテレーションごとの環境生成を避ける)
もしループ内でクロージャが変数`i`の「その時点の静的な値」を必要とするだけで、かつメモリ効率を極限まで高めたい場合は、ループカウンタを外側(あるいは従来の構造)に逃がし、イテレーション内のスコープをフラットに保つ。
/
- 【堅牢な最適化パターン 1】
- スコープの分離と変数の巻き上げ・再利用によるGC負荷の軽減
/
function processItemsOptimized(items) {
console.time(‘Optimized Loop’);
const length = items.length;
// ループ変数を一つに限定し、レキシカル環境の乱造を防ぐ
let i = 0;
for (; i < length; i++) { // ループブロック外でデータを取得し、ブロックスコープの生成を最小限に抑える handleSingleItem(items[i], i); } console.timeEnd('Optimized Loop'); } function handleSingleItem(itemData, index) { // 同期処理であれば、スコープの生存期間は即座に終了し、V8のポインター退避のみで済む // (エフェメラルなアロケーションとなり、ヤング世代GCで一瞬で回収される) }
パターンB:非同期処理を伴う場合の「イミュータブルな値の明示的キャプチャ」
非同期処理(`async/await`や`Promise.all`)をループ内で扱う場合、配列メソッド(`map`など)を利用するか、関数スコープを切って「値のコピー」を関数引数として渡すのが最も安全だ。これにより、不要なレキシカル環境のチェインチェーン(スコープチェーンの肥大化)を防ぐことができる。
/
- 【堅牢な最適化パターン 2】
- 非同期処理を伴う安全な配列処理
- 即時実行関数(IIFE)や外部関数でスコープを閉じ、参照の寿命を明確にする
/
async function processAsyncBatchSafely(items) {
// mapとPromise.allを使い、スコープを関数単位で完全に独立させる
const promises = items.map(async (itemData, index) => {
// 各コールバックは独自の独立した関数スコープを持つため、
// for(let …)特有の複雑なレキシカル環境チェインのトラブルを防げる
await executeAsyncOperationSafely(itemData, index);
});
await Promise.all(promises);
}
async function executeAsyncOperationSafely(data, index) {
// 処理の実態
return new Promise((resolve) => {
setTimeout(resolve, 100);
});
}
—
4. テックリードからの総括
「モダンなJSだから`let`と`const`を安全に自動処理してくれる」という甘えは、プロダクション環境では通用しない。
V8エンジンは非常に優秀だが、開発者が意図しないメモリの保持(レキシカル環境の残留)まで魔法のように消し去ることはできない。
1. 高頻度ループ(数千件以上)の内部で`let`を宣言し、かつそれをクロージャがキャプチャしていないか?
2. 非同期処理のスケジューリングによって、不要なスコープがヒープ上に長期間残留していないか?
コードレビューの際は、常にこの2点を自問自答してほしい。
メモリの挙動を支配する者だけが、真にスケーラブルで美しいフロントエンドアプリケーションを構築できる。次のコミットからは、この「見えないメモリ消費」を意識したコードを書くことを強く期待する。