Отладка утечек памяти в 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: создать базовую линию
- Открыть DevTools → панель Memory
- Нажать Take heap snapshot
- Подписать как "Baseline"
Шаг 2: выполнить операции и сравнить
- Выполнить потенциально утечные операции (например, 10 раз сменить маршрут)
- Нажать Take heap snapshot
- Подписать как "After operation"
- Выбрать вид "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, которая является глобальной переменной.
Шкала аллокаций для отслеживания в реальном времени
- Выбрать Allocation instrumentation on timeline
- Начать запись
- Выполнить операции
- Остановить запись
- Синие столбцы = живые аллокации, серые столбцы = уже собранные
Сигнатуры 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 неполная, консоль сразу выявит проблему.
Лучшие практики
- Проверять каждый useEffect на наличие функции очистки
- Централизованно управлять глобальными слушателями событий, удаляя их пакетно при размонтировании
- Избегать захвата больших объектов в замыканиях, брать только нужные значения
- Использовать WeakMap/WeakSet вместо Map/Set для метаданных объектов
- Обнулять ссылки на отсоединённые DOM-узлы
- Интегрировать обнаружение утечек памяти в CI, чтобы предотвращать регрессии
- Регулярно сравнивать Heap Snapshots, особенно при смене маршрутов
- Использовать Strict Mode React 18 для обнаружения двойного монтирования в разработке
#JavaScript#内存泄漏#Chrome DevTools#性能优化