TL

スコープチェーンとクロージャの内部表現(環境レコード)

クロージャが変数を覚えている理由を内部から説明できるようになる。レキシカル環境・環境レコード・外部参照のチェーン構造から、捕捉と保持の仕組みを原理で解説します。

応用JavaScriptクロージャスコープ実行コンテキストECMAScript最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.実行中のスコープはレキシカル環境(Lexical Environment)で表され、変数を保持する環境レコードと、外側を指す外部参照([[OuterEnv]])の対で構成される。
  • 2.変数解決は現在の環境レコードから外部参照をたどって外へ向かう探索=スコープチェーンで、ネスト構造ではなく定義位置(レキシカル)で決まる。
  • 3.クロージャは関数オブジェクトが生成時の外部環境への参照([[Environment]])を持つこと。捕捉とは値のコピーではなく環境レコードへの参照保持で、GC到達可能なまま残る。

なぜ環境レコードを知るのか

クロージャは「関数が外側の変数を覚えている仕組み」と説明されがちですが、これでは「いつ何が捕捉され、いつまで生き残るのか」を正確に予測できません。ループ内のvarが最後の値だけを返す、letなら各反復が別々の値を持つ、といった挙動の差も、表層の説明では腑に落ちないはずです。鍵はECMAScript仕様の内部構造で、スコープは**レキシカル環境(Lexical Environment)**という実行時データ構造で表現されます。本稿はこの環境レコードと外部参照のチェーンを土台に、変数解決とクロージャの捕捉・保持を原理から説明します。前提として JavaScript の値と関数、実行モデルは イベントループの内部動作 を押さえておくと理解が速いです。

レキシカル環境:環境レコードと外部参照の対

コードを実行するとき、エンジンは各スコープに対応するレキシカル環境を生成します。これは概念上、二つの要素の対です。

構成要素役割保持するもの
環境レコード (Environment Record)このスコープで宣言された識別子と値の対応表変数・関数・引数の束縛(binding)
外部参照 ([[OuterEnv]])一つ外側のレキシカル環境へのポインタ親スコープの環境への参照1本

環境レコードは識別子(変数名)から値(または束縛の格納場所)へのマップです。外部参照は仕様上の内部スロット[[OuterEnv]]で、コードの定義位置で外側にあたる環境を指します。重要なのは、外部参照が「呼び出した側」ではなく「定義された場所の外側」を指す点です。これがレキシカル(静的)スコープの本質で、関数がどこから呼ばれたかではなく、ソース上どこに書かれたかでスコープが決まります。

環境レコードの種類

仕様は環境レコードを用途別に分けています。関数本体の引数やvar/let/constを持つ宣言的環境レコード(Declarative)、グローバルやwithが使うオブジェクト環境レコード(Object)、関数呼び出しでthisやsuperを束縛する関数環境レコード(Function)などです。実装の詳細は異なりますが、「識別子→値の表+外側への参照」という骨格は共通です。

スコープチェーン:外へたどる識別子解決

変数を参照すると、エンジンは現在のレキシカル環境の環境レコードを調べます。見つからなければ[[OuterEnv]]をたどって一つ外側の環境を調べ、また見つからなければさらに外へ——と進みます。グローバル環境まで探して無ければReferenceErrorです。この外向きの探索経路がスコープチェーンです。

const g = "global";
function outer() {
  const o = "outer";
  function inner() {
    const i = "inner";
    return g + o + i; // i は自身の環境、o は外部参照先、g はさらに外
  }
  return inner();
}

innerの中でoを参照すると、innerの環境レコードには無いので[[OuterEnv]]outerの環境)を見て発見します。チェーンは一方向(内→外)で、外側から内側の変数は見えません。また探索は近い側が優先なので、内側で同名を宣言すると外側をシャドーイングします。

チェーンは構造の入れ子ではなく定義位置で決まる

スコープチェーンの形は実行時のコールスタックの深さではなく、ソースコード上のネストで固定されます。innerを別の関数に渡して遠くから呼んでも、inner[[OuterEnv]]は定義時のouterの環境を指したままです。だから「どこで呼ぶか」を変えても見える変数は変わりません。

関数オブジェクトと [[Environment]]:捕捉の正体

ここがクロージャの核心です。関数を定義(評価)した瞬間、エンジンは関数オブジェクトを作り、その内部スロット[[Environment]]そのとき実行中のレキシカル環境への参照を格納します。これが「外側のスコープを捕捉する」の実体です。

後にその関数を呼び出すと、新しいレキシカル環境(呼び出しごとの引数やローカル変数を持つ)が作られ、その[[OuterEnv]][[Environment]]の値がセットされます。つまり呼び出し時のスコープチェーンの外側は、呼び出し元ではなく定義時に捕捉した環境になります。クロージャとは特別な機能ではなく、「関数オブジェクトが[[Environment]]で外部環境を持つ」という常時成り立つ性質の名前です。

捕捉は値のコピーではない

クロージャが捕捉するのは変数の「その時の値」ではなく、環境レコードそのものへの参照です。だから捕捉後に外側で変数を書き換えれば、クロージャから見える値も変わります。「スナップショットを撮る」のではなく「同じ箱を共有する」と理解してください。

function counter() {
  let n = 0;
  return {
    inc: () => ++n, // どちらのクロージャも同じ n を共有
    get: () => n,
  };
}
const c = counter();
c.inc(); c.inc();
c.get(); // 2 :同一の環境レコードを参照しているため

incgetは同じcounter呼び出しの環境を捕捉するので、**同一のn**を共有します。値のコピーなら共有は起きません。共有こそが参照捕捉の証拠です。

寿命とGC:環境はいつまで残るか

関数を抜けると、その実行コンテキストはコールスタックから取り除かれます。素朴には対応するレキシカル環境も消えるはずですが、捕捉している関数が生きている限り、その環境レコードは[[Environment]]経由で到達可能なため回収されません。これがクロージャが変数を「保持」する正体です。スコープの寿命は構文ブロックの終わりではなく、到達可能性で決まります。

function makeAdder(x) {
  return (y) => x + y; // x を捕捉
}
const add5 = makeAdder(5);
// makeAdder は終了済みだが、add5 が捕捉環境を生かしている
add5(3); // 8

makeAdderの実行は終わっていますが、返された関数がxを含む環境を捕捉しているので、xは生き続けます。逆に言えば、不要になった関数(イベントハンドラ、setIntervalのコールバックなど)が大きなデータを捕捉したまま残ると、その環境ごと回収されずメモリリークになります。到達可能性に基づく回収の詳細は ガベージコレクションのブラウザ内動作 を参照してください。

var と let:ループで挙動が分かれる理由

スコープと環境の観点から、有名な「ループ+クロージャ」問題が説明できます。varは関数スコープで、ループ全体でただ一つの束縛を共有します。一方let反復ごとに新しい環境レコードを生成し、各反復が独立した束縛を持ちます。

宣言束縛の単位ループでの捕捉結果
var i関数スコープに1個全クロージャが最終値を共有
let i反復ごとに新規生成各クロージャが各回の値を保持
const vfns = [];
for (var i = 0; i < 3; i++) vfns.push(() => i);
vfns.map((f) => f()); // [3, 3, 3] :単一の i を共有

const lfns = [];
for (let j = 0; j < 3; j++) lfns.push(() => j);
lfns.map((f) => f()); // [0, 1, 2] :反復ごとに別の j

var版は全クロージャが同じiを捕捉し、ループ終了後の値3を返します。let版は仕様上、各反復の前に前回の値を引き継いで新しい束縛を作る(CreatePerIterationEnvironment)ため、各クロージャがその回専用の環境レコードを捕捉します。捕捉が「参照の共有」だと理解していれば、この差は当然の帰結です。

TDZと巻き上げ:束縛はいつ作られるか

環境レコード上の束縛は、コードのどこで「生成」され、どこで「初期化」されるかが分かれています。varは環境生成時に束縛が作られundefined初期化済みになるため、宣言前に参照してもundefinedが返ります(巻き上げ)。対してlet/constは束縛こそ環境生成時に作られますが未初期化のまま置かれ、宣言文に到達して初めて初期化されます。

この「束縛は存在するが未初期化」の区間が**一時的デッドゾーン(TDZ, Temporal Dead Zone)**です。TDZ中にその識別子へアクセスするとReferenceErrorになります。

console.log(a); // undefined :var は初期化済み
var a = 1;
console.log(b); // ReferenceError :b はTDZ(束縛はあるが未初期化)
let b = 2;
TDZは“宣言が無い”のではない

TDZのエラーは「変数が存在しない」からではありません。環境レコードには既に束縛が登録されており、初期化前だから触れないという意図的な保護です。スコープチェーンの探索ではlet bが先にヒットするため、外側の同名変数を拾うこともしません。巻き上げの有無で挙動が分かれるのは、束縛の生成と初期化のタイミングが宣言の種類で違うからです。

まとめ

まとめ

スコープは実行時のレキシカル環境で表され、環境レコード(識別子→値の表)と外部参照[[OuterEnv]](外側の環境へのポインタ)の対です。変数解決は環境レコードから外部参照をたどるスコープチェーンで、経路は呼び出し位置ではなく定義位置で固定されます。クロージャは特別な機能ではなく、関数オブジェクトが定義時の環境を[[Environment]]に保持する常時の性質で、捕捉は値のコピーではなく環境レコードへの参照共有です。だから複数クロージャは同じ束縛を共有し、捕捉が生きる限り環境はGCで回収されません。varletのループ差も、束縛が関数スコープに1個か反復ごとに新規かの違いで説明でき、TDZは「束縛はあるが未初期化」の保護区間です。実行時の最適化の土台は JavaScriptエンジンの内部 と合わせて押さえると、変数アクセスの速さまで一貫して理解できます。

Web/フロントエンド Article

スコープチェーンとクロージャの内部表現(環境レコード)を実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

JavaScript

比較で見る軸

難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5

導入後に効く点

変数解決は現在の環境レコードから外部参照をたどって外へ向かう探索=スコープチェーンで、ネスト構造ではなく定義位置(レキシカル)で決まる。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
Web/フロントエンド
タグ数
5

判断チェックリスト

  • 自社の用途が「JavaScript / クロージャ」に近いか確認する。
  • 強みである「実行中のスコープはレキシカル環境(Lexical Environment)で表され、変数を保持する環境レコードと、外側を指す外部参照([[OuterEnv]])の対で構成される。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

JavaScriptクロージャスコープ実行コンテキストECMAScriptJavaScriptクロージャスコープ
参考: 公式情報