Отладка утечек памяти в JavaScript на практике: полный конвейер от Chrome DevTools до автоматического обнаружения

前端工程

Утечки памяти: безмолвный убийца фронтенд-приложений

Утечки памяти не вызывают мгновенного падения — они как медленный яд, из-за которого страницы постепенно замедляются, пока в итоге не уходят в белый экран (white-screen). Особенно остро это в SPA, где страницы никогда не перезагружаются.

Тяжесть утечки Симптомы Масштаб времени
Лёгкая Небольшое подлагивание после долгого использования Несколько часов
Средняя Заметное замедление после смены маршрута 30–60 минут
Тяжёлая Белый экран/crash после взаимодействия Несколько минут

Шесть классических паттернов утечек

1. Непогашенные таймеры

// Leak: timer keeps running after component unmounts
function PollingComponent() {
  useEffect(() => {
    const timer = setInterval(fetchData, 5000);
    // Forgot to return cleanup
  }, []);
}

// Fix
function PollingComponent() {
  useEffect(() => {
    const timer = setInterval(fetchData, 5000);
    return () => clearInterval(timer);
  }, []);
}

2. Ненужные слушатели событий

// Leak: global event listener holds component reference
function ScrollComponent() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    // Forgot to remove
  }, []);
}

// Fix
function ScrollComponent() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
}

3. Случайные ссылки в замыканиях

// Leak: closure holds reference to entire largeData
function processChunk(largeData) {
  const result = largeData.items[0].value;
  // Even though only result is used, closure still holds largeData
  return function getResult() {
    return result;
  };
}

// Fix: only capture the needed value
function processChunk(largeData) {
  const result = largeData.items[0].value;
  const captured = result; // primitive type, no reference
  return function getResult() {
    return captured;
  };
}

4. Отсоединённые (Detached) DOM-узлы

// Leak: DOM removed but JS still references it
const element = document.getElementById('card');
element.remove();
// element variable still holds reference, DOM node cannot be garbage collected

// Fix: null out the reference after removal
element.remove();
element = null;

5. Зомби-ссылки в Map/Set

// Leak: Map holds references to destroyed components
const componentCache = new Map();

function register(id, component) {
  componentCache.set(id, component);
}

// Component destroyed but not removed from Map
// Fix: clean up on destroy
function unregister(id) {
  componentCache.delete(id);
}

6. Использование Map вместо WeakMap

// When keys are objects and auto-collection is desired, use WeakMap
const metadata = new WeakMap();

function attachMeta(obj, meta) {
  metadata.set(obj, meta); // when obj is GC'd, entry auto-disappears
}

// Using Map, the entry persists even after obj is GC'd

Панель Memory в Chrome DevTools на практике

Шаг 1: создать базовую линию

  1. Открыть DevTools → панель Memory
  2. Нажать Take heap snapshot
  3. Подписать как "Baseline"

Шаг 2: выполнить операции и сравнить

  1. Выполнить потенциально утечные операции (например, 10 раз сменить маршрут)
  2. Нажать Take heap snapshot
  3. Подписать как "After operation"
  4. Выбрать вид "Comparison" и сравнить с базовой линией

Шаг 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-элемент удерживается Map componentCache, которая является глобальной переменной.

Шкала аллокаций для отслеживания в реальном времени

  1. Выбрать Allocation instrumentation on timeline
  2. Начать запись
  3. Выполнить операции
  4. Остановить запись
  5. Синие столбцы = живые аллокации, серые столбцы = уже собранные

Сигнатуры DevTools для частых сценариев утечек

Тип утечки Сигнатура в Heap Snapshot Ключевые слова
Слушатели событий Число EventListener продолжает расти eventListeners
Таймеры Объекты таймеров не уменьшаются 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++) {
    // Perform operation
    await action(page);

    // Force GC
    await page.evaluate(() => {
      if (window.gc) window.gc();
    });

    // Get memory metrics
    const metrics = await page.metrics();
    results.push({
      iteration: i + 1,
      jsHeapUsedSize: metrics.JSHeapUsedSize,
    });
  }

  await browser.close();

  // Analyze trend: if heap keeps growing, there may be a leak
  const first = results[0].jsHeapUsedSize;
  const last = results[results.length - 1].jsHeapUsedSize;
  const growth = (last - first) / first;

  return {
    leaked: growth > 0.1, // >10% growth considered a leak
    growthRate: `${(growth * 100).toFixed(1)}%`,
    details: results,
  };
}

// Usage
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();        // Cancel request
      clearInterval(timer);      // Clear timer
      window.removeEventListener('resize', handler); // Remove listener
      subscription.unsubscribe(); // Unsubscribe
    };
  }, [id]);
}

Помощь Strict Mode в React 18

В Strict Mode React 18 компоненты монтируются дважды, а затем размонтируются. Если очистка useEffect неполная, консоль сразу выявит проблему.


Лучшие практики

  1. Проверять каждый useEffect на наличие функции очистки
  2. Централизованно управлять глобальными слушателями событий, удаляя их пакетно при размонтировании
  3. Избегать захвата больших объектов в замыканиях, брать только нужные значения
  4. Использовать WeakMap/WeakSet вместо Map/Set для метаданных объектов
  5. Обнулять ссылки на отсоединённые DOM-узлы
  6. Интегрировать обнаружение утечек памяти в CI, чтобы предотвращать регрессии
  7. Регулярно сравнивать Heap Snapshots, особенно при смене маршрутов
  8. Использовать Strict Mode React 18 для обнаружения двойного монтирования в разработке
#JavaScript#内存泄漏#Chrome DevTools#性能优化