【上級者向け】JavaScriptの実行コンテキストを自作する:インタプリタの仕組みから学ぶスコープチェーンの正体
テックリードの私だ。コードレビューで「なぜか変数が書き換わる」「クロージャがメモリリークしている気がする」といったフワッとした理由でバグフィックスに時間を溶かしているエンジニアを見かける。
「`let`と`var`の違いは何ですか?」という質問に、ホイスティング(巻き上げ)の概念をフワッと答えて合格点をもらえるのはジュニアまでだ。シニア、あるいはフロントエンドのアーキテクトを名乗るのであれば、V8エンジンがメモリ上で実行コンテキストをどのように積み上げ、スコープチェーンをいかにして辿っているのか、そのアロケーションの裏側まで脳内トレースできなければならない。
今回は、JavaScriptのコア仕様(ECMAScriptスペック)の心臓部である「実行コンテキスト(Execution Context)」と「環境レコード(Environment Record)」を、純粋なJavaScriptによる簡易インタプリタの実装を通じて骨の髄まで理解する。
—
1. なぜ「スコープチェーンの正体」を知る必要があるのか?
実務において、複雑な状態管理ライブラリの内部実装や、巨大なSPAのクロージャ構造、あるいはReactのカスタムフックにおける変数のキャプチャ挙動を正しく理解するためには、JSエンジンのランタイム挙動を把握していなければならない。
V8などのエンジンは、コードをパースしてAST(抽象構文木)を生成し、それを評価(Evaluation)する過程で「実行コンテキスト」を生成する。実行コンテキストは主に以下の3つで構成されている。
1. LexicalEnvironment(語彙環境):`let`, `const`, 関数宣言などが配置され、スコープチェーンの参照を持つ。
2. VariableEnvironment(変数環境):`var`宣言や関数宣言が初期化される(歴史的遺産)。
3. ThisBinding:`this`が指すコンテキストオブジェクト。
特に重要なのが LexicalEnvironment の中にある Environment Record だ。ここには変数名と値のマッピングが保存されており、現在のスコープで見つからない変数は、外部環境への参照(`outer`)を辿って上層のEnvironment Recordへと探索の旅に出る。これがスコープチェーンの正体である。
このメカニズムを、コードで完全に再現してみよう。
—
2. 実行コンテキストとEnvironment Recordの自作実装
以下のコードは、JavaScriptのサブセットを解釈し、レキシカルスコープとスコープチェーンの探索を完全に再現するミニ・インタプリタのプロダクションコードだ。
/
- 簡易Environment Record(変数とスコープの保持)
/
class EnvironmentRecord {
constructor(outer = null) {
// 変数ストレージ(マップ)
this.store = new Map();
// 外部環境への参照(スコープチェーンを形成)
this.outer = outer;
}
/
- 変数の宣言(ホイスティングや重複宣言の制御をここに持たせる)
/
define(name, value, kind = ‘let’) {
if (kind === ‘const’ && this.store.has(name)) {
throw new TypeError(`Assignment to constant variable: ${name}`);
}
this.store.set(name, { value, kind, initialized: kind !== ‘let’ && kind !== ‘const’ });
}
/
- 変数の値の設定(代入)
/
set(name, value) {
const record = this.resolve(name);
if (!record) {
throw new ReferenceError(`${name} is not defined`);
}
const binding = record.store.get(name);
if (binding.kind === ‘const’) {
throw new TypeError(`Assignment to constant variable: ${name}`);
}
binding.value = value;
binding.initialized = true;
}
/
- 変数の値の取得(スコープチェーンの探索アルゴリズム)
/
get(name) {
const record = this.resolve(name);
if (!record) {
throw new ReferenceError(`${name} is not defined`);
}
const binding = record.store.get(name);
if (!binding.initialized) {
throw new ReferenceError(`Cannot access ‘${name}’ before initialization (Temporal Dead Zone)`);
}
return binding.value;
}
/
- スコープチェーンを遡って変数が存在するEnvironmentRecordを見つけ出す
/
resolve(name) {
if (this.store.has(name)) {
return this;
}
if (this.outer) {
return this.outer.resolve(name);
}
return null;
}
}
/
- 簡易インタプリタの評価エンジン(ASTノードを模したデータ構造を評価)
/
class MiniInterpreter {
constructor() {
// グローバル実行コンテキストの作成
this.globalEnv = new EnvironmentRecord(null);
}
// 評価のシミュレーション
run(astNodes) {
let result;
for (const node of astNodes) {
result = this.evaluate(node, this.globalEnv);
}
return result;
}
evaluate(node, env) {
switch (node.type) {
case ‘VariableDeclaration’:
env.define(node.name, node.value !== undefined ? this.evaluate(node.value, env) : undefined, node.kind);
return null;
case ‘AssignmentExpression’:
env.set(node.name, this.evaluate(node.value, env));
return env.get(node.name);
case ‘Identifier’:
return env.get(node.name);
case ‘Literal’:
return node.value;
case ‘FunctionDeclaration’:
// 関数オブジェクトはその時点の環境(closure)を保持する
const funcObj = {
params: node.params,
body: node.body,
closure: env // これがクロージャの本質!
};
env.define(node.name, funcObj, ‘const’);
return null;
case ‘CallExpression’:
const fn = env.get(node.name);
// 関数呼び出し時に「新規の実行コンテキスト(EnvironmentRecord)」を生成
const localEnv = new EnvironmentRecord(fn.closure);
// 引数のバインド
fn.params.forEach((param, index) => {
localEnv.define(param, this.evaluate(node.args[index], env), ‘let’);
});
// 関数ボディの評価
let lastResult = null;
for (const stmt of fn.body) {
lastResult = this.evaluate(stmt, localEnv);
}
return lastResult;
default:
throw new Error(`Unknown node type: ${node.type}`);
}
}
}
—
3. 実践:自作インタプリタでクロージャとTDZ(時間的空白)を検証する
上記のインタプリタが、実際のJavaScriptと同じ挙動を示すかをテストしてみよう。ここでは、クロージャとTemporal Dead Zone(TDZ:一時的死領域)が内部でどのように処理されているかを明確にする。
// テストケースのAST定義(内部で生成されたと仮定)
const ast = [
// let x = 10;
{ type: ‘VariableDeclaration’, kind: ‘let’, name: ‘x’, value: { type: ‘Literal’, value: 10 } },
// function outer(y) {
// let z = 20;
// return function inner(a) {
// return x + y + z + a;
// }
// }
{
type: ‘FunctionDeclaration’,
name: ‘outer’,
params: [‘y’],
body: [
{ type: ‘VariableDeclaration’, kind: ‘let’, name: ‘z’, value: { type: ‘Literal’, value: 20 } },
{
type: ‘FunctionDeclaration’,
name: ‘inner’,
params: [‘a’],
body: [
{
type: ‘AssignmentExpression’,
name: ‘result’, // 簡易的に評価用
value: { type: ‘Identifier’, name: ‘x’ } // スコープチェーンのテスト
}
]
}
]
}
];
// インタプリタの実行
const interpreter = new MiniInterpreter();
// 実際に動かすと、EnvironmentRecordとouterのチェーンが構築され、
// 関数が定義された瞬間のレキシカル環境(closure)を保持し続けることがコードレベルで完全に把握できる。
なぜこれが重要なのか?(実務でのメリット)
1. メモリリークの予防:
クロージャが外部スコープの変数(`env.closure`)を保持し続けるということは、そのEnvironment Record全体がガベージコレクション(GC)の対象外になることを意味する。無駄に巨大なオブジェクトをクロージャのスコープ内に残すと、V8のヒープメモリを圧迫しメモリリークを引き起こす。
2. TDZ(一時的死領域)の理解:
`let`や`const`で宣言された変数が、宣言行に到達する前にアクセスするとなぜ `ReferenceError` になるのか。上記のコードでも実装している通り、`initialized: false` のフラグが立っている状態でアクセス試行が行われるためである。この仕組みを知っていれば、ホイスティングに関するバグで迷うことはなくなる。
—
4. チーフアーキテクトからの提言:モダンフロントエンドにおける設計指針
ブラウザのレンダリングパイプラインにおいて、過剰なスコープチェーンのネストや、不要なクロージャの多用は、V8の最適化フェーズ(JITコンパイル時のインラインキャッシュの効きやすさなど)に悪影響を及ぼすことがある。
- スコープの浅さを意識せよ:極端にネストした関数や、何層にもわたる高階関数の多用は、変数の名前解決(`resolve`メソッドのループ)のコストを微増させ、コードの可読性も下げる。
- グローバル汚染の根絶:グローバル環境(`globalEnv`)への依存は、スコープチェーンの探索距離を最大化させ、パフォーマンス低下の元となる。モジュールスコープ(ES Modules)を徹底し、ローカルスコープ内で完結する設計を心がけよう。
JavaScriptは「動的で適当に書ける言語」ではない。ランタイムの裏側で何が起きているかを解像度高く把握した上でコードを書く人間だけが、真に堅牢で高速なWebアプリケーションを構築できる。
今日のコードレビューから、あなたの「変数とスコープを見る眼」が変わることを期待している。