disconnectedCallback とメモリリークの防止

DOM から要素を削除しても、その要素に紐づいたイベントリスナーやタイマー、外部リソースへの参照が残っていると、ガベージコレクタはメモリを回収できません。こうしたメモリリークは、SPA のようにページ遷移なしで DOM の追加・削除を繰り返すアプリケーションで深刻な問題になります。Custom Elements の disconnectedCallback は、要素が DOM から切り離されたタイミングで後片付けを行うための仕組みです。

DOM 削除とメモリリークの関係

要素を removeChild や remove で DOM ツリーから取り除いても、JavaScript のどこかからその要素やその内部のオブジェクトへの参照が残っていれば、メモリは解放されません。

const button = document.createElement('button');
document.body.appendChild(button);

// グローバルな配列に参照を保持
const cache = [];
cache.push(button);

// DOM から削除しても cache が参照を持っているため GC されない
button.remove();

これは JavaScript のガベージコレクションが「どこからも参照されていないオブジェクト」を回収する仕組みだからです。DOM ツリーからの除去はあくまで親子関係の切断であり、オブジェクトそのものの破棄ではありません。

要素を DOM から削除する

参照が残っていればメモリは解放されない

参照を明示的に切らなければリークが蓄積する

よくあるリークのパターン

実際のアプリケーションで起こりやすいメモリリークのパターンをいくつか見てみましょう。

イベントリスナーの残留

window や document に登録したイベントリスナーのコールバックがコンポーネント内の変数をクロージャで捕捉している場合、コンポーネントを削除してもクロージャ経由で参照が残る。

タイマーの未クリア

setInterval で定期実行している処理が、要素削除後も動き続ける。コールバックが要素を参照していれば、その要素は GC の対象にならない。

外部ライブラリの購読

WebSocket の onmessage ハンドラや EventSource のリスナーなど、DOM の外側で登録した購読を解除し忘れると、要素が削除されてもコールバックが生き続ける。

MutationObserver / ResizeObserver の未切断

observe で監視を開始した Observer を disconnect せずに放置すると、内部的に要素への参照が保持されたままになる。

これらのパターンに共通するのは、DOM ツリーの外側に存在するリソースが要素への参照を握っているという構造です。DOM の削除だけでは、これらの外部参照は自動的には解消されません。

Custom Elements と disconnectedCallback

Custom Elements のライフサイクルコールバックの一つである disconnectedCallback は、要素が DOM から切り離されたときに自動で呼ばれます。ここにクリーンアップ処理を書くことで、リソースの後片付けを確実に行えます。

class LiveClock extends HTMLElement {
  connectedCallback() {
    // DOM に追加されたときに開始
    this.timerId = setInterval(() => {
      this.textContent = new Date().toLocaleTimeString();
    }, 1000);
  }

  disconnectedCallback() {
    // DOM から削除されたときに後片付け
    clearInterval(this.timerId);
    this.timerId = null;
  }
}

customElements.define('live-clock', LiveClock);

connectedCallback でリソースを確保し、disconnectedCallback で解放するという対称的なパターンが基本形になります。このペアを常にセットで考える習慣をつけると、リークを未然に防げます。

connectedCallback

要素が DOM に挿入されたときに呼ばれる。イベントリスナーの登録、タイマーの開始、Observer の接続など、リソースの確保を行う。

disconnectedCallback

要素が DOM から切り離されたときに呼ばれる。connectedCallback で確保したリソースをすべて解放する。

実践:リソース管理を組み込んだコンポーネント

window の resize イベントを監視しつつ、削除時に適切にクリーンアップするコンポーネントを作ってみましょう。

class ResponsiveBox extends HTMLElement {
  constructor() {
    super();
    // bind しておかないと removeEventListener で同一関数を指定できない
    this.handleResize = this.handleResize.bind(this);
    this.observer = null;
  }

  connectedCallback() {
    this.render();

    // window への登録(要素の外側のリソース)
    window.addEventListener('resize', this.handleResize);

    // ResizeObserver の開始
    this.observer = new ResizeObserver(entries => {
      for (const entry of entries) {
        this.updateSize(entry.contentRect);
      }
    });
    this.observer.observe(this);
  }

  disconnectedCallback() {
    // window から解除
    window.removeEventListener('resize', this.handleResize);

    // Observer を切断
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
  }

  handleResize() {
    this.render();
  }

  updateSize(rect) {
    const info = this.querySelector('.size-info');
    if (info) {
      info.textContent = Math.round(rect.width) + ' x ' + Math.round(rect.height);
    }
  }

  render() {
    this.innerHTML = '<div class="size-info">計測中...</div>';
  }
}

customElements.define('responsive-box', ResponsiveBox);

ポイントは handleResize をコンストラクタで bind していることです。addEventListener と removeEventListener に同じ関数参照を渡さなければ、リスナーの解除に失敗します。アロー関数をその場で書いてしまうと、毎回新しい関数オブジェクトが生成されるため removeEventListener で指定しても一致せず、リスナーが残り続けてしまいます。

// 悪い例:解除できない
connectedCallback() {
  window.addEventListener('resize', () => this.render());
}

disconnectedCallback() {
  // この関数は connectedCallback で渡したものとは別物なので解除されない
  window.removeEventListener('resize', () => this.render());
}

AbortController による一括解除

イベントリスナーが多数ある場合、一つずつ removeEventListener するのは煩雑です。AbortController を使えば、signal を共有するすべてのリスナーを一括で解除できます。

class SearchWidget extends HTMLElement {
  connectedCallback() {
    this.controller = new AbortController();
    const signal = this.controller.signal;

    // signal を渡して登録
    this.querySelector('input').addEventListener('input', e => {
      this.search(e.target.value);
    }, { signal });

    this.querySelector('button').addEventListener('click', () => {
      this.clearResults();
    }, { signal });

    document.addEventListener('keydown', e => {
      if (e.key === 'Escape') this.close();
    }, { signal });
  }

  disconnectedCallback() {
    // abort() で signal を共有する全リスナーが一括解除される
    this.controller.abort();
  }

  // ... 省略
}

AbortController は本来 fetch のキャンセル用に導入された API ですが、addEventListener の第 3 引数に signal を渡す用法が追加され、イベントリスナーの一括管理にも使えるようになりました。

EventTarget.addEventListener の options.signal として指定すると、abort 時にリスナーが自動で除去される。

bind の手間もなくなり、コールバックをアロー関数で書いても問題ありません。リスナーの数が増えるほどこのパターンの恩恵が大きくなります。

Custom Elements を使わない場合の対策

Custom Elements を使わないプロジェクトでも、同じ考え方でリークを防げます。要素の削除前にクリーンアップ関数を呼ぶ規約を設けるか、MutationObserver で要素の削除を検知して自動的に後片付けする方法があります。

function createComponent(container) {
  const controller = new AbortController();
  const signal = controller.signal;

  const timerId = setInterval(() => {
    container.textContent = new Date().toLocaleTimeString();
  }, 1000);

  window.addEventListener('resize', () => {
    container.style.width = window.innerWidth / 2 + 'px';
  }, { signal });

  // クリーンアップ関数を返す
  return function cleanup() {
    clearInterval(timerId);
    controller.abort();
  };
}

// 使用側
const container = document.getElementById('widget');
const cleanup = createComponent(container);

// 削除時に呼ぶ
cleanup();
container.remove();

クリーンアップ関数を返すパターンは React の useEffect のクリーンアップや Vue の onUnmounted と同じ発想です。フレームワークが内部でやっていることを、素の DOM 操作でも意識的に行うことが大切だといえます。

DevTools でリークを検出する

メモリリークが疑われるとき、Chrome DevTools の Memory タブでヒープスナップショットを取得すると原因を特定できます。

要素を追加する前にスナップショットを取得する
要素の追加と削除を何度か繰り返す
再度スナップショットを取得する
Comparison ビューで増加したオブジェクトを確認する

Detached HTMLElement というカテゴリに表示されるオブジェクトは、DOM ツリーからは切り離されているがメモリ上に残っている要素です。ここに大量の要素が見つかれば、どこかで参照が残っている証拠になります。Retainers パネルを展開すると、何がその要素を保持しているかの参照チェーンが表示されるので、リークの原因箇所を追跡できます。

Custom Elements で window に登録したイベントリスナーを確実に解除するために必要なことはどれですか?

  • remove() を呼べばリスナーも自動で解除される
  • connectedCallback 内でアロー関数を使えば自動解除される
  • disconnectedCallback で removeEventListener または AbortController.abort() を呼ぶ
  • ガベージコレクタが自動で解除してくれるので何もしなくてよい
__RESULT__

window に登録したイベントリスナーは要素の削除では自動解除されません。disconnectedCallback で明示的に解除するか、AbortController を使って一括で abort する必要があります。