閉じる

クロージャでメモリリークが起きる理由と防ぎ方をやさしく解説

クロージャはとても便利ですが、使いどころを誤ると見えないところでメモリが残り、アプリが重くなる原因になります。

本記事では、クロージャでメモリリークが起きる仕組みと防ぎ方を、コード例とともにやさしく解説し、日常の開発でのチェック方法まで紹介します。

ポイントは「参照が残るとメモリも残る」ことです

クロージャとメモリリークの基礎

クロージャとは

かんたんな定義

クロージャとは、関数が作られたときの外側の変数を覚えておき、関数の呼び出し時にも使えるようにした仕組みです。

つまり「関数」と「外側の変数のセット」をまとめて持ち歩けるのがクロージャです

ここでいう外側の変数はレキシカルスコープ(コード上で見える範囲)の変数のことです。

JavaScript
function counter() {
  let n = 0;                 // 外側の変数
  return function () {       // 内側の関数がnを覚えている(クロージャ)
    n++;
    console.log(n);
  };
}

const inc = counter();
inc(); // 1
inc(); // 2

直感的なイメージ

クロージャは「メモ帳を持った関数」のようなものです。

関数は自分のメモ帳(外側の変数)を見ながら処理を進めるため、関数が生きている限りメモ帳の中身も残り続けます

メモリリークとは

定義

メモリリークとは、もう使っていないデータなのにメモリ上に残り続けてしまう状態です。

使わないのに解放されないメモリが積み重なり、アプリが遅くなったりクラッシュの原因になります

初心者に起きがちな状況

例えば画面を閉じたのに、その画面で使っていたデータやDOM要素への参照がどこかに残っているケースです。

「見えないだけで参照が切れていない」ことが、リークの典型パターンです

なぜクロージャでメモリが残るのか

クロージャは外側の変数を覚え続けます。

もしその外側の変数が大きな配列やDOM要素だと、クロージャが生きている限りそれらも解放されません

ここで鍵になるのが参照です。

JavaScript
function makeWorker() {
  const big = new Array(1e6).fill("x"); // 大きな配列
  return () => big.length;               // bigを参照している
}

let worker = makeWorker();  // ここでbigがメモリに確保される
// workerが存在する限り、bigもガベージコレクションされない
worker = null;              // 参照を切ると、bigは解放対象になる

クロージャが外側の変数を参照し続ける限り、ガベージコレクタはそのメモリを片付けられません

参照が残ると、メモリも残ります

クロージャでメモリリークが起きる仕組み

外側の変数を閉じ込めて覚え続ける

仕組みの要点

クロージャは変数の実体ではなく変数への参照を持ちます。

この参照がどこかで生きていると、その変数が占めるメモリは解放できません

シンプルな例

JavaScript
function onSomething(bigData) {
  window.addEventListener("resize", () => {
    // 無名関数がbigDataを参照(クロージャ)
    console.log(bigData.length);
  });
}
// onSomething(巨大データ)を呼ぶだけで、イベントリスナーが残る限りbigDataも残る

参照が残るとメモリが解放されない

ざっくり理解するGC

ブラウザは「到達可能」なオブジェクトを残して、それ以外を捨てます。

どこからも到達できない(参照されない)状態にするのが解放の条件です

クロージャは到達経路を作るため、解放のじゃまをしやすいのです。

悪い流れの典型

イベントやタイマーがクロージャを保持 → クロージャが大きなデータを保持 → 画面を離れてもイベントやタイマーが動き続ける → 結果として大きなデータが残り続ける

長く生きる関数が原因になりやすい

具体的な例

グローバルに保存したコールバック、解除されないイベントリスナー、止め忘れのsetIntervalなどが該当します。

寿命の長い関数ほど、巻き込んだメモリも長く生きます

JavaScriptで起きやすいポイント

SPAのようにページ遷移がないアプリ、スクロールやリサイズなど永続的なイベント、WebSocketやストリーム購読、繰り返しのタイマーは特に注意が必要です。

「ずっと動いているもの」には必ず解除の出口を用意しましょう

よくある原因とNG例

イベントリスナーの外し忘れ

悪い例

JavaScript
const bigData = new Array(1e6).fill({}); // 大きなデータ
window.addEventListener("resize", () => {
  console.log(bigData.length);
}); // 解除しない

リスナーが生きている限り、bigDataも残ります

直し方(安全なパターン)

JavaScript
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で確実に解除します

タイマーの止め忘れ

悪い例

JavaScript
const bigCache = new Array(1e6).fill("x");
const id = setInterval(() => {
  console.log(bigCache[0]);
}, 1000); // clearIntervalしない

setIntervalが残る限り、bigCacheも残ります

直し方

JavaScript
const bigCache = new Array(1e6).fill("x");
const id = setInterval(() => {
  console.log(bigCache[0]);
  // 条件が満たされたら停止
  clearInterval(id);
}, 1000);

完了条件や画面破棄時に必ずclearIntervalを呼びます

DOM参照をクロージャに持ち続ける

悪い例

JavaScript
function attach(node) {
  const handler = () => console.log(node.textContent);
  node.addEventListener("click", handler);
  // nodeをDOMからremoveしても、handlerが参照していると解放されにくい
}

クロージャがnodeを参照すると、DOMが分離(detached)しても残ることがあります

直し方

JavaScript
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を離すときはイベントも一緒に解除し、不要な参照を残しません

大きな配列やオブジェクトを閉じ込める

悪い例

JavaScript
function makeSearch(data) { // dataがとても大きい
  return (keyword) => data.filter(d => d.includes(keyword));
}
const search = makeSearch(hugeArray); // hugeArrayがずっと残る

大きなデータをクロージャに閉じ込めると寿命が伸びます

直し方

JavaScript
// 必要な最小限の情報だけを持つ
function makeSearch(indexOnly) {
  return (keyword) => indexOnly[keyword] || [];
}
// または都度引数で渡す
function search(data, keyword) {
  return data.filter(d => d.includes(keyword));
}

必要最小限だけを閉じ込めるか、都度引数で渡して寿命を短くします

無名関数を変数に保持し続ける

悪い例

JavaScript
let saved; // どこからでも参照できる
function setup(data) {
  saved = () => console.log(data.length); // dataを閉じ込める
}

savedが存在する限りdataも残ります

直し方

JavaScript
function runOnce(data) {
  const fn = () => console.log(data.length);
  fn(); // 使い切る
  // ここでスコープを抜ければ解放対象
}

スコープ内で使い切り、グローバルに逃さないようにします

グローバル変数にクロージャを置く

悪い例

JavaScript
// window.appHandlerのようなグローバルに残す
window.appHandler = (() => {
  const big = new Array(1e6).fill(0);
  return () => console.log(big[0]);
})();

グローバルに置くと寿命が最長になるため、リークしやすくなります

直し方

JavaScript
(function main() {
  const big = new Array(1e6).fill(0);
  function run() { console.log(big[0]); }
  run();
  // スコープから抜ければ解放対象
})();

グローバルではなくローカルスコープで完結させます

防ぎ方とチェック方法

使い終わった参照は切る

基本の考え方

「参照をなくす」ことが解放の条件です。

使い終えたら変数を再代入やnull代入で参照を外すと、ガベージコレクタが回収しやすくなります。

JavaScript
let cache = new Array(1e6).fill(0);
// ... 使い終わったら
cache = null; // 参照を切る

イベントとタイマーは必ず解除

実用的なパターン

  • onceオプションを使う: 一度だけ動けば自動解除されます。短命にできるときはonceを積極的に使います
  • AbortControllerを使う: 複数のリスナーをまとめて解除できます。
  • タイマーのIDを保持し、終了時にclearTimeoutやclearIntervalを必ず呼びます。
JavaScript
// 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)で囲みます。

JavaScript
{
  const temp = prepare();
  doWork(temp); // ここでしか使わない
} // スコープを抜けて参照が消える

一時的な処理は関数内で完結させる

完結させるコツ

処理を小さな関数に分け、戻り値で必要な情報だけを返します。

内部の一時データは関数終了とともにスコープから外れ、解放対象になります

JavaScript
function calcSummary(list) {
  const buf = list.map(x => x.value);
  const sum = buf.reduce((a, b) => a + b, 0);
  return { sum };
} // bufはここでスコープ外

ブラウザの開発者ツールでメモリを確認

Chromeの例

  1. DevToolsを開く(F12) → Memoryタブ。
  2. Heap snapshotを撮る → 操作する → もう一度撮る。
  3. Detached HTMLDivElementなどが残っていないか確認します。残っていればDOM参照のリークが疑われます。
  4. PerformanceタブでMemoryの記録を有効にし、操作ごとにJS heapが右肩上がりにならないかを見ると傾向がわかります。

数字で確認する習慣を持てば、リークに早く気づけます

コードレビューでクロージャの参照を点検

見るべき観点

  • クロージャが大きな配列やオブジェクトを抱えていないか。
  • DOM要素を参照していて、イベント解除や破棄処理が用意されているか。
  • setIntervalや購読(WebSocketなど)に停止処理があるか。
  • 不要にグローバルへ関数やデータを逃していないか。

「どの参照が、どれくらいの間、生きるのか」を会話の中心にすると見落としが減ります

以下に、よくあるNGと対策をまとめます。

よくあるNG何が残るかすぐできる対策
解除しないイベントリスナーと閉じ込めたデータonce、AbortController、removeEventListener
止めないsetIntervalタイマーと閉じ込めたデータclearInterval、条件で停止
DOM参照の持ちっぱなしDetached DOMと関連データ破棄時に解除、参照を切る
大きな配列の閉じ込め大容量メモリ最小限だけ持つ、引数に渡す
グローバルにクロージャ全体が長寿命化ローカル化、寿命を短くする

表の対策はどれも「参照の寿命を短くする」ことに直結します

まとめ

クロージャは強力ですが、外側の変数への参照を長く持つほどメモリが残りやすくなります

初心者の方は、次の3点を心がけると安心です。

まず、イベントやタイマーは必ず解除して参照を断つこと。

次に、スコープを小さく設計し、大きなデータは閉じ込めないこと。

最後に、開発者ツールでメモリを定期的に確認し、コードレビューで参照の寿命を話題にすることです。

「参照が残るとメモリも残る」という原則を意識すれば、クロージャを安全に活用できます

この記事を書いた人
エーテリア編集部
エーテリア編集部

このサイトでは、プログラミングをこれから学びたい初心者の方に向けて記事を書いています。 基本的な用語や環境構築の手順から、実際に手を動かして学べるサンプルコードまで、わかりやすく整理することを心がけています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!