JavaScript上級 #1 クロージャとスコープ

JavaScript 上級シリーズの始まりです。基礎 / 中級で扱った道具を自由に使えるようになったなら、今度はその道具たちがなぜそう動作するのかを覗いてみる番です。

全 7 編で構成されます。

  • #1 クロージャとスコープ ← この記事
  • #2 this バインディングと呼び出しパターン
  • #3 プロトタイプチェーン
  • #4 イベントループとタスク
  • #5 メモリモデルと GC
  • #6 Symbol、WeakRef、Proxy
  • #7 モジュールシステム深掘り

この記事では、JavaScript の最も重要な概念の一つである クロージャ を扱います。

スコープとは — 変数が見える範囲 #

まずスコープ(scope)の意味を整理しましょう。スコープは変数がどこから見えるかを規定します。

ブロックスコープ
{
  const a = 10;
  console.log(a);   // 10
}
console.log(a);     // ✗ ReferenceError

{} の中で宣言した変数は、その中だけで見えます。基礎 #2 で見た let/const のブロックスコープがこれです。

関数スコープ + ブロックスコープ #

JavaScript には 2 種類のスコープがあります。

  • 関数スコープvarfunction 宣言が従う。関数単位で範囲が決まる。
  • ブロックスコープlet/const が従う。{...} 単位。

新しいコードでは let/const を使うので、ほぼブロックスコープだけ気にすればよいです。

レキシカルスコープ — どこに書いたかで決まる #

JavaScript はレキシカル(lexical)スコープに従います。変数がどこから見えるかは、関数がどう呼び出されたかではなく、どこに書かれているかで決まります。

レキシカルスコープ
const message = '外側';

function inner() {
  console.log(message);   // '外側' — 自分の位置から見える変数
}

function outer() {
  const message = '内側';
  inner();                 // どこから呼んでも結果は同じ
}

outer();   // '外側'

innerouter の中で呼ばれたとしても、innermessage自分が書かれた場所から見える外側の変数を指します。呼び出し位置ではなく定義位置が基準です。

クロージャ — 関数が自分のスコープを引き連れていく #

基礎 #4 で少し見たカウンタの例。

カウンタ — クロージャ
function createCounter() {
  let count = 0;

  return function() {
    count = count + 1;
    return count;
  };
}

const counter = createCounter();
counter();   // 1
counter();   // 2
counter();   // 3

createCounter が終わったのに、その中の count が生きています。返された関数が自分が作られた環境(スコープ)を引き連れて動くからです。これをクロージャ(closure)と呼びます。

正確な定義 #

クロージャは関数と、その関数が作られたレキシカル環境の組み合わせ

関数自体だけでなく、その関数が参照している外側の変数までまとめて呼ぶ名前です。JavaScript のすべての関数は実はクロージャです — ただ外側の変数を使わない関数はその意味が薄いだけです。

クロージャが作り出すもの — private な状態 #

複数インスタンスの分離された状態
function createCounter() {
  let count = 0;
  return {
    increment() { count++; return count; },
    decrement() { count--; return count; },
    get value() { return count; },
  };
}

const a = createCounter();
const b = createCounter();

a.increment();  // 1
a.increment();  // 2
b.increment();  // 1 — a と無関係

console.log(a.value);  // 2
console.log(b.value);  // 1

a.count;        // undefined — 外部から直接アクセス不可

呼び出しごとに新しいクロージャが作られ count を別々に持ちます。外側から a.count でアクセスできません — モジュールパターンの private な状態がこのように作られます。

ES2022 の #フィールド (中級 #1) が登場する前、JavaScript の private はほぼクロージャで作られていました。

コールバックとクロージャ — 最もよく出会う場所 #

非同期コールバック、イベントハンドラは事実上クロージャの応用です。

イベントハンドラ — クロージャ
function attachHandlers(buttons) {
  buttons.forEach((btn, i) => {
    btn.addEventListener('click', () => {
      console.log(`${i}番目のボタンクリック`);
    });
  });
}

各ハンドラが自分の番の i 値を覚えています。関数が生きている間、その場所の i も一緒に生きています。

昔の落とし穴 — var とクロージャ #

ES2015 以前の JavaScript で最も有名な落とし穴。

var の落とし穴
function attachOld(buttons) {
  for (var i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function() {
      console.log(`${i}番目のボタン`);
    });
  }
}

このコードはすべてのボタンが最後の i 値を出力します。var は関数スコープなので、すべてのコールバックが同じ i を共有していたためです。

let に変えれば解決します。

let — 反復ごとに新しい変数
for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function() {
    console.log(`${i}番目のボタン`);
  });
}

let はブロックスコープなので、繰り返しごとに新しい i が作られ、各コールバックが別の i をキャプチャします。let/const が登場した大きな理由の一つがまさにこの場面でした。

クロージャで作るパターン #

1) 部分適用 (Partial Application) #

引数を先に束ねる
function add(a, b) {
  return a + b;
}

function partial(fn, ...preset) {
  return function(...rest) {
    return fn(...preset, ...rest);
  };
}

const add5 = partial(add, 5);
add5(3);   // 8
add5(10);  // 15

preset をクロージャが覚えておきます。関数型プログラミングのよく使われる道具です。

2) メモ化 (Memoization) #

結果のキャッシング
function memoize(fn) {
  const cache = new Map();
  return function(arg) {
    if (cache.has(arg)) return cache.get(arg);
    const result = fn(arg);
    cache.set(arg, result);
    return result;
  };
}

const slowSquare = (n) => {
  console.log(`計算: ${n}`);
  return n * n;
};

const fastSquare = memoize(slowSquare);
fastSquare(5);   // 計算: 5 → 25
fastSquare(5);   // 25 (キャッシュ)
fastSquare(3);   // 計算: 3 → 9

cache Map がクロージャで生き残ります。React の useMemo、lodash の memoize もすべて同じアイディアです。

3) デバウンス / スロットル #

デバウンス
function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const handleSearch = debounce((query) => {
  fetch(`/api/search?q=${query}`);
}, 300);

input.addEventListener('input', (e) => handleSearch(e.target.value));

timer 変数が呼び出しの間に生き残り、前のタイマーをキャンセルします。入力が止まってから 300ms 経たないと実際の呼び出しが行われません。

落とし穴 — 長く生き残りすぎるクロージャ #

クロージャが外側の変数をキャプチャすると、その変数は関数が生きている間 GC されません

メモリリークの可能性
function setupHeavy() {
  const huge = new Array(1_000_000).fill(0);  // 大きな配列

  return function() {
    console.log('hi');
  };
}

const fn = setupHeavy();   // huge が fn によって生き残る (不要なのに)

上の関数は huge を使わないのに、クロージャがキャプチャしてメモリ上に生きています。モダンな JavaScript エンジンは使わない変数はキャプチャしないように努めるのですが、意図的に大きなオブジェクトを早く解放したいなら、自分で null にしておくほうが安全です。

明示的に解放
function setupHeavy() {
  let huge = new Array(1_000_000).fill(0);
  process(huge);
  huge = null;   // もう不要なら解放

  return function() { console.log('hi'); };
}

この場面は #5 メモリモデル でさらに詳しく扱います。

IIFE 再訪 — 古いモジュールパターン #

基礎 #4 で IIFE を少し見ました。クロージャの観点から見直すと、モジュールシステムがなかった時代の private スコープの隔離がこれでした。

昔の IIFE モジュールパターン
const counter = (function() {
  let count = 0;
  return {
    increment() { return ++count; },
    decrement() { return --count; },
    get value() { return count; },
  };
})();

counter.increment();  // 1
counter.count;         // undefined — 見えない

ES Modules が登場してからはほとんど使われませんが、古いライブラリのコードによく出てきます。

TDZ (Temporal Dead Zone) #

let/const の興味深い動作のひとつ。

TDZ
console.log(x);          // ✗ ReferenceError
let x = 10;

宣言行の前にアクセスするとエラーになります。varundefined でしたが、letエラーを投げます。

これを一時的な死角(Temporal Dead Zone)と呼びます。変数が宣言される行の前は「存在するが使用不可」の状態です。誤って宣言前に使う事故を防いでくれます。

まとめ #

この記事で整理した内容:

  • スコープ — 変数が見える範囲(ブロック / 関数スコープ)
  • JavaScript はレキシカルスコープ — 定義位置が基準
  • クロージャ = 関数 + その関数のレキシカル環境
  • private な状態、コールバック、部分適用、メモ化、デバウンスはすべてクロージャ
  • var が作る古い落とし穴 — let が解決
  • クロージャはキャプチャした変数のメモリを生かしておく — 大きなオブジェクトは明示的に解放
  • TDZ — let/const は宣言前のアクセスでエラー

次の記事(#2 this バインディングと呼び出しパターン)では JavaScript のもう一つの混乱しやすい概念 — this が呼び出し方によってどう決まるか、call/apply/bind の意味とアロー関数との違いを扱います。

X