- JavaScript
JavaScript クロージャとは?使いどころ7例をコード付きで図解
クロージャとは、関数が「自分が定義された場所の変数」を覚えていて、その場所の外で呼ばれても使い続けられる仕組みです。 JavaScript ではすべての関数がこの性質を持っています。特別な構文があるわけではなく、「関数の中で関数を作り、外側の変数を使う」だけでクロージャになります。
この記事では、使いどころを7つ(カウンター、プライベート変数、関数ファクトリ、debounce/throttle、メモ化、once、イベントハンドラ)に分け、コピペできるコードと Node.js 22.22.2 での実行結果を並べました。さらに、for と var で全部同じ値になる現象、React で更新前の state を掴んでしまう stale closure、参照が残り続けるメモリリークを実際に再現し、直し方まで載せています。スコープチェーンの図で「なぜ変数が生き残るのか」も一段深く説明します。
クロージャの使いどころ早見表
| 使いどころ | 覚えておく変数 | 得られること | 最小の形 |
|---|---|---|---|
| 1. カウンター | 現在の回数 | 呼ぶたびに状態が進む。グローバル変数が不要 | let count = 0; return () => ++count; |
| 2. プライベート変数 | 外から触らせたくない値 | 決めた操作だけで値を変えられる | return { add() {...}, get value() {...} } |
| 3. 関数ファクトリ | 設定値(倍率・接頭辞など) | 似た関数を引数1つで量産できる | rate => value => value * rate |
| 4. debounce / throttle | タイマーID・前回の時刻 | 連続する呼び出しを間引ける | let timerId; return (...a) => {...} |
| 5. メモ化 | 計算結果のキャッシュ | 同じ引数の再計算を省ける | const cache = new Map(); return arg => {...} |
| 6. once | 実行済みフラグと結果 | 初期化を1回だけにできる | let called = false; return () => {...} |
| 7. イベントハンドラ | 要素ごとの状態・解除用の関数 | 登録と解除を対で扱える | return () => el.removeEventListener(...) |
つまずきやすい3つの現象と直し方です。
| 現象 | 起きること | 直し方 |
|---|---|---|
for + var の罠 |
全部の関数が同じ値([3, 3, 3])を返す |
let にする |
| stale closure(React) | カウンターが 1 で止まる |
setCount(c => c + 1)、依存配列、useRef |
| メモリリーク | 不要になった大きなデータが解放されない | 必要な値だけ取り出す/登録を解除する |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「JavaScript クロージャとは?使いどころ7例をコード付きで図解」
クロージャとは:最小の例で確かめる
function createCounter() {
let count = 0; // createCounter の中の変数
return function () { // 内側の関数を返す
count += 1; // 外側の変数を使っている
return count;
};
}
const counterA = createCounter();
console.log(counterA(), counterA(), counterA());
console.log(typeof count);
実行結果:
1 2 3
undefined
注目したい点は2つです。
createCounter()の実行はもう終わっているのに、countが消えずに1 → 2 → 3と増えている- 外から
countを見てもundefined。countに触れられるのは、返された関数だけ
この「内側の関数」と「その関数が覚えている外側の変数たち」の組み合わせがクロージャです。
| 用語 | 意味 |
|---|---|
| スコープ | 変数を参照できる範囲。関数や {} ブロックごとに作られる |
| レキシカルスコープ | 変数の参照先が「コードを書いた位置」で決まるというルール |
| スコープチェーン | 内側から外側へ順に変数を探していくつながり |
| クロージャ | 関数と、その関数が定義された場所のスコープの組み合わせ |
なぜ変数が生き残るのか:スコープチェーンの図解
変数は内側から外側へ探される
関数の中で変数名が出てくると、JavaScript はまず自分のスコープ、なければ1つ外側、さらに外側…という順で探します。このつながりがスコープチェーンです。
const globalName = 'G';
function outer() {
const a = 'outer-a';
function middle() {
const b = 'middle-b';
return function inner() {
const c = 'inner-c';
return [c, b, a, globalName].join(' > ');
};
}
return middle();
}
console.log(outer()());
実行結果:
inner-c > middle-b > outer-a > G
flowchart LR
I["inner のスコープ<br>c = 'inner-c'"] -->|なければ外へ| M["middle のスコープ<br>b = 'middle-b'"]
M -->|なければ外へ| O["outer のスコープ<br>a = 'outer-a'"]
O -->|なければ外へ| G["モジュール(グローバル)<br>globalName = 'G'"]
どこを探すかは、関数を「呼んだ場所」ではなく「書いた場所」で決まります。 これがレキシカルスコープです。
関数は「生まれた場所への参照」を持ち歩く
関数が作られるとき、その関数には「自分が作られたスコープへの参照」がこっそり付けられます(仕様上は [[Environment]] という内部スロットです)。関数を変数に入れても、別の関数に渡しても、この参照は付いたままです。
flowchart TD
subgraph call1["createCounter() 1回目の呼び出しで作られたスコープ"]
C1["count = 3"]
end
subgraph call2["createCounter() 2回目の呼び出しで作られたスコープ"]
C2["count = 1"]
end
A["counterA(関数)"] -->|作られた場所への参照| call1
B["counterB(関数)"] -->|作られた場所への参照| call2
通常、関数の実行が終わると、その中の変数は誰からも参照されなくなり、ガベージコレクション(不要になったメモリを自動で回収する仕組み)の対象になります。ところがクロージャの場合、返された関数がスコープを参照し続けているので、回収されません。これが「変数が生き残る」理由です。
逆に言えば、返された関数(上の counterA)をどこからも参照しなくなれば、スコープごと回収されます。後半のメモリリークの節で、実際に数値で確かめます。
呼び出しごとに別のスコープができる
const counterA = createCounter();
const counterB = createCounter();
console.log(counterA(), counterA(), counterA()); // 1 2 3
console.log(counterB()); // 1
createCounter() を呼ぶたびに新しいスコープが作られるので、counterA と counterB は別々の count を持ちます。同じ関数から作っても、状態は混ざりません。
覚えているのは「値のコピー」ではなく「変数そのもの」
function live() {
let v = 1;
const get = () => v;
v = 2; // get を作ったあとで書き換える
return get;
}
console.log(live()()); // 2
get を作った時点の 1 ではなく、2 が返ります。クロージャは変数の値を写し取るのではなく、変数そのものを参照しています。 この性質は、複数の関数で1つの状態を共有できる利点になる一方、後述の「for + var の罠」の原因にもなります。
function pair() {
let n = 0;
return { inc: () => ++n, get: () => n };
}
const p = pair();
p.inc(); p.inc();
console.log(p.get()); // 2(inc と get が同じ n を見ている)
使いどころ1:カウンター(状態を持つ関数)
最初の例がそのまま使いどころの1つ目です。連番の発行に応用した形を載せます。
function createIdGenerator(prefix, start = 1) {
let next = start;
return () => `${prefix}-${String(next++).padStart(4, '0')}`;
}
const nextOrderId = createIdGenerator('ORD');
const nextMemberId = createIdGenerator('MEM', 100);
console.log(nextOrderId(), nextOrderId());
console.log(nextMemberId());
実行結果:
ORD-0001 ORD-0002
MEM-0100
グローバル変数に let orderSeq = 1; と置く方法でも同じことはできますが、どこからでも書き換えられてしまいます。クロージャなら、番号を進められるのは返された関数だけです。
使いどころ2:プライベート変数(カプセル化)
外から直接書き換えさせたくない値を、決めた操作だけで扱えるようにします。
function createStock(initial) {
let quantity = initial; // 外からは見えない
return {
add(n) {
quantity += n;
return quantity;
},
remove(n) {
if (n > quantity) {
throw new RangeError(`在庫が足りません(残り ${quantity})`);
}
quantity -= n;
return quantity;
},
get quantity() {
return quantity;
},
};
}
const stock = createStock(10);
console.log(stock.add(5), stock.remove(3), stock.quantity);
try { stock.quantity = 999; } catch (e) { console.log(`${e.name}: ${e.message}`); }
console.log(stock.quantity, Object.keys(stock));
try { stock.remove(100); } catch (e) { console.log(`${e.name}: ${e.message}`); }
実行結果(ES モジュールとして実行):
15 12 12
TypeError: Cannot set property quantity of #<Object> which has only a getter
12 [ 'add', 'remove', 'quantity' ]
RangeError: 在庫が足りません(残り 12)
stock.quantity = 999 は受け付けられず、値は 12 のままです(strict モードでは TypeError、非 strict モードでは何も起きずに無視されます)。在庫数を変える手段が add と remove だけになるので、「マイナスにならない」というルールを1か所で守れます。
同じ目的は class の # 付きプライベートフィールドでも実現できます。
| 方法 | 書き方 | 向いている場面 |
|---|---|---|
| クロージャ | function createStock() { let quantity; return {...}; } |
小さな部品、関数だけ返したい、this を気にしたくない |
class の #field |
class Stock { #quantity; ... } |
インスタンスが多い、継承や instanceof を使いたい |
使いどころ3:関数ファクトリ(設定を覚えた関数を作る)
引数で受け取った設定を覚えた関数を返す形です。似た関数をいくつも書く代わりに、1つの「関数を作る関数」で済みます。
// 倍率を覚える
const multiplyBy = rate => value => value * rate;
const double = multiplyBy(2);
const triple = multiplyBy(3);
console.log(double(5), triple(5));
// 接頭辞を覚える
const withPrefix = prefix => message => `[${prefix}] ${message}`;
const info = withPrefix('INFO');
const warn = withPrefix('WARN');
console.log(info('起動しました'));
console.log(warn('残り容量が少なくなっています'));
// 並べ替えのキーを覚える
const byKey = key => (a, b) => (a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0);
const items = [{ n: 'b', p: 2 }, { n: 'a', p: 3 }];
console.log(items.toSorted(byKey('n')).map(x => x.n));
実行結果:
10 15
[INFO] 起動しました
[WARN] 残り容量が少なくなっています
[ 'a', 'b' ]
array.sort(byKey('n')) や array.filter(isOver(100)) のように、配列メソッドへ渡す関数を「その場で設定して作る」ときに特に役立ちます。
使いどころ4:debounceとthrottle(呼び出しを間引く)
入力やスクロールのように短時間に何度も起きる処理を間引く関数です。どちらも「タイマーID」や「前回実行した時刻」をクロージャに覚えさせることで成り立っています。
// 最後の呼び出しから wait ミリ秒たったら1回だけ実行
function debounce(fn, wait) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn.apply(this, args), wait);
};
}
// interval ミリ秒に1回だけ実行
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const dlog = [], tlog = [];
const search = debounce(word => dlog.push(word), 100);
const onScroll = throttle(y => tlog.push(y), 100);
for (const w of ['j', 'ja', 'jav', 'java']) { search(w); await sleep(30); }
for (let y = 0; y < 10; y++) { onScroll(y * 10); await sleep(30); }
await sleep(150);
console.log('debounce:', dlog);
console.log('throttle:', tlog.length, '回 /10回中');
実行結果:
debounce: [ 'java' ]
throttle: 3 回 /10回中
30ミリ秒おきに4回呼んだ search は最後の 'java' だけ、10回呼んだ onScroll は3回だけ実行されました。
| 関数 | 覚えている変数 | 動き | 向いている処理 |
|---|---|---|---|
| debounce | timerId |
呼び出しが止まってから1回 | 検索ボックスの入力、ウィンドウのリサイズ後の再計算 |
| throttle | last |
一定間隔ごとに1回 | スクロール位置の記録、連打されるボタン |
timerId が関数の外(グローバル)にあると、複数の debounce 関数が1つのタイマーを取り合ってしまいます。クロージャに入れておくことで、作った関数ごとに独立したタイマーを持てます。 キャンセル機能や最後の1回を必ず実行する版などの発展形は、debounce・throttle を扱う別の記事で詳しく取り上げています。
使いどころ5:メモ化(計算結果を覚えておく)
同じ引数で何度も呼ばれる重い計算を、結果をキャッシュして2回目以降は即座に返すようにします。
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;
};
}
let calls = 0;
const slowSquare = n => { calls += 1; return n * n; };
const fastSquare = memoize(slowSquare);
console.log(fastSquare(12), fastSquare(12), fastSquare(12), 'calls =', calls);
// 再帰と組み合わせる
const plainFib = n => (n < 2 ? n : plainFib(n - 1) + plainFib(n - 2));
const fib = memoize(n => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
let t = performance.now();
const r1 = plainFib(32);
const t1 = performance.now() - t;
t = performance.now();
const r2 = fib(32);
const t2 = performance.now() - t;
console.log(r1, r2, `plain ${t1.toFixed(1)}ms / memo ${t2.toFixed(3)}ms`);
実行結果(時間は環境により変わります):
144 144 144 calls = 1
2178309 2178309 plain 24.8ms / memo 0.100ms
3回呼んでも元の関数が動いたのは1回だけです。フィボナッチ数の計算では、手元の環境で約24.8ミリ秒が約0.1ミリ秒になりました。
メモ化を使うときの注意点は次のとおりです。
| 確認すること | 理由 |
|---|---|
| 同じ引数なら必ず同じ結果になる関数か | 現在時刻や外部の状態に左右される関数は、キャッシュした値とずれる |
| 引数がオブジェクトではないか | Map のキーは参照で比べるので、中身が同じでも別のオブジェクトは別扱いになる |
| キャッシュが増え続けないか | 引数の種類が多いと cache が大きくなる。上限を設けるか、オブジェクトがキーなら WeakMap を使う |
使いどころ6:once(1回だけ実行する)
初期化や設定の読み込みなど、何度呼ばれても最初の1回だけ実行したい処理に使います。
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
const init = once(() => {
console.log('初期化しました');
return { ready: true };
});
console.log(init(), init(), init() === init());
実行結果:
初期化しました
{ ready: true } { ready: true } true
「初期化しました」は1回だけ表示され、2回目以降は最初の結果と同じオブジェクトが返ります。called と result はクロージャの中にあるので、外から「未実行」に戻されることがありません。
なお、イベントリスナーを1回だけ動かしたい場合は、addEventListener('click', handler, { once: true }) というオプションが用意されています。
使いどころ7:イベントハンドラ(要素ごとの状態と解除)
イベントハンドラは、登録してから実際に呼ばれるまで時間が空くので、クロージャが自然に登場します。要素ごとの状態を覚えておき、解除用の関数も返す形にすると扱いやすくなります。
function attachClickCounter(target, label) {
let clicks = 0;
const handler = () => {
clicks += 1;
console.log(`${label}: ${clicks}回目`);
};
target.addEventListener('click', handler);
// 解除用の関数を返す(handler を覚えているクロージャ)
return () => target.removeEventListener('click', handler);
}
// Node.js でも EventTarget で同じ動きを確かめられる
const target = new EventTarget();
const detach = attachClickCounter(target, '保存ボタン');
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('click'));
detach();
target.dispatchEvent(new Event('click'));
console.log('解除後は出力なし');
実行結果:
保存ボタン: 1回目
保存ボタン: 2回目
解除後は出力なし
ブラウザでは target を document.querySelector('#save') などの要素に置き換えるだけです。
removeEventListener には登録したときと同じ関数を渡す必要があります。解除用の関数が handler を覚えていてくれるので、呼び出す側は detach() と書くだけで済みます。React の useEffect が返すクリーンアップ関数も、これと同じ形です。
つまずき1:for+varで全部同じ値になる
ここからは、クロージャでつまずきやすい現象を3つ再現します。1つ目はループです。
const fv = [];
for (var i = 0; i < 3; i++) {
fv.push(() => i);
}
console.log('var:', fv.map(f => f()));
const fl = [];
for (let j = 0; j < 3; j++) {
fl.push(() => j);
}
console.log('let:', fl.map(f => f()));
実行結果:
var: [ 3, 3, 3 ]
let: [ 0, 1, 2 ]
setTimeout と組み合わせても同じ結果になります(var では [ 3, 3, 3 ]、let では [ 0, 1, 2 ])。
原因は「クロージャは値のコピーではなく変数そのものを参照する」という性質です。
flowchart TD
subgraph V["var の場合:変数 i は1つだけ"]
VI["i = 3(ループ終了後の値)"]
F0["関数0"] --> VI
F1["関数1"] --> VI
F2["関数2"] --> VI
end
subgraph L["let の場合:周回ごとに新しい j"]
G0["関数0"] --> J0["j = 0"]
G1["関数1"] --> J1["j = 1"]
G2["関数2"] --> J2["j = 2"]
end
| 宣言 | スコープ | ループでの動き | 結果 |
|---|---|---|---|
var i |
関数(またはファイル)全体で1つ | 3つの関数が同じ i を参照。呼ばれた時点で i は 3 |
[3, 3, 3] |
let j |
ブロック(周回)ごと | 周回のたびに新しい j が作られる |
[0, 1, 2] |
直し方は3通りあります。
// 1. let にする(基本はこれ)
for (let i = 0; i < 3; i++) { fns.push(() => i); }
// 2. 配列メソッドを使う(コールバックの引数は呼び出しごとに別)
const fns2 = [0, 1, 2].map(n => () => n);
// 3. 即時実行関数で周回ごとのスコープを作る(let がなかった頃からある書き方)
for (var k = 0; k < 3; k++) {
(function (n) { fns3.push(() => n); })(k);
}
3つとも [ 0, 1, 2 ] になることを確認しています。今は let(または const と for...of)を使えば済みます。
つまずき2:Reactで更新前のstateを掴むstale closure
stale closure とは、クロージャが覚えている変数が、すでに最新ではなくなっている状態のことです。React の関数コンポーネントでよく出会います。
再現:カウンターが1で止まる
import { useState, useEffect } from 'react';
function StaleCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // この count は最初の描画のときの 0 のまま
}, 50);
return () => clearInterval(id);
}, []); // 依存配列が空 = 最初の1回だけ実行
return <p>count: {count}</p>;
}
React 19.3.0 で、JSX を createElement に置き換えたものを Node.js + jsdom 上で約0.33秒動かした結果です。
StaleCounter : count: 1
50ミリ秒ごとに6回ほど setCount が呼ばれているのに、1 から進みません。
なぜ起きるか
React の関数コンポーネントは、描画のたびに関数全体がもう一度呼ばれます。つまり count は描画ごとに別の変数です。
flowchart LR
subgraph R1["1回目の描画のスコープ"]
C0["count = 0"]
end
subgraph R2["2回目の描画のスコープ"]
C1["count = 1"]
end
T["setInterval のコールバック<br>(1回目の描画で作られた)"] --> C0
T -. 見えていない .-> C1
依存配列が [] なので、useEffect の中身は1回目の描画のときにだけ実行されます。そこで作られたコールバックは 1回目の描画の count(=0)を覚えたままです。何度呼ばれても setCount(0 + 1) を繰り返すので、1 で止まります。
直し方3通り
// 1. 関数型の更新:最新の値を引数で受け取る
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 50);
return () => clearInterval(id);
}, []);
// 2. 依存配列に入れる:count が変わるたびにタイマーを作り直す
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 50);
return () => clearInterval(id);
}, [count]);
// 3. useRef に最新の値を入れておく:読むだけの用途に
const latest = useRef(count);
useEffect(() => { latest.current = count; });
同じ条件で動かした結果です(回数はタイマーの誤差で前後します)。
FixedUpdater : count: 7
FixedDeps : count: 6
FixedRef : stateの変数=0 / ref=5
3つ目は、280ミリ秒後に「最初の描画で掴んだ count」と「latest.current」を読み比べたものです。変数のほうは 0 のまま、ref は 5 と最新に近い値を返しました。
| 直し方 | 向いている場面 | 補足 |
|---|---|---|
setCount(c => c + 1) |
前の値をもとに更新したい | タイマーを作り直さずに済む。まずこれを検討 |
| 依存配列に入れる | 値が変わったら処理をやり直したい | 変わるたびにクリーンアップと再実行が走る |
useRef |
コールバックの中で最新の値を「読む」だけ | ref の変更では再描画されない |
依存配列の書き漏れは、ESLint の react-hooks/exhaustive-deps ルールで検出できます(この記事では未実行。ルール名は一般に知られた仕様として記載しています)。
つまずき3:参照が残り続けるメモリリーク
クロージャが変数を生かし続ける性質は、裏を返すと「クロージャが生きている限り、変数も解放されない」ということです。Node.js 22.22.2 で node --expose-gc leak.mjs として実行し、ヒープ使用量を測りました。
再現:大きな配列を掴んだままになる
// node --expose-gc leak.mjs
const mb = () => (gc(), (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) + ' MB');
console.log('開始時:', mb());
// A: クロージャが大きな配列を参照し続ける
const handlersA = [];
function registerA() {
const rows = new Array(1_000_000).fill('row'); // 約 8 MB
handlersA.push(() => rows.length);
}
for (let i = 0; i < 10; i++) registerA();
console.log('A 登録後:', mb());
handlersA.length = 0; // クロージャへの参照をなくす
console.log('A 解除後:', mb());
実行結果:
開始時: 3.5 MB
A 登録後: 80.0 MB
A 解除後: 3.7 MB
必要なのは件数(rows.length)だけなのに、クロージャが rows 全体を参照しているため、約76 MB が保持されたままになりました。クロージャへの参照をなくすと、3.7 MB に戻ります。
直し方1:必要な値だけ取り出してからクロージャを作る
const handlersB = [];
function registerB() {
const rows = new Array(1_000_000).fill('row');
const count = rows.length; // 必要な値だけ取り出す
handlersB.push(() => count); // rows は参照しない
}
for (let i = 0; i < 10; i++) registerB();
console.log('B 登録後:', mb());
B 登録後: 3.7 MB
クロージャを10個保持したままでも、メモリは増えていません。
知っておくと役立つ:使っていない内側関数が掴んでいる場合
const handlersC = [];
function registerC() {
const rows = new Array(1_000_000).fill('row');
const unused = () => rows.length; // どこにも渡していない
handlersC.push(() => 'done'); // rows を使っていない
}
for (let i = 0; i < 10; i++) registerC();
console.log('C 登録後:', mb());
handlersC.length = 0;
console.log('C 解除後:', mb());
C 登録後: 80.0 MB
C 解除後: 3.7 MB
保持している関数(() => 'done')は rows を使っていないのに、80 MB のままでした。Node.js 22(V8)では、同じスコープで作られた内側の関数たちは、外側の変数を1つの入れ物で共有する動きになっており、どれか1つでも rows を参照していると、ほかの関数が生きている間も rows が残ります。大きなデータを扱う関数の中でクロージャを作るときは、関数を分けるか、使い終わった変数に null を代入しておくと確実です。
直し方2:タイマーとリスナーは解除する
function startPolling() {
const buffer = new Array(1_000_000).fill('log');
return setInterval(() => buffer.length, 1000);
}
const ids = [];
for (let i = 0; i < 10; i++) ids.push(startPolling());
console.log('D 起動中:', mb());
ids.forEach(clearInterval);
console.log('D 停止後:', mb());
D 起動中: 80.0 MB
D 停止後: 3.7 MB
setInterval や addEventListener に渡した関数は、解除するまで実行環境が参照し続けます。画面を切り替えたときや部品を取り除くときに clearInterval・removeEventListener を呼ぶと、クロージャごと解放されます。
メモリが増え続けるときのチェックリスト
| 確認すること | 対処 |
|---|---|
| クロージャが大きな配列・オブジェクト全体を参照していないか | 必要な値だけ変数に取り出してから関数を作る |
setInterval を止めているか |
不要になったら clearInterval |
| イベントリスナーを解除しているか | removeEventListener、または AbortController の signal でまとめて解除 |
| メモ化のキャッシュが増え続けていないか | 上限を設ける、オブジェクトがキーなら WeakMap |
| グローバルな配列や Map に関数を溜めていないか | 使い終わったら取り除く |
クロージャ自体がメモリを多く使うわけではありません。「何を覚えさせているか」と「いつ手放すか」を意識することがポイントです。
クロージャを使うかどうかの判断表
| やりたいこと | 向いている方法 | 理由 |
|---|---|---|
| 関数1つに小さな状態を持たせたい | クロージャ | 最も短く書ける |
| 設定を変えた関数をいくつも作りたい | クロージャ(関数ファクトリ) | 引数で設定を渡すだけ |
| 複数のメソッドと状態をまとめ、インスタンスを多く作る | class(# プライベートフィールド) |
メソッドが prototype で共有される |
| ファイル内だけで使う値を隠したい | ES モジュール(export しない変数) |
モジュールのスコープ自体が外から見えない |
| React で値を描画間で持ち越したい | useState / useRef |
再描画との連携が用意されている |
よくある質問
Q. クロージャとは、ひとことで言うと何ですか?
A. 「関数」と「その関数が定義された場所の変数」の組み合わせです。関数が外へ持ち出されても、定義された場所の変数を使い続けられます。JavaScript の関数はすべてこの性質を持っています。
Q. クロージャはどんなときに使いますか?
A. 関数に状態や設定を覚えさせたいときです。この記事で紹介した、カウンター、プライベート変数、関数ファクトリ、debounce/throttle、メモ化、once、イベントハンドラの7つが代表的な使いどころです。map や setTimeout に渡すコールバックが外側の変数を使っていれば、それもクロージャです。
Q. クロージャとクラスはどう使い分けますか?
A. 関数1つと少しの状態で足りるならクロージャ、複数のメソッドを持つ部品をたくさん作るなら class が向いています。クロージャはインスタンスごとに関数を作るのに対し、class のメソッドは prototype で共有されます。
Q. クロージャを使うとメモリリークしますか?
A. クロージャを使うだけでは起きません。クロージャが大きなデータを参照したまま、タイマーやイベントリスナー、グローバルな配列などに残り続けると、そのデータが解放されなくなります。必要な値だけを取り出す、不要になったら解除する、の2点で防げます。
まとめ
- クロージャは「関数+定義された場所の変数」の組み合わせ。関数が生きている間、その変数も生き残る
- 変数は内側から外側へ探され(スコープチェーン)、探す先は「書いた場所」で決まる
- 使いどころは7つ:カウンター、プライベート変数、関数ファクトリ、debounce/throttle、メモ化、once、イベントハンドラ
- 覚えているのは値のコピーではなく変数そのもの。
for+varで[3, 3, 3]になるのはこのため。letで解決する - React の stale closure は「描画ごとに変数が別」なことが原因。
setCount(c => c + 1)・依存配列・useRefで直せる - メモリは「何を覚えさせるか」と「いつ手放すか」で決まる。必要な値だけ取り出し、タイマーとリスナーは解除する
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「JavaScript クロージャとは?使いどころ7例をコード付きで図解」
株式会社RJCでは、JavaScriptのクロージャやスコープの仕組みを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。