JavaScript を学んでいると、どこかで「クロージャ(closure)」という言葉に出会います。名前だけ見ると難しそうですが、正体は「関数が、自分の生まれた場所にあった変数を覚えている」という仕組みです。この記事では、クロージャとは何か、なぜそうなるのか、最小のコード例から実用的な使い方までを段階的に解説します。さらに、初心者が必ず一度はつまずく「ループとクロージャ」の罠と、メモリ面の注意点まで噛み砕いて説明します。コーディング初心者から中級者の方を対象にしています。
目次
クロージャとは何か
クロージャとは、関数と、その関数が定義されたときの外側の変数(スコープ)の組み合わせのことです。JavaScript では、関数の内側から外側の変数を参照できます。そして、その内側の関数を外に持ち出しても、参照していた外側の変数は生き続けます。この「持ち出された関数が、外側の変数を覚えている状態」がクロージャです。
まずは一番小さな例を見てみましょう。outer 関数の中で変数 message を定義し、それを使う inner 関数を返しています。
function outer() {
const message = 'こんにちは';
// 外側の変数 message を参照する関数を返す
function inner() {
console.log(message);
}
return inner;
}
const greet = outer(); // outer の実行はここで終わる
greet(); // => こんにちは
注目したいのは、outer() の実行はすでに終わっているのに、greet() を呼ぶと message の値が表示される点です。普通に考えると、関数の実行が終わればその中のローカル変数は消えそうなものです。しかし inner が message を参照しているため、message は破棄されずに残り続けます。この inner と message のセットがクロージャです。
なぜ覚えていられるのか(レキシカルスコープ)
クロージャを理解するには、その土台にあるスコープとレキシカルスコープという考え方を軽く押さえておく必要があります。
スコープとは「変数が参照できる範囲」のことです。JavaScript では、関数の内側から外側の変数は見えますが、外側から内側の変数は見えません。そして JavaScript のスコープはレキシカル(静的)スコープです。これは、ある関数がどの変数を参照できるかが、その関数が書かれた場所(コードの位置)によって決まるという意味です。実行時にどこから呼ばれたかではなく、定義されたときの位置で参照先が決まります。
先ほどの例では、inner は outer の内側に書かれているので、outer のローカル変数 message を参照できます。この参照関係はコードを書いた時点で決まり、inner を別の場所に持ち出しても変わりません。だからこそ、関数を返したあとでも外側の変数を覚えていられるのです。
カウンターを作ってみる
クロージャの威力がはっきり分かるのが、状態を持つ関数を作る場面です。呼び出すたびに数が増えるカウンターを作ってみましょう。
function createCounter() {
let count = 0; // この変数がクロージャに閉じ込められる
return function () {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // => 1
console.log(counter()); // => 2
console.log(counter()); // => 3
createCounter を呼ぶと、count という変数を内部に抱えた関数が返ってきます。返された関数は count を参照し続けるので、呼び出すたびに前回の値を覚えていて、1ずつ増えていきます。count は createCounter の外からは直接触れず、返された関数を通してのみ変化させられる点もポイントです。
大切なのは、呼び出しごとに新しいクロージャが作られることです。次のように2つのカウンターを作ると、それぞれが独立した count を持ちます。
const counterA = createCounter();
const counterB = createCounter();
console.log(counterA()); // => 1
console.log(counterA()); // => 2
console.log(counterB()); // => 1 (B は A とは別の count を持つ)
counterA と counterB は別々に作られたため、内部の count も別物です。互いに影響し合いません。
プライベートな変数を作る
カウンターの例で見たように、クロージャを使うと「外から直接いじれない変数」を作れます。これを利用すると、オブジェクト指向でいうプライベート変数のようなものを表現できます。値を直接書き換えられないようにし、決められた操作(メソッド)を通してのみ変更させる、という使い方です。
次は、連番を発行する採番処理の例です。内部の current は外から触れず、next と reset を通してのみ操作できます。
function createIdGenerator(prefix) {
let current = 0; // 外部から直接アクセスできないプライベート変数
return {
next() {
current += 1;
return prefix + '-' + current;
},
reset() {
current = 0;
},
};
}
const orderId = createIdGenerator('ORDER');
console.log(orderId.next()); // => ORDER-1
console.log(orderId.next()); // => ORDER-2
orderId.reset();
console.log(orderId.next()); // => ORDER-1
// current には外から直接アクセスできない
console.log(orderId.current); // => undefined
返されたオブジェクトの next と reset は、どちらも同じ current を共有するクロージャです。一方で orderId.current としてもアクセスできず、undefined になります。current はクロージャの中に閉じ込められており、用意したメソッド以外からは変更できません。こうして「状態を隠して、安全な操作だけを公開する」という設計ができます。これは古くからモジュールパターンと呼ばれてきた書き方の基礎でもあります。
ループの中でクロージャがうまく動かないとき
クロージャでもっとも有名なつまずきが、ループの中で関数を作るケースです。次のコードは、0秒後・1秒後…と数字を順番に表示したい、という意図で書いたものです。しかし期待どおりには動きません。
// 期待: 0, 1, 2 と表示したい
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000 * i);
}
// 実際の出力:
// 3
// 3
// 3
なぜ全部同じ値になるのか
原因は var のスコープにあります。var で宣言した i は関数スコープで、ループ全体で1つだけ共有されます。setTimeout に渡した関数はその唯一の i を参照するクロージャですが、実際に実行されるのはループが終わったあとです。そのときには i はすでに 3 まで増え切っているため、3つの関数がそろって 3 を表示してしまいます。各関数は「ループ中のそのときの値」をコピーしているのではなく、「同じ変数 i」を参照している、というのがポイントです。
letで宣言して解決する
もっとも簡単な解決策は、var を let に変えることです。let はブロックスコープなので、ループの繰り返しごとに新しい i が作られます。それぞれの関数は、その回の i を参照するクロージャになるため、意図どおりに表示されます。
// var を let に変えるだけ
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000 * i);
}
// 実際の出力:
// 0
// 1
// 2
即時関数で値を閉じ込める
どうしても var を使う必要がある環境では、即時実行関数(IIFE)でその回の値を引数として渡し、新しいスコープに閉じ込める方法があります。引数 j はループの回ごとに別の変数になるので、各クロージャが正しい値を覚えます。
for (var i = 0; i < 3; i++) {
// i の値を引数 j として即時関数に渡し、その回ごとのスコープを作る
(function (j) {
setTimeout(function () {
console.log(j);
}, 1000 * j);
})(i);
}
// 実際の出力:
// 0
// 1
// 2
現在のコードでは let を使うのが一番素直で読みやすい解決策です。即時関数による方法は、古いコードを読むときや var しか使えない場面のための知識として押さえておくとよいでしょう。
クロージャとメモリの注意点
クロージャは外側の変数を生かし続ける仕組みなので、便利な反面、不要になっても変数がメモリに残り続けることがあります。クロージャを参照する関数が生きている限り、その関数が抱える変数は解放されません。普段はガベージコレクションが自動で片付けてくれますが、いくつか気をつけたい場面があります。
使い終わった参照は手放す
イベントリスナーやタイマーにクロージャを登録したまま放置すると、その関数とそれが抱える変数がいつまでも残ります。要素を削除したりページの状態が変わったりして不要になったら、removeEventListener や clearInterval で登録を解除し、もう使わない変数には null を入れるなどして参照を手放しておくと、メモリが解放されやすくなります。
大きなデータを必要以上に抱え込まない
クロージャは、外側スコープにある変数を必要な分だけ生かし続けます。長く生き残る関数のすぐ外側に巨大な配列やオブジェクトを置いていると、それらも一緒に解放されずに残ることがあります。本当に必要な値だけをクロージャに渡す、あるいは関数のスコープを小さく保つことで、こうした無駄な保持を避けられます。とはいえ、通常のカウンターやちょっとした状態管理であれば、メモリを過度に心配する必要はありません。仕組みを理解したうえで、長生きする関数を扱うときだけ意識すれば十分です。
まとめ
クロージャは、関数とその関数が定義されたときの外側の変数を結びつけた仕組みです。JavaScript はレキシカルスコープなので、関数は「書かれた場所」の変数を覚えており、外に持ち出してもその参照は失われません。これを使うと、状態を保持するカウンターや、外から触れないプライベート変数を作れます。一方で、var を使ったループでは全関数が同じ変数を共有してしまう罠があり、let か即時関数で解決します。また、長生きする関数では不要な参照を手放してメモリの保持を避けましょう。最初は不思議に感じる概念ですが、「関数が外側の変数を覚えている」という一点を押さえれば、多くの場面ですっきり理解できるはずです。