概要
クロージャ (Closure) とは、関数がその定義時のレキシカル環境 (Lexical Environment) を保持する仕組みのことである。
JavaScriptでは、内部関数が外部関数の変数を参照する場合、外部関数の実行が終了した後もその変数にアクセスし続けることができる。
この動作の根拠となるのがレキシカルスコープ (Lexical Scope) である。
JavaScriptはレキシカルスコープを採用しており、関数のスコープは定義された場所によって決定される。
呼び出し場所は関係しない。
クロージャは、カウンタの状態管理、プライベート変数のエミュレート、関数ファクトリ、メモ化 (Memoization) 等、多様な実用パターンで活用される。
モダンJavaScript開発において不可欠な概念であり、ReactのHooks (useState、useEffect等) もクロージャに基づいて実装されている。
一方で、stale closure (古いクロージャ) と呼ばれる問題も存在する。
クロージャが古い状態のスナップショットを参照し続け、期待した値と異なる値を扱ってしまう現象である。
Reactのフック内で発生しやすく、特に useEffect 内での setInterval や setTimeout との組み合わせで注意が必要である。
クロージャはJavaScriptの関数とレキシカルスコープを深く理解することで初めて使いこなせる機能であり、設計力に直結する重要な概念である。
レキシカルスコープ
スコープチェーン
JavaScriptで変数を参照する時、エンジンは現在のスコープから外側に向かって順番に変数を探索する。
この探索経路をスコープチェーン (Scope Chain) と呼ぶ。
探索の順序は以下の通りである。
- ローカルスコープ
- 現在の関数内で宣言された変数を最初に探す。
- 外側のスコープ
- ローカルスコープで見つからない場合、1つ外側の関数スコープへと遡る。
- グローバルスコープ
- 最終的にグローバルスコープまで遡り、見つからない場合は ReferenceError となる。
スコープチェーンの動作例を以下に示す。
const globalVar = "global";
function outer() {
const outerVar = "outer";
function inner() {
const innerVar = "inner";
console.log(innerVar); // "inner" : ローカルスコープ
console.log(outerVar); // "outer" : 外側のスコープ
console.log(globalVar); // "global" : グローバルスコープ
}
inner();
}
outer();
JavaScriptには、3種類のスコープが存在する。
| スコープ | 説明 |
|---|---|
| グローバルスコープ | スクリプト全体からアクセスできる。var のトップレベル宣言 や let / const のトップレベル宣言が対象
|
| 関数スコープ | 関数の {} 内で定義された変数が対象var はこのスコープを使用する。
|
| ブロックスコープ | if、for、while 等の {} 内で定義された変数が対象let と const はこのスコープを使用する。
|
静的スコープ と 動的スコープ
スコープの決定方式には、静的スコープと動的スコープの2種類がある。
JavaScriptは静的スコープ (レキシカルスコープ) を採用している。
| 方式 | 説明 |
|---|---|
| 静的スコープ (レキシカルスコープ) | 変数のスコープはソースコードを定義した場所によって決定される。 JavaScriptはこの方式を採用している。 |
| 動的スコープ | 変数のスコープは関数が呼び出された場所によって決定される。 JavaScriptは採用していない。 |
静的スコープの動作を実証する例を以下に示す。
const x = "global";
function showX() {
console.log(x); // 定義時のスコープ (グローバル) のxを参照
}
function callShowX() {
const x = "local"; // この変数は、showX() のスコープに影響しない
showX(); // "global"が出力される
}
callShowX(); // "global"
showX() 関数は callShowX() の内部から呼び出されているが、showX() のスコープは定義場所 (グローバルスコープ) によって決定される。
そのため、callShowX() 内の const x = "local" は参照されず、グローバルの x が出力される。
クロージャの仕組み
クロージャとは
クロージャとは、内部関数が外部関数の変数を参照しており、外部関数の実行が終了した後もその変数にアクセスできる状態のことを指す。
以下に基本的なクロージャの例を示す。
makeGreeter("Hello") の実行が終了した後も、返された内部関数は変数 greeting に "Hello" の値でアクセスし続けることができる。
これがクロージャである。
function makeGreeter(greeting) {
// greeting は makeGreeter のローカル変数
return function(name) {
// 内部関数が外部関数の greeting を参照している
console.log(greeting + ", " + name);
};
}
const helloGreeter = makeGreeter("Hello");
const hiGreeter = makeGreeter("Hi");
helloGreeter("Alice"); // "Hello, Alice"
helloGreeter("Bob"); // "Hello, Bob"
hiGreeter("Carol"); // "Hi, Carol"
クロージャが変数を保持する理由
関数オブジェクトは内部に Environment という内部スロットを持つ。
このスロットは、関数が定義された時点のレキシカル環境への参照を保持している。
内部関数が外部変数を参照している限り、JavaScriptのガベージコレクタ (GC) はそのレキシカル環境を解放できない。
これにより、外部関数の実行が終了しても変数が保持される。
また、クロージャはスナップショットではなくリアルタイムの参照であることに注意する。
外部変数の値が変更された場合、クロージャはその変更を反映した最新の値を参照する。
function makeCounter() {
let count = 0;
return {
increment: function() { count++; },
getCount: function() { return count; }
};
}
const counter = makeCounter();
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2 : リアルタイム参照のため最新値が返される
increment と getCount はどちらも同じ count 変数への参照を共有している。
increment で変更した値が getCount で取得できるのは、クロージャがリアルタイムの参照だからである。
クロージャとループ
ループ内でクロージャを作成する際、var を使用すると意図しない動作が発生する。
var はブロックスコープを持たないため、全てのクロージャが同一の変数を参照してしまう。
- 問題のあるコード例
// var を使用した場合 : 全て同じ変数 i を参照する for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i); // 3, 3, 3 が出力される (期待値: 0, 1, 2) }, 100); }
- この問題を解決する方法として、以下の2種類がある。
// 解決策1 : let を使用する (推奨) // let はループの各イテレーションで新しいブロックスコープを作成する for (let i = 0; i < 3; i++) { setTimeout(function() { console.log(i); // 0, 1, 2 が正しく出力される }, 100); } // 解決策2 : IIFE (即時実行関数式) を使用する (ES2015以前) // 各イテレーションの i の値を IIFE の引数に渡して新しいスコープを作成する for (var i = 0; i < 3; i++) { (function(j) { setTimeout(function() { console.log(j); // 0, 1, 2 が正しく出力される }, 100); })(i); }
現代的なJavaScript開発では let を使用する解決策が推奨される。
IIFEによる解決策は、ES2015以前のコードベースで見られることがある。
実用例
カウンタ
クロージャを使用したカウンタは、状態を安全に保持しながら複数のメソッドで操作できる実用的なパターンである。
変数 count は外部から直接アクセスできず、返されたオブジェクトのメソッドを通じてのみ操作できる。
function createCounter(initialValue = 0) {
let count = initialValue;
return {
increment: function() {
count++;
return count;
},
decrement: function() {
count--;
return count;
},
reset: function() {
count = initialValue;
return count;
},
getCount: function() {
return count;
}
};
}
const counter = createCounter(10);
console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.decrement()); // 11
console.log(counter.getCount()); // 11
console.log(counter.reset()); // 10
// countに直接アクセスはできない
console.log(counter.count); // undefined
プライベート変数
クロージャを使用することで、外部からのアクセスを制限したプライベート変数を実現できる。
オブジェクトのデータを保護し、getter / setterを通じてバリデーションを行うパターンである。
function createUser(initialName) {
let name = initialName; // プライベート変数
let age = 0; // プライベート変数
return {
getName: function() {
return name;
},
setName: function(newName) {
if (typeof newName === "string" && newName.length > 0) {
name = newName;
} else {
console.log("無効な名前です");
}
},
getAge: function() {
return age;
},
setAge: function(newAge) {
if (typeof newAge === "number" && newAge >= 0) {
age = newAge;
} else {
console.log("無効な年齢です");
}
}
};
}
const user = createUser("Alice");
console.log(user.getName()); // "Alice"
user.setName("Bob");
console.log(user.getName()); // "Bob"
user.setAge(-5); // "無効な年齢です"
user.setAge(30);
console.log(user.getAge()); // 30
// プライベート変数には直接アクセスできない
console.log(user.name); // undefined
このパターンはモジュールパターン (Module Pattern) とも呼ばれ、クロージャによってデータのカプセル化を実現している。
関数ファクトリ
関数ファクトリ (Function Factory) とは、設定値をクロージャで保持し、同じロジックで異なる動作をする関数を生成するパターンである。
// 乗算関数ファクトリ
function createMultiplier(multiplier) {
return function(number) {
return number * multiplier;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
const times10 = createMultiplier(10);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(times10(5)); // 50
// 挨拶関数ファクトリ
function createGreeter(greeting) {
return function(name) {
return greeting + ", " + name + "!";
};
}
const sayHello = createGreeter("Hello");
const sayGoodbye = createGreeter("Goodbye");
console.log(sayHello("Alice")); // "Hello, Alice!"
console.log(sayGoodbye("Bob")); // "Goodbye, Bob!"
関数ファクトリを使用することにより、ソースコードの重複を避けながら柔軟な設定を持つ関数を簡潔に作成できる。
メモ化
メモ化 (Memoization) は、関数の計算結果をキャッシュし、同じ入力に対して再計算を行わずにキャッシュから値を返す最適化技法である。
クロージャを使用してキャッシュオブジェクトを保持する。
以下の例では、cache オブジェクトはクロージャによって保持され、memoizedCalc が呼び出されるたびに参照される。
同じ引数での再計算を防ぐことで、パフォーマンスを改善できる。
function memoize(fn) {
const cache = {}; // クロージャでキャッシュを保持
return function(...args) {
const key = JSON.stringify(args);
if (key in cache) {
console.log("キャッシュから返却: " + key);
return cache[key];
}
const result = fn(...args);
cache[key] = result;
return result;
};
}
// 重い計算を行う関数
function expensiveCalc(n) {
console.log("計算中: " + n);
return n * n;
}
const memoizedCalc = memoize(expensiveCalc);
console.log(memoizedCalc(5)); // "計算中: 5" -> 25
console.log(memoizedCalc(5)); // "キャッシュから返却: [5]" -> 25
console.log(memoizedCalc(10)); // "計算中: 10" -> 100
console.log(memoizedCalc(10)); // "キャッシュから返却: [10]" -> 100
下表に、クロージャを使用した実用パターンの比較を示す。
| パターン名 | 用途 | 特徴 |
|---|---|---|
| カウンタ | 状態の保持と管理 | プライベート変数をカウント値として複数メソッドで共有 |
| プライベート変数 | データ保護とバリデーション | getter / setterで直接アクセスを制限 |
| 関数ファクトリ | 設定値を持つ関数の生成 | 同じロジックで異なる設定の関数を作成 |
| メモ化 | 計算結果のキャッシュ | 同じ入力に対して計算を繰り返さない |
クロージャの注意点
メモリへの影響
クロージャは外部関数のレキシカル環境への参照を保持するため、参照が存在する限りガベージコレクタはその環境を解放できない。
大きなオブジェクトや大量のデータへの参照をクロージャが保持している場合、メモリリークの原因となる可能性がある。
function createHeavyClosure() {
const largeArray = new Array(1000000).fill("data"); // 大きなオブジェクト
return function() {
// largeArray を参照しているため、GC によって解放されない
return largeArray[0];
};
}
let heavyClosure = createHeavyClosure();
console.log(heavyClosure()); // "data"
// 参照を解放することにより、GCがlargeArrayを回収できるようになる
heavyClosure = null;
参照を解放する方法として、クロージャを保持している変数に null を代入することが有効である。
これにより、クロージャからのレキシカル環境への参照が切れ、ガベージコレクタが対象のメモリを回収できるようになる。
大きなオブジェクトを扱う場合は、クロージャで保持する必要があるのはその一部のデータだけであることが多い。
必要な値だけを抽出してクロージャに渡すことにより、不要なメモリ保持を防ぐことができる。
function createEfficientClosure() {
const largeArray = new Array(1000000).fill("data");
const neededValue = largeArray[0]; // 必要な値だけ抽出
// largeArray 自体への参照はクロージャに含まれない
return function() {
return neededValue;
};
}
意図しないクロージャ
クロージャは意図せず作成されることがある。
必要のない変数への参照を保持し続けることでメモリを無駄に消費したり、デバッグを困難にする場合がある。
// 意図しないクロージャの例
function setup() {
const largeData = fetchLargeData(); // 大きなデータを取得
const id = largeData.id; // 必要なのは id だけ
// largeData 全体がクロージャに捕捉されてしまう
return function handler() {
process(largeData);
};
}
// 推奨 : 必要な値だけをクロージャに渡す
function setupBetter() {
const largeData = fetchLargeData();
const id = largeData.id;
// id だけがクロージャに捕捉される
return function handler() {
process({ id: id });
};
}
クロージャが保持している変数を確認するには、Chrome DevToolsの[Sources]パネルを使用する。
ブレークポイントを設定した状態で実行すると、右側の[Scope]パネルに[Closure]のセクションが表示されて、クロージャが保持している変数の一覧を確認できる。
Reactとクロージャ
Hooksとクロージャ
ReactのHooks (useState、useEffect 等) はクロージャに基づいて実装されている。
関数コンポーネントが呼び出されるたびに新しいレキシカル環境が生成され、各レンダリング時の状態がクロージャによって管理される。
import React, { useState, useEffect } from "react";
function Counter() {
const [count, setCount] = useState(0);
useEffect(function() {
// この関数はクロージャであり、レンダリング時の count を参照している
console.log("count の値:", count);
}, [count]); // count が変更されるたびに実行される
return (
<div>
<p>Count: {count}</p>
<button onClick={function() { setCount(count + 1); }}>
インクリメント
</button>
</div>
);
}
useState の state 値は、各レンダリング時のスナップショットとして機能する。
useEffect 内のクロージャは、エフェクトが実行された時点のレンダリングの state を参照する。
stale closure問題への伏線
stale closure (古いクロージャ) 問題とは、クロージャが古いレンダリング時の状態を参照し続けることで、期待した値と異なる値を参照してしまう現象である。
特に、useEffect 内の setInterval と useState の組み合わせで発生しやすい。
import React, { useState, useEffect } from "react";
function StaleClosureExample() {
const [count, setCount] = useState(0);
useEffect(function() {
// count の初期値 0 をキャプチャしたクロージャが作成される
const interval = setInterval(function() {
// ここでの count は常に 0 (初期値) を参照する
console.log("count:", count); // 常に 0 が出力される
setCount(count + 1); // 常に 0 + 1 = 1 となる
}, 1000);
return function() { clearInterval(interval); };
}, []); // 依存配列が空のため、count が変わっても再実行されない
return <p>Count: {count}</p>;
}
この問題への対処として、主に以下の3種類の解決策がある。
| 解決策 | 説明 |
|---|---|
| 解決策1 : 依存配列に count を含める | useEffect の依存配列に [count] を指定することにより、countが変わるたびにエフェクトが再実行され、最新のcountを参照したクロージャが作成される。 ただし、インターバルが毎回リセットされるという副作用がある。 |
| 解決策2 : 関数型更新を使用する | setCount(prev => prev + 1) のように関数型更新を使用することにより、最新のstateを受け取れる。 stale closureの影響を受けずに状態を更新できる。 |
| 解決策3 : useRef で最新値を保持する | useRef で最新のcountを保持することにより、クロージャが古い値を参照する問題を回避できる。
|
stale closure問題の詳細な解決策、useRef を使用したパターン、useCallback との関係については、Reactのクロージャ問題を扱う専門のページを参照のこと。
関連情報
- JavaScriptの基礎 - 関数宣言と関数式
- 関数宣言と関数式、デフォルト引数、残余引数
- JavaScriptの基礎 - アロー関数
- アロー関数の構文、暗黙のreturn、レキシカルthis
- JavaScriptの基礎 - コールバック関数
- コールバック関数、高階関数、タイマ、イベントリスナー