【テクニカル・上級編】【初心者向け】巻き上げ(Hoisting)の正体:コードが実行される前に何が起きているのか? – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

巻き上げ(Hoisting)の正体:V8エンジンがコード実行前に裏で行っているメモリの物理アロケーション

こんにちは。長年JavaScriptランタイムエンジンの挙動やTC39の仕様策定、そして巨大なコードベースのパフォーマンスチューニングに向き合ってきたシニア・アーキテクトだ。

ネット上には「変数の宣言がコードの先頭に持ち上げられる現象が巻き上げです」という、表面的なおためし解説があふれている。しかし、そんな抽象的な理解で止まっているうかがい知れない「ランタイムの真実」を、今日はここで剥き出しにする。

JavaScriptは、一見すると直感的にその場で解釈されて動くスクリプト言語のように見える。だが、あなたが書いたコードがV8(あるいは他のJSエンジン)に投入された瞬間、厳密な2つのフェーズ——コンパイルフェーズ(Creation Phase)と実行フェーズ(Execution Phase)を経て物理的なメモリ空間へと落とし込まれている。

「なぜ `var` は `undefined` になり、`let` や `const` は `ReferenceError` になるのか?」
この問いに正確に答えられるかどうかが、プロダクション環境でメモリリークや意図せぬバグを踏み抜くエンジニアと、ランタイムを掌の上で転がすエンジニアの分水嶺だ。

—

1. V8エンジンの内部挙動:コンパイルフェーズとスコープの物理構築

JSエンジンがソースコードを受け取ると、まず実行前にパース(構文解析)を行い、抽象構文木(AST: Abstract Syntax Tree)を生成する。その後、コードの「実行」が始まる前に、コンパイルフェーズ(環境の構築フェーズ)に入る。

ここでエンジンは何をしているのか?
それは、スコープ内に存在するすべての変数と関数宣言をスキャンし、メモリ(Lexical Environment / Variable Environment)上に領域を物理的に事前確保(アロケーション)しているのだ。

これが、いわゆる「巻き上げ」の正体に他ならない。コードが実際に1行目から下に向かって実行されるより前に、変数の名前空間とメモリ上のスロットがすでに割り当てられているのである。

`var` と `let`/`const` のメモリ管理の決定的違い

では、なぜ `var` と `let` で振る舞いが違うのか。V8のヒープメモリ空間における表現を見てみよう。

// — コンパイルフェーズの脳内シミュレーション —
console.log(x); // undefined
var x = 42;
console.log(x); // 42

console.log(y); // ReferenceError: Cannot access ‘y’ before initialization
let y = 100;

  • `var` の場合:

コンパイルフェーズにおいて、変数 `x` は現在の実行コンテキスト(Execution Context)の VariableEnvironment に登録され、同時に初期値として `undefined` が強制的にバインドされる。だからこそ、宣言文に到達する前にアクセスしても `ReferenceError` にならず、単に `undefined` が返ってくるのだ。

  • `let` / `const` の場合:

これらもコンパイルフェーズで LexicalEnvironment に登録はされる。しかし、`var` と違って初期値のバインドが行われない。メモリ上には領域の枠だけが確保され、値が未定義のまま「初期化されていない状態(Uninitialized)」としてマークされる。
この「宣言から実際の初期化コード(`let y = …`)に到達するまでの空間」こそが、かの有名な TDZ(Temporal Dead Zone:一時的死活領域) である。この領域内で変数に触れようとすると、V8のランタイムが強制的に `ReferenceError` を投げ、安全性を担保する。

—

2. 巻き上げの二大巨頭:「関数宣言」と「変数」の優先順位

コード中に同じ名前の関数と変数が混在しているとき、V8のメモリ空間ではどのような覇権争いが起きているのだろうか。ここを正確に把握していないと、複雑なモジュール構造をリファクタリングする際に足元をすくわれる。

以下のコードを見てほしい。出力はどうなるだろうか?

// プロダクションで混入しがちなアンチパターン
var foo = 100;

function foo() {
return ‘I am a function’;
}

console.log(typeof foo); // さて、何が出力されるか?

正解は `’number’`(数値)だ。
「関数宣言のほうが巻き上げの優先順位が高いのだから、文字列か関数になるはずでは?」と思ったかもしれない。しかし、ここにはコンパイルフェーズにおける厳密なアロケーションの順序が関与している。

1. 関数宣言(Function Declaration)は、コンパイルフェーズにおいて、識別子の登録と関数オブジェクトのメモリ割り当て、およびその実体のバインドまでが完全に行われる。
2. その後、コードのパースが進む中で `var foo = 100;` という変数宣言に出会う。
3. すでにスコープ内に `foo` という名前が存在する場合、`var` による重複宣言の巻き上げフェーズでは、既存の関数バインドが上書きされないようにガードされる(あるいは値の代入フェーズで実行時に `100` が上書きされる)。

しかし、もしこれが「変数としての関数代入(関数式)」だったらどうなるか?

console.log(typeof bar); // undefined

var bar = function() {
return ‘I am a function expression’;
};

この場合、`bar` はあくまで `var` によって巻き上げられているため、コンパイル時には `undefined` が初期化される。関数としての実体が代入されるのは、実行フェーズでその行に到達した瞬間のみだ。この差を意識できるかどうかが、高度な非同期処理やクロージャを設計する際の致命的な差となる。

—

3. シニア視点:巻き上げ・TDZを悪用した脆弱性ハックとプロトタイプ汚染の防壁

ここからは、ランタイムの低レイヤ知見を持つアーキテクトとして、この変数のライフサイクルや巻き上げの挙動が、セキュリティの文脈(特にサプライチェーン攻撃やプロトタイプ汚染:Prototype Pollution)とどう結びついているのかを解説しよう。

悪意ある攻撃者は、JavaScriptの動的なプロパティ解決と、スコープチェーン、そして巻き上げの隙間を縫ってアプリケーションのメモリ空間を歪めようとする。

プロトタイプ汚染(Prototype Pollution)のメカニズム

例えば、外部から受け取った不正なJSONオブジェクトを再帰的にマージ(Deep Merge)するユーティリティ関数を考えてみよう。

// 危険なマージ関数の例
function unsafeDeepMerge(target, source) {
for (let key in source) {
if (typeof source[key] === ‘object’ && source[key] !== null) {
if (!target[key]) target[key] = {};
unsafeDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}

// 攻撃ペイロードの例
const maliciousPayload = JSON.parse(‘{“__proto__”: {“rceAllowed”: true}}’);

// 汚染の実行
let config = {};
unsafeDeepMerge(config, maliciousPayload);

// この瞬間、すべてのオブジェクトのプロトタイプが汚染される
console.log({}.rceAllowed); // true (致命的なセキュリティホール)

このコードが実行されると、JavaScriptのすべてのオブジェクトの根源である `Object.prototype` に `rceAllowed: true` が挿入されてしまう。
もし、後続のコードや依存ライブラリ(NPMパッケージなど)が、オブジェクトの安全性を確認せずにプロパティの有無だけで処理を分岐させていたらどうなるか? 任意のコード実行(RCE)や認証バイパスへの扉が開いてしまう。

ランタイムの防壁を築く:Nullish Coalescing と Freeze

このようなサプライチェーン攻撃や予期せぬスコープ汚染、変数巻き上げに起因するバグを防ぐためには、V8のランタイム特性を逆手に取った堅牢なコードを書く必要がある。

1. イミュータビリティの強制(`Object.freeze`)
グローバルなオブジェクトやプロトタイプ拡張を防ぐため、ランタイムの初期化時にコアプロトタイプを凍結する。
2. TDZの厳格な活用(`let`/`const` の徹底)
`var` をコードベースから完全に排除し、意図しない巻き上げによる `undefined` の混入をコンパイラ(およびESLint)の段階で封じ込める。
3. 安全なプロパティアクセスの実践
`__proto__` などの特殊なキーがペイロードに含まれていないかを検証し、ハッシュマップを作る際は `Object.create(null)` を用いてプロトタイプチェーンを持たない純粋な辞書(Dictionary)オブジェクトを生成する。

// 安全な辞書オブジェクトの生成
const safeDictionary = Object.create(null);
safeDictionary[‘foo’] = ‘bar’;

console.log(safeDictionary.__proto__); // undefined (プロトタイプ汚染を完全に遮断)

—

4. 結びにかえて

「巻き上げ」という言葉は、初心者が最初に躓く言語仕様の一つとして片付けられがちだ。しかし、その裏側ではV8エンジンがASTを解析し、メモリ上のスロットを緻密にアロケートし、レキシカル環境を構築するという極めてダイナミックで美しいドラマが展開されている。

コードが実行される前に何が起きているのか。
その物理的なプロセスを脳内で完璧にトレースできるエンジニアこそが、メモリ効率を極限まで高め、セキュリティの脅威を未然に叩き潰すことのできる真のフルスタック・アーキテクトである。

JavaScriptは、決して「テキトーに動くおもちゃの言語」ではない。そのランタイムの鼓動を感じ取りながら、今日も最高精度のコードを紡ぎ出してほしい。

タイトルとURLをコピーしました