クロージャはとても便利ですが、使いどころを誤ると見えないところでメモリが残り、アプリが重くなる原因になります。
本記事では、クロージャでメモリリークが起きる仕組みと防ぎ方を、コード例とともにやさしく解説し、日常の開発でのチェック方法まで紹介します。
ポイントは「参照が残るとメモリも残る」ことです。
クロージャとメモリリークの基礎
クロージャとは
かんたんな定義
クロージャとは、関数が作られたときの外側の変数を覚えておき、関数の呼び出し時にも使えるようにした仕組みです。
つまり「関数」と「外側の変数のセット」をまとめて持ち歩けるのがクロージャです。
ここでいう外側の変数はレキシカルスコープ(コード上で見える範囲)の変数のことです。
function counter() {
let n = 0; // 外側の変数
return function () { // 内側の関数がnを覚えている(クロージャ)
n++;
console.log(n);
};
}
const inc = counter();
inc(); // 1
inc(); // 2
直感的なイメージ
クロージャは「メモ帳を持った関数」のようなものです。
関数は自分のメモ帳(外側の変数)を見ながら処理を進めるため、関数が生きている限りメモ帳の中身も残り続けます。
メモリリークとは
定義
メモリリークとは、もう使っていないデータなのにメモリ上に残り続けてしまう状態です。
使わないのに解放されないメモリが積み重なり、アプリが遅くなったりクラッシュの原因になります。
初心者に起きがちな状況
例えば画面を閉じたのに、その画面で使っていたデータやDOM要素への参照がどこかに残っているケースです。
「見えないだけで参照が切れていない」ことが、リークの典型パターンです。
なぜクロージャでメモリが残るのか
クロージャは外側の変数を覚え続けます。
もしその外側の変数が大きな配列やDOM要素だと、クロージャが生きている限りそれらも解放されません。
ここで鍵になるのが参照です。
function makeWorker() {
const big = new Array(1e6).fill("x"); // 大きな配列
return () => big.length; // bigを参照している
}
let worker = makeWorker(); // ここでbigがメモリに確保される
// workerが存在する限り、bigもガベージコレクションされない
worker = null; // 参照を切ると、bigは解放対象になる
クロージャが外側の変数を参照し続ける限り、ガベージコレクタはそのメモリを片付けられません。
参照が残ると、メモリも残ります。
クロージャでメモリリークが起きる仕組み
外側の変数を閉じ込めて覚え続ける
仕組みの要点
クロージャは変数の実体ではなく変数への参照を持ちます。
この参照がどこかで生きていると、その変数が占めるメモリは解放できません。
シンプルな例
function onSomething(bigData) {
window.addEventListener("resize", () => {
// 無名関数がbigDataを参照(クロージャ)
console.log(bigData.length);
});
}
// onSomething(巨大データ)を呼ぶだけで、イベントリスナーが残る限りbigDataも残る
参照が残るとメモリが解放されない
ざっくり理解するGC
ブラウザは「到達可能」なオブジェクトを残して、それ以外を捨てます。
どこからも到達できない(参照されない)状態にするのが解放の条件です。
クロージャは到達経路を作るため、解放のじゃまをしやすいのです。
悪い流れの典型
イベントやタイマーがクロージャを保持 → クロージャが大きなデータを保持 → 画面を離れてもイベントやタイマーが動き続ける → 結果として大きなデータが残り続ける。
長く生きる関数が原因になりやすい
具体的な例
グローバルに保存したコールバック、解除されないイベントリスナー、止め忘れのsetIntervalなどが該当します。
寿命の長い関数ほど、巻き込んだメモリも長く生きます。
JavaScriptで起きやすいポイント
SPAのようにページ遷移がないアプリ、スクロールやリサイズなど永続的なイベント、WebSocketやストリーム購読、繰り返しのタイマーは特に注意が必要です。
「ずっと動いているもの」には必ず解除の出口を用意しましょう。
よくある原因とNG例
イベントリスナーの外し忘れ
悪い例
const bigData = new Array(1e6).fill({}); // 大きなデータ
window.addEventListener("resize", () => {
console.log(bigData.length);
}); // 解除しない
リスナーが生きている限り、bigDataも残ります。
直し方(安全なパターン)
const ac = new AbortController();
function mount() {
const bigData = new Array(1e6).fill({});
const handler = () => console.log(bigData.length);
window.addEventListener("resize", handler, { signal: ac.signal });
}
function unmount() {
ac.abort(); // すべての関連リスナーを解除
}
AbortControllerやonceオプション、removeEventListenerで確実に解除します。
タイマーの止め忘れ
悪い例
const bigCache = new Array(1e6).fill("x");
const id = setInterval(() => {
console.log(bigCache[0]);
}, 1000); // clearIntervalしない
setIntervalが残る限り、bigCacheも残ります。
直し方
const bigCache = new Array(1e6).fill("x");
const id = setInterval(() => {
console.log(bigCache[0]);
// 条件が満たされたら停止
clearInterval(id);
}, 1000);
完了条件や画面破棄時に必ずclearIntervalを呼びます。
DOM参照をクロージャに持ち続ける
悪い例
function attach(node) {
const handler = () => console.log(node.textContent);
node.addEventListener("click", handler);
// nodeをDOMからremoveしても、handlerが参照していると解放されにくい
}
クロージャがnodeを参照すると、DOMが分離(detached)しても残ることがあります。
直し方
function attach(node) {
const ac = new AbortController();
const handler = () => console.log(node.textContent);
node.addEventListener("click", handler, { signal: ac.signal });
return () => {
ac.abort(); // 参照を断つ
// 必要ならnode関連の変数もnull代入で参照を切る
// node = null; // ローカルなら不要
};
}
DOMを離すときはイベントも一緒に解除し、不要な参照を残しません。
大きな配列やオブジェクトを閉じ込める
悪い例
function makeSearch(data) { // dataがとても大きい
return (keyword) => data.filter(d => d.includes(keyword));
}
const search = makeSearch(hugeArray); // hugeArrayがずっと残る
大きなデータをクロージャに閉じ込めると寿命が伸びます。
直し方
// 必要な最小限の情報だけを持つ
function makeSearch(indexOnly) {
return (keyword) => indexOnly[keyword] || [];
}
// または都度引数で渡す
function search(data, keyword) {
return data.filter(d => d.includes(keyword));
}
必要最小限だけを閉じ込めるか、都度引数で渡して寿命を短くします。
無名関数を変数に保持し続ける
悪い例
let saved; // どこからでも参照できる
function setup(data) {
saved = () => console.log(data.length); // dataを閉じ込める
}
savedが存在する限りdataも残ります。
直し方
function runOnce(data) {
const fn = () => console.log(data.length);
fn(); // 使い切る
// ここでスコープを抜ければ解放対象
}
スコープ内で使い切り、グローバルに逃さないようにします。
グローバル変数にクロージャを置く
悪い例
// window.appHandlerのようなグローバルに残す
window.appHandler = (() => {
const big = new Array(1e6).fill(0);
return () => console.log(big[0]);
})();
グローバルに置くと寿命が最長になるため、リークしやすくなります。
直し方
(function main() {
const big = new Array(1e6).fill(0);
function run() { console.log(big[0]); }
run();
// スコープから抜ければ解放対象
})();
グローバルではなくローカルスコープで完結させます。
防ぎ方とチェック方法
使い終わった参照は切る
基本の考え方
「参照をなくす」ことが解放の条件です。
使い終えたら変数を再代入やnull代入で参照を外すと、ガベージコレクタが回収しやすくなります。
let cache = new Array(1e6).fill(0);
// ... 使い終わったら
cache = null; // 参照を切る
イベントとタイマーは必ず解除
実用的なパターン
- onceオプションを使う: 一度だけ動けば自動解除されます。短命にできるときはonceを積極的に使います。
- AbortControllerを使う: 複数のリスナーをまとめて解除できます。
- タイマーのIDを保持し、終了時にclearTimeoutやclearIntervalを必ず呼びます。
// once
element.addEventListener("click", onClick, { once: true });
// AbortController
const ac = new AbortController();
element.addEventListener("mousemove", onMove, { signal: ac.signal });
ac.abort(); // まとめて解除
// タイマー
const id = setInterval(tick, 1000);
clearInterval(id);
スコープを小さく保つ
スコープ設計
クロージャが抱える変数の数と寿命を小さくするほど安全です。
必要な場所でだけ変数を宣言し、ブロックスコープ(let,const)で囲みます。
{
const temp = prepare();
doWork(temp); // ここでしか使わない
} // スコープを抜けて参照が消える
一時的な処理は関数内で完結させる
完結させるコツ
処理を小さな関数に分け、戻り値で必要な情報だけを返します。
内部の一時データは関数終了とともにスコープから外れ、解放対象になります。
function calcSummary(list) {
const buf = list.map(x => x.value);
const sum = buf.reduce((a, b) => a + b, 0);
return { sum };
} // bufはここでスコープ外
ブラウザの開発者ツールでメモリを確認
Chromeの例
- DevToolsを開く(F12) → Memoryタブ。
- Heap snapshotを撮る → 操作する → もう一度撮る。
- Detached HTMLDivElementなどが残っていないか確認します。残っていればDOM参照のリークが疑われます。
- PerformanceタブでMemoryの記録を有効にし、操作ごとにJS heapが右肩上がりにならないかを見ると傾向がわかります。
数字で確認する習慣を持てば、リークに早く気づけます。
コードレビューでクロージャの参照を点検
見るべき観点
- クロージャが大きな配列やオブジェクトを抱えていないか。
- DOM要素を参照していて、イベント解除や破棄処理が用意されているか。
- setIntervalや購読(WebSocketなど)に停止処理があるか。
- 不要にグローバルへ関数やデータを逃していないか。
「どの参照が、どれくらいの間、生きるのか」を会話の中心にすると見落としが減ります。
以下に、よくあるNGと対策をまとめます。
| よくあるNG | 何が残るか | すぐできる対策 |
|---|---|---|
| 解除しないイベント | リスナーと閉じ込めたデータ | once、AbortController、removeEventListener |
| 止めないsetInterval | タイマーと閉じ込めたデータ | clearInterval、条件で停止 |
| DOM参照の持ちっぱなし | Detached DOMと関連データ | 破棄時に解除、参照を切る |
| 大きな配列の閉じ込め | 大容量メモリ | 最小限だけ持つ、引数に渡す |
| グローバルにクロージャ | 全体が長寿命化 | ローカル化、寿命を短くする |
表の対策はどれも「参照の寿命を短くする」ことに直結します。
まとめ
クロージャは強力ですが、外側の変数への参照を長く持つほどメモリが残りやすくなります。
初心者の方は、次の3点を心がけると安心です。
まず、イベントやタイマーは必ず解除して参照を断つこと。
次に、スコープを小さく設計し、大きなデータは閉じ込めないこと。
最後に、開発者ツールでメモリを定期的に確認し、コードレビューで参照の寿命を話題にすることです。
「参照が残るとメモリも残る」という原則を意識すれば、クロージャを安全に活用できます。
