JavaScript メモリリーク調査実践:Chrome DevTools から自動検出までの完全パイプライン
前端工程(更新: 2026年5月14日)
メモリリーク:フロントエンドの見えない殺し屋
メモリリークはすぐにクラッシュしません——慢性的な毒のように、ページが徐々に遅くなり、最終的にホワイトスクリーンになります。SPA アプリケーションでは、ページが更新されないため特に深刻です。
| リークの深刻度 | 症状 | 時間スケール |
|---|---|---|
| 軽度 | 長時間使用後の軽微なラグ | 数時間 |
| 中程度 | ルート切り替え後の顕著な遅延 | 30-60 分 |
| 重度 | 操作後のホワイトスクリーン/クラッシュ | 数分 |
6 つの典型的なリークパターン
1. クリアされていないタイマー
// リーク:コンポーネントがアンマウントされてもタイマーが動作し続ける
function PollingComponent() {
useEffect(() => {
const timer = setInterval(fetchData, 5000);
// cleanup を return し忘れた
}, []);
}
// 修正
function PollingComponent() {
useEffect(() => {
const timer = setInterval(fetchData, 5000);
return () => clearInterval(timer);
}, []);
}
2. 削除されていないイベントリスナー
// リーク:グローバルイベントリスナーがコンポーネント参照を保持
function ScrollComponent() {
useEffect(() => {
window.addEventListener('scroll', handleScroll);
// 削除し忘れた
}, []);
}
// 修正
function ScrollComponent() {
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
}
3. クロージャ内の意図しない参照
// リーク:クロージャが largeData 全体への参照を保持
function processChunk(largeData) {
const result = largeData.items[0].value;
// result のみ使用しても、クロージャは largeData を保持
return function getResult() {
return result;
};
}
// 修正:必要な値のみをキャプチャ
function processChunk(largeData) {
const result = largeData.items[0].value;
const captured = result; // プリミティブ型、参照なし
return function getResult() {
return captured;
};
}
4. 切り離された DOM ノード
// リーク:DOM を削除したが JS がまだ参照している
const element = document.getElementById('card');
element.remove();
// element 変数がまだ参照を保持、DOM ノードが GC されない
// 修正:削除後に参照を null にする
element.remove();
element = null;
5. Map/Set 内のゾンビ参照
// リーク:Map が破棄されたコンポーネントへの参照を保持
const componentCache = new Map();
function register(id, component) {
componentCache.set(id, component);
}
// コンポーネント破棄時に Map から削除されていない
// 修正:破棄時にクリーンアップ
function unregister(id) {
componentCache.delete(id);
}
6. WeakMap の代わりに Map を使用
// キーがオブジェクトで自動回収を期待する場合は WeakMap を使用
const metadata = new WeakMap();
function attachMeta(obj, meta) {
metadata.set(obj, meta); // obj が GC されるとエントリは自動的に消える
}
// Map を使用すると、obj が GC されてもエントリが残る
Chrome DevTools Memory パネル実践
ステップ 1:ベースラインの確立
- DevTools → Memory パネルを開く
- Take heap snapshot をクリック
- 「Baseline」としてラベル付け
ステップ 2:操作と比較
- リークの可能性がある操作を実行(例:ルートを 10 回切り替え)
- Take heap snapshot をクリック
- 「After operation」としてラベル付け
- 「Comparison」ビューを選択し、Baseline と比較
ステップ 3:増加オブジェクトの分析
| 列 | 意味 |
|---|---|
| Added | 新規作成オブジェクト数 |
| Deleted | 削除されたオブジェクト数 |
| Delta | 純増オブジェクト数 |
| Allocated Size | 新規割り当てメモリ |
| Freed Size | 解放されたメモリ |
| Size Delta | 純増メモリ |
注目点:Delta > 0 かつ Size Delta が大きいオブジェクトタイプ。
ステップ 4:Retainers の確認
リークオブジェクトをクリック → Retainers パネルを表示 → 参照チェーンを追跡:
Detached HTMLDivElement
↳ retained by Object (componentCache)
↳ retained by Map @12345
↳ retained by Window (global)
このチェーンが示すのは:DOM から切り離された要素が、componentCache という Map に保持され、その Map がグローバル変数であること。
Allocation Timeline によるリアルタイム追跡
- Allocation instrumentation on timeline を選択
- 記録を開始
- 操作を実行
- 記録を停止
- 青色のバー = 生存中の割り当て、灰色 = 回収済み
一般的なリークシナリオの DevTools 特徴
| リークタイプ | Heap Snapshot の特徴 | キーワード |
|---|---|---|
| イベントリスナー | EventListener 数が継続的に増加 | eventListeners |
| タイマー | Timer オブジェクトが減少しない | timer |
| 切り離し DOM | Detached HTML*Element |
Detached |
| クロージャ参照 | closure が大きなオブジェクトを保持 |
context |
| Map/Set | Map サイズが継続的に増加 | Map / Set |
自動メモリリーク検出
Puppeteer + DevTools Protocol
const puppeteer = require('puppeteer');
async function detectMemoryLeak(url, action, iterations = 10) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url);
const results = [];
for (let i = 0; i < iterations; i++) {
// 操作を実行
await action(page);
// GC を強制
await page.evaluate(() => {
if (window.gc) window.gc();
});
// メモリメトリクスを取得
const metrics = await page.metrics();
results.push({
iteration: i + 1,
jsHeapUsedSize: metrics.JSHeapUsedSize,
});
}
await browser.close();
// トレンド分析:ヒープが継続的に増加する場合、リークの可能性あり
const first = results[0].jsHeapUsedSize;
const last = results[results.length - 1].jsHeapUsedSize;
const growth = (last - first) / first;
return {
leaked: growth > 0.1, // 10% 以上の増加をリークとみなす
growthRate: `${(growth * 100).toFixed(1)}%`,
details: results,
};
}
// 使用例
const result = await detectMemoryLeak(
'http://localhost:3000',
async (page) => {
await page.click('#navigate-btn');
await page.waitForSelector('#content');
await page.click('#back-btn');
},
20
);
console.log(result);
CI 統合
# .github/workflows/memory-check.yml
name: Memory Leak Check
on: [push]
jobs:
memory-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- run: node scripts/memory-leak-test.js
React 特化:useEffect クリーンアップチェックリスト
function DataComponent({ id }) {
useEffect(() => {
const controller = new AbortController();
const timer = setInterval(() => {}, 5000);
const handler = () => {};
window.addEventListener('resize', handler);
const subscription = eventBus.subscribe('event', handler);
return () => {
controller.abort(); // リクエストキャンセル
clearInterval(timer); // タイマークリア
window.removeEventListener('resize', handler); // リスナー削除
subscription.unsubscribe(); // 購読解除
};
}, [id]);
}
React 18 Strict Mode の助け
React 18 Strict Mode では、コンポーネントが二重マウントされた後アンマウントされます。useEffect のクリーンアップが不十分な場合、コンソールがすぐに問題を明らかにします。
ベストプラクティス
- すべての useEffect にクリーンアップ関数があるか確認する
- グローバルイベントリスナーを一元管理し、アンマウント時に一括削除
- クロージャ内で大きなオブジェクトをキャプチャしない、必要な値のみを取得
- WeakMap/WeakSet を Map/Set の代わりに使用してオブジェクトメタデータを保存
- 切り離された DOM ノードの参照を null にする
- CI にメモリリーク検出を統合して回帰を防止
- 定期的に Heap Snapshot を比較、特にルート切り替えシナリオ
- React 18 Strict Mode を使用して開発環境で二重マウント検出
#JavaScript#内存泄漏#Chrome DevTools#性能优化