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:ベースラインの確立

  1. DevTools → Memory パネルを開く
  2. Take heap snapshot をクリック
  3. 「Baseline」としてラベル付け

ステップ 2:操作と比較

  1. リークの可能性がある操作を実行(例:ルートを 10 回切り替え)
  2. Take heap snapshot をクリック
  3. 「After operation」としてラベル付け
  4. 「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 によるリアルタイム追跡

  1. Allocation instrumentation on timeline を選択
  2. 記録を開始
  3. 操作を実行
  4. 記録を停止
  5. 青色のバー = 生存中の割り当て、灰色 = 回収済み

一般的なリークシナリオの 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 のクリーンアップが不十分な場合、コンソールがすぐに問題を明らかにします。


ベストプラクティス

  1. すべての useEffect にクリーンアップ関数があるか確認する
  2. グローバルイベントリスナーを一元管理し、アンマウント時に一括削除
  3. クロージャ内で大きなオブジェクトをキャプチャしない、必要な値のみを取得
  4. WeakMap/WeakSet を Map/Set の代わりに使用してオブジェクトメタデータを保存
  5. 切り離された DOM ノードの参照を null にする
  6. CI にメモリリーク検出を統合して回帰を防止
  7. 定期的に Heap Snapshot を比較、特にルート切り替えシナリオ
  8. React 18 Strict Mode を使用して開発環境で二重マウント検出
#JavaScript#内存泄漏#Chrome DevTools#性能优化