ブラウザの自動メモリ管理とデタッチDOMリークの検出
長時間使うと重くなるSPAのメモリリークを自力で特定できるようになる。デタッチDOM・リスナ・タイマーが漏れる成立条件と、ヒープスナップショットでリテイナを追って直す手順を原理から解説します。
- 1.デタッチDOMはJS側の強参照が1本でも残ると、画面から消えてもサブツリーごと到達可能なまま残りリークする。リスナとタイマーはコールバックのクロージャ経由でその参照を生かす典型経路。
- 2.検出はDevToolsのHeap snapshotで3スナップショット法。操作前後で撮り比べ、Detachedで絞り、Retainersでルートまでの保持経路を辿って真犯人を特定する。
- 3.修正は保持経路の切断に集約される。removeEventListener・clearInterval/clearTimeout・ObserverのdisconnectとAbortControllerで購読寿命を要素寿命に揃える。
なぜデタッチDOMリークを学ぶのか
SPAは画面を破棄せず差し替え続けるため、コンポーネントの生成と破棄が膨大に繰り返されます。このとき「破棄したはずのDOMが解放されない」と、操作のたびにメモリが積み上がり、やがてGC頻度の上昇・カクつき・タブのクラッシュに至ります。やっかいなのは、これがGCのバグではなく到達可能性の論理的帰結である点です。GCの基本動作は ガベージコレクションのブラウザ内動作 で扱った通り、回収されるのは「使わないオブジェクト」ではなくどこからも到達できないオブジェクトだけです。本稿はこの一点を起点に、デタッチDOM・イベントリスナ・タイマーが漏れる成立条件を厳密に整理し、ヒープスナップショットで犯人を特定して直すまでの手順を解説します。DOMの構造は DOM を前提とします。
デタッチDOMの成立条件
「デタッチDOM(Detached DOM)」とは、ドキュメントツリーから切り離されている(documentから到達できない)が、JavaScriptヒープからは到達可能なDOMノードを指します。リークが成立する条件は厳密に次の積です。
- その要素(またはその祖先)が
removeChild/remove/innerHTML差し替えなどでツリーから外れている。 - それでもJS側のGCルートから、その要素またはサブツリー内のいずれかのノードへの強参照が1本以上残っている。
DOMノードは親子・兄弟が相互に参照し合うグラフです。サブツリーの末端の子1つにJS参照が残るだけで、その子は親をたどれ、親はさらに上の親と全兄弟をたどれます。結果として、外した要素を頂点とするサブツリー全体が到達可能になり解放されません。「親への参照は消したのに減らない」原因はたいていこれです。
const detachedRoots = [];
function render() {
const panel = document.createElement("div");
panel.innerHTML = "<ul><li>row</li><li>row</li></ul>";
document.body.appendChild(panel);
// li を1つだけ掴んでおく(よくあるDOMキャッシュ)
detachedRoots.push(panel.querySelector("li"));
panel.remove(); // ツリーからは外れる
}
// detachedRoots[i] が生きている限り、li→ul→panel の鎖で panel ごと残る
リスナとタイマーが参照を生かす経路
イベントリスナやタイマーそのものがメモリを食うわけではありません。問題はコールバック関数がクロージャとして要素を捕捉する点にあります。クロージャの捕捉規則は クロージャとスコープチェーンの内部 の通りで、ハンドラ関数が生きている限り、その捕捉対象も到達可能なまま保たれます。保持経路を整理します。
| 経路 | 誰が参照を握るか | 切れる条件 |
|---|---|---|
| addEventListener | EventTargetがリスナ関数を保持。関数が要素を捕捉 | removeEventListener、または要素自体がGC対象になる |
| setInterval / setTimeout | タイマーキューがコールバックを保持し続ける | clearInterval / clearTimeout を呼ぶまで切れない |
| IntersectionObserver等 | Observerが監視対象とコールバックを保持 | unobserve / disconnect |
| グローバルなMap/配列 | ルート直下のコンテナが要素を強参照 | エントリ削除、またはWeakMap化 |
決定的な非対称があります。外した要素に付いたリスナは、その要素が他から到達不能なら要素ごとGCされ、リスナも一緒に消えます。問題になるのは、リスナがwindow・document・長命の親など要素より長生きするターゲットに登録されている場合です。このとき要素は「リスナ関数のクロージャ」という鎖でターゲットからずっと到達可能になります。
function mount(el) {
const onScroll = () => layout(el); // el を捕捉
window.addEventListener("scroll", onScroll); // windowは永続
// el を消しても window→onScroll→el の鎖で残る
// → アンマウント時に removeEventListener(onScroll) が必須
}
setIntervalはさらに直接的です。コールバックはタイマーキューにとどまり続け、clearIntervalを呼ぶまで決して解放されません。コールバックがDOMを触っていれば、そのDOMは無条件に生き続けます。
検出手順:3スナップショット法
リークの観測はDevToolsのMemoryパネルが中心です。性能計測の全体観は Webパフォーマンス も参照しつつ、まず増加傾向を確認し、次に犯人を特定します。
| 段階 | ツール | 見るもの |
|---|---|---|
| 1. 傾向確認 | Performance / Memory のタイムライン | 操作を繰り返してヒープ折れ線が右肩上がりか(山谷を作らないか) |
| 2. 切り分け | Heap snapshot ×3(3-snapshot法) | 同一操作を挟んで撮り、回収されず残り続けるオブジェクト |
| 3. 特定 | snapshotの Retainers / 比較ビュー | ルートまでの保持経路と、増分オブジェクトの生成元 |
3スナップショット法の手順はこうです。(A) 操作前にsnapshotを1枚撮る。(B) 漏れが疑われる操作(パネルの開閉、ページ遷移など)を複数回繰り返す。(C) もう1枚撮る。(D) さらに同じ操作を繰り返し、3枚目を撮る。比較ドロップダウンで2枚目を1枚目基準(Objects allocated between snapshot 1 and 2)に絞ると、操作後も生き残っているオブジェクトだけが見えます。同じ操作で毎回同種オブジェクトが増え続けるなら、それがリークです。
Heap snapshotの取得処理は内部でフルGCを走らせてから撮るため、撮影時点で到達不能なものは原則含まれません。つまりsnapshotに残るオブジェクトは真に到達可能なものだけです。これは利点で、snapshotに居座る=必ず保持経路が存在する、と断言できます。手動で計測したいときはMemoryパネルのゴミ箱(Collect garbage)でGCを促してから操作するとノイズが減ります。
リテイナを辿って真犯人を特定する
増分オブジェクトが分かったら、なぜ生きているかを**保持経路(Retainers)**で確認します。Retainersビューは「このオブジェクトを参照しているのは誰か」を下から上へ、GCルートまで辿るツリーです。これが原因究明の核心です。
デタッチDOMを直接探すなら、snapshotの上部フィルタにDetachedと入力します。Detached <div>のように、ツリーから外れたのにヒープに残るノードが列挙されます。狙ったノードを選び、下段のRetainersを開いて経路を読みます。
Detached HTMLLIElement
└ in __proto__ / closure ... を無視し、最短の強参照鎖を見る
└ retained by closure (onScroll) in EventListener
└ retained by window ← ここがGCルート。windowに登録したリスナが原因
Retainersには複数の親が並ぶことがありますが、回収を妨げているのは強参照だけです。WeakMap経由のエッジはGCに無視されるので、弱参照の意味論(弱参照の意味論 参照)を踏まえ、ルートにつながる強い鎖を探します。鎖の途中にclosure・contextが出たらクロージャ捕捉、Array・Mapが出たらコンテナへの溜め込み、EventListenerが出たら解除漏れ、と原因が読み取れます。
参考に、リークの兆候を測る補助指標としてperformance.memory(Chrome)やmeasureUserAgentSpecificMemory()がありますが、これらは粒度が粗く切り分けには使えません。あくまでsnapshotのRetainersが特定の主役です。
修正パターン:保持経路を切る
原因が「ルートにつながる強参照」である以上、修正はすべてその経路を切ることに帰着します。要素の寿命と、それを参照するもの(リスナ・タイマー・コンテナ)の寿命を一致させるのが原則です。
| 症状 | 保持経路 | 修正 |
|---|---|---|
| 外したDOMが残る | 配列/変数が要素を強参照 | 破棄時に配列から除去・変数をnull化、またはWeakMap/WeakRef |
| windowリスナで要素が残る | window→クロージャ→要素 | removeEventListener、まとめてAbortControllerのsignal |
| タイマーが要素を生かす | タイマーキュー→コールバック→要素 | clearInterval / clearTimeout を破棄時に必ず実行 |
| Observerで残る | Observer→監視対象+コールバック | unobserve または disconnect |
| 要素キーのキャッシュ | Mapが要素を強参照 | WeakMapに置き換える |
実務でとくに効くのがAbortControllerです。複数のリスナを1つのsignalにまとめて登録し、破棄時にabort()を1回呼ぶだけで全リスナが外れます。解除漏れを構造的に防げます。
function mount(el) {
const ac = new AbortController();
const { signal } = ac;
window.addEventListener("scroll", () => layout(el), { signal });
el.addEventListener("click", onClick, { signal });
return () => ac.abort(); // アンマウント時に1回。全リスナが解除される
}
ReactやVueはコンポーネント破棄時に自前で張ったDOM参照を解放しますが、useEffect/onMounted内で手動登録したwindowリスナ・setInterval・外部購読はクリーンアップ関数(return () => .../onUnmounted)で明示解除しない限り漏れます。タイマーやグローバルリスナ、EventEmitter系の購読は、フレームワークの管理外であることを常に意識してください。イベント伝播の仕組みは イベント伝播の内部動作 を参照。
まとめ
デタッチDOMリークは、ツリーから外れた要素(やそのサブツリーの一部)へGCルートから強参照が1本でも残ると成立します。リスナは長命ターゲット(window/document)に登録されたクロージャ経由で、タイマーはclearIntervalまで解放されないコールバック経由で、その参照を生かします。検出は3スナップショット法——操作を挟んでHeap snapshotを撮り比べ、Detachedで絞り、Retainersでルートまでの保持経路を辿って真犯人を特定します。snapshotは撮影前にGCが走るため、残るものは必ず到達可能=保持経路ありと断言できます。修正は経路の切断に集約され、removeEventListener・clearInterval/clearTimeout・Observerのdisconnect、そしてAbortControllerで購読寿命を要素寿命に揃え、溜め込みはWeakMap/WeakRefで弱く持つのが定石です。
Web/フロントエンド Article
ブラウザの自動メモリ管理とデタッチDOMリークの検出を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
メモリ管理
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
検出はDevToolsのHeap snapshotで3スナップショット法。操作前後で撮り比べ、Detachedで絞り、Retainersでルートまでの保持経路を辿って真犯人を特定する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「メモリ管理 / DOM」に近いか確認する。
- 強みである「デタッチDOMはJS側の強参照が1本でも残ると、画面から消えてもサブツリーごと到達可能なまま残りリークする。リスナとタイマーはコールバックのクロージャ経由でその参照を生かす典型経路。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。