スコープ

変数や関数が参照可能な範囲を定義するプログラミングの基本概念

JavaScript基礎

スコープとは

スコープ (Scope) は、変数や関数が参照可能な範囲を定義するプログラミングの基本概念である。JavaScript のスコープは静的 (レキシカル) に決まる。どの宣言を参照するかはソースコード上のネスト位置だけで確定し、実行時にどこから呼ばれたかには左右されない。だからコードを読むだけで参照先を追える。

範囲を作る単位はグローバル、関数、ブロックの 3 つで、どれに属するかは宣言に使ったキーワードと宣言を置いた場所で決まる。

スコープの種類

スコープ宣言範囲
グローバル従来のスクリプトのトップレベルの var, functionどこからでも参照可能 (globalThis のプロパティになる)
モジュールES モジュールのトップレベルの全宣言そのファイル内のみ
関数var, function宣言を囲む最も内側の関数の全体
ブロックlet, const, class宣言を囲む {} の中のみ

トップレベルの宣言が必ずグローバルになるわけではない。<script> で読み込む従来のスクリプトなら var g = 1globalThis.g になるが、ES モジュール (type="module".mjs) では同じ宣言がモジュールスコープに閉じ、globalThis.gundefined のままになる。Node.js の CommonJS も各ファイルが関数で包まれるため、同じくグローバルを汚さない。

var と let と const の差

3 つの差はスコープの単位と再代入の可否に集約される。var はブロックを無視して関数全体に広がるため、iffor の中で宣言した変数がブロックの外でも生き残る。letconst はブロック単位で閉じる。

// var: 関数スコープ (ブロックを無視)
if (true) { var x = 1; }
console.log(x); // 1 (ブロックの外でも参照可能)

// let: ブロックスコープ
if (true) { let y = 2; }
console.log(y); // ReferenceError (ブロックの外では参照不可)

// const: ブロックスコープ + 再代入不可
const z = 3;
z = 4; // TypeError (TypeScript ならコンパイル時に検出される)

const が禁じるのは束縛先の差し替えだけで、値の中身を凍結するものではない。const arr = [] に対する arr.push(1) は通り、arr = [] だけが失敗する。

ホイスティングと一時的デッドゾーン

宣言はスコープの先頭で登録される (ホイスティング) が、登録と初期化は別の段階である。var は登録の時点で undefined が入るため、宣言行より前に読んでもエラーにならず undefined が返る。letconst は登録されるだけで未初期化のまま置かれ、宣言行に到達するまでの区間はアクセスすると例外になる。この区間を一時的デッドゾーン (Temporal Dead Zone) と呼ぶ。

function f() {
  console.log(v); // undefined (var は登録済み・未代入)
  var v = 1;
}

function g() {
  console.log(t); // ReferenceError: Cannot access 't' before initialization
  let t = 1;
}

デッドゾーン中の変数は typeof でも例外になる点が実務では効く。宣言自体が無い名前に対する typeof x は例外を出さず 'undefined' を返すため、typeof で存在を確かめる書き方は letconst には通じない。宣言してから使う順序を守るのが唯一の回避策である。

レキシカルスコープの解決順

変数名は、宣言を囲むスコープを内側から外側へ順にたどって解決される。最初に見つかった宣言が参照先になり、最も外までたどって見つからなければ ReferenceError になる。参照は内側から外側への一方通行で、外側から内側は見えない。

const outer = 'outer';

function foo() {
  const inner = 'inner';
  console.log(outer); // ✅ 外側のスコープを参照可能
}

console.log(inner); // ❌ ReferenceError (関数の内側は外から見えない)

たどる経路は定義位置で固定される。下の showcaller から呼ばれても callerlabel ではなく、自分が定義された位置から見える label を返す。呼び出し元のスコープを参照する方式 (動的スコープ) を採る言語とは、ここが分かれる。

const label = 'module';
function show() { return label; }
function caller() {
  const label = 'caller';
  return show(); // 'module' が返る
}

クロージャとスコープ

関数が定義位置のスコープを抱えたまま外へ持ち出される形がクロージャである。外側の関数が終了してもそのスコープは解放されず、返された関数からだけ参照できる状態として残る。

function createCounter() {
  let count = 0; // createCounter のスコープ
  return () => ++count; // 定義位置のスコープを抱えて外へ出る
}
const counter = createCounter();
counter(); // 1
counter(); // 2
// count は外部からアクセスできないが、返された関数からは参照可能

サーバーレスではスコープが寿命を決める

スコープの寿命が実行環境の挙動に直結する場面もある。AWS Lambda では、ハンドラの外側 (モジュールスコープ) に書いた初期化はコールドスタート時に 1 回だけ走り、同じ実行環境が再利用される間は残り続ける。ハンドラの内側で宣言した変数は呼び出しごとに作り直される。

スコープLambda での挙動
モジュールスコープコールドスタート時に初期化、ウォームスタートで再利用
ハンドラスコープ毎回のリクエストで新規作成

この差を利用してコネクションプールや設定の読み込みをモジュールスコープへ置くと、初期化のコストを削れる。反面、リクエスト固有の値をモジュールスコープの変数に持たせると、後続の呼び出しに前のデータが漏れる。モジュールスコープへ置くのは、呼び出しをまたいで共有しても安全なものだけに限る。

スコープの落とし穴

var を使った for は、反復をまたいで 1 個の変数を共有する。setTimeout のコールバックが動くのはループが終わった後なので、3 つのコールバックが同じ変数を読み、終了時点の値だけが見える。letfor は反復ごとに新しい束縛を作るため、各コールバックが自分の反復の値を持つ。

// ❌ var + ループ: 全て同じ i を参照
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // 3, 3, 3
}

// ✅ let: 各反復で新しいスコープ
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // 0, 1, 2
}

ベストプラクティス

宣言のキーワードは、スコープを狭く保つ方向へ倒して選ぶ。

プラクティス理由
const をデフォルトで使う再代入を防止
let は再代入が必要な場合のみスコープが明確
var は使わない関数スコープで予期しない挙動
グローバル変数を避ける名前の衝突、テスト困難

詳しくは関連書籍を参照。

この記事は役に立ちましたか?

関連用語

関連する記事