Утечки памяти — как найти и исправить
Утечка памяти в браузере — это объект, который больше не нужен приложению, но не может быть удалён сборщиком мусора, потому что на него сохраняется ссылка. Накапливаясь, утечки приводят к замедлению работы и краху вкладки.
Зачем нужно
Утечки памяти — частая причина деградации SPA: страница работает быстро при первом посещении, но замедляется через 10-30 минут взаимодействия. Garbage Collector не помогает, если на объект удерживается ссылка. Без знания паттернов утечек диагностика занимает часы.
Где используется
- SPA-приложения (React, Vue, Angular) с длительной сессией
- Дашборды с WebSocket и polling — постоянные обновления данных
- Карты, графики (WebGL контекст, Canvas) — нативные ресурсы
- Компоненты с таймерами, subscriptions, ResizeObserver
Основной контент
Основные паттерны утечек
// 1. Забытые event listeners
function leaky() {
const bigData = new Array(10_000).fill('data');
window.addEventListener('resize', () => {
console.log(bigData.length); // bigData не освобождается!
});
}
// Исправление: сохранить ссылку и удалить
function fixed() {
const bigData = new Array(10_000).fill('data');
const handler = () => console.log(bigData.length);
window.addEventListener('resize', handler);
return => window.removeEventListener('resize', handler); // cleanup
}
// 2. Отсоединённые DOM-узлы (Detached DOM)
let detachedDiv;
function createNode() {
detachedDiv = document.createElement('div');
document.body.appendChild(detachedDiv);
}
function removeNode() {
detachedDiv.remove(); // Удалён из DOM, но переменная держит ссылку
// detachedDiv = null; // Нужно обнулить!
}
// 3. Таймеры без очистки
function startPolling() {
const id = setInterval( => fetchData, 5000);
// return => clearInterval(id); // Забыли!
}
// 4. Closure-утечка — замыкание держит весь контекст
function outer() {
const bigArray = new Uint8Array(1_000_000);
return function inner() {
// Достаточно одной ссылки в closure — весь bigArray живёт
return bigArray[0];
};
}
// Исправление: использовать только нужное значение
function outerFixed() {
const bigArray = new Uint8Array(1_000_000);
const firstByte = bigArray[0]; // Копируем нужное
// bigArray теперь может быть собрана GC
return => firstByte;
}
WeakMap и WeakRef — слабые ссылки
// WeakMap: ключ — объект, не мешает GC
const cache = new WeakMap();
function processElement(el) {
if (cache.has(el)) return cache.get(el);
const result = expensiveCompute(el);
cache.set(el, result); // Когда el удалён из DOM — запись уйдёт сама
return result;
}
// WeakRef: ссылка не удерживает объект
class EventBus {
#listeners = new Set();
subscribe(callback) {
const ref = new WeakRef(callback);
this.#listeners.add(ref);
return => this.#listeners.delete(ref);
}
emit(data) {
for (const ref of this.#listeners) {
const fn = ref.deref; // null если GC собрал
if (fn) fn(data);
else this.#listeners.delete(ref);
}
}
}
React: очистка useEffect
// Классические утечки в React
function BadComponent() {
const [data, setData] = useState(null);
useEffect(() => {
// Проблема 1: async без AbortController
fetch('/api/data').then(r => r.json()).then(setData);
// Проблема 2: таймер без clearInterval
const id = setInterval(fetchUpdates, 5000);
// Проблема 3: subscription без unsubscribe
const sub = store.subscribe(setData);
}, );
}
// Исправление
function GoodComponent() {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(() => {}); // AbortError игнорируем
const id = setInterval(fetchUpdates, 5000);
const sub = store.subscribe(setData);
return => {
controller.abort();
clearInterval(id);
sub.unsubscribe;
};
}, );
}
Диагностика в Chrome DevTools
# Шаг 1: Открыть DevTools → Memory tab
# Heap Snapshot — снимок кучи в момент времени
# Шаг 2: Сравнение снимков (выявление утечки)
# 1. Snapshot 1 (baseline)
# 2. Совершить действие N раз (открыть/закрыть компонент)
# 3. Snapshot 2
# Фильтр: Objects allocated between Snapshot 1 and 2
# Ищем: Detached HTMLElement, растущие массивы, closure
# Шаг 3: Allocation Timeline
# Выбрать "Allocation instrumentation on timeline"
# Записать сессию → синие полосы = живые объекты
// performance.memory API (только Chrome)
function checkMemory() {
if (performance.memory) {
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1) + 'MB';
console.log({
used: mb(performance.memory.usedJSHeapSize),
total: mb(performance.memory.totalJSHeapSize),
limit: mb(performance.memory.jsHeapLimitSize),
});
}
}
// Мониторинг роста памяти
let baseline;
function trackMemoryGrowth() {
if (!performance.memory) return;
if (!baseline) {
baseline = performance.memory.usedJSHeapSize;
return;
}
const growth = performance.memory.usedJSHeapSize - baseline;
if (growth > 50 * 1024 * 1024) { // >50MB рост
console.warn('Possible memory leak detected', growth);
}
}
setInterval(trackMemoryGrowth, 30_000);
Частые ошибки
- Удаление DOM-узла без обнуления переменной — Detached DOM продолжает жить
addEventListenerбез парногоremoveEventListenerв компонентах с unmountsetInterval/setTimeoutбезclearInterval/clearTimeoutв cleanup- Глобальные кеши (
Map, массивы) без LRU-ограничения — бесконечный рост - Игнорирование WeakMap/WeakRef там, где ключ — DOM-элемент или объект
Связанные темы
- _MOC Производительность
- Как браузер рендерит страницу
- Виртуализация длинных списков
- React.memo и useMemo
- Synthetic Monitoring
- Утечки памяти и GC в Node
- Garbage Collection в V8
- Profiling Node -- инструменты
🎓 Источники
- Утечки памяти в Node.js и JavaScript, сборка мусора и профилирование · 2018-12-19
- Тезисы: GC не освобождает OS-дескрипторы; 3-snapshot technique; источники утечек — замыкания, кэши, подписки на события
- Цитата: «Garbage Collection, который у нас есть в ноде, объекты операционной системы он не освобождает.»
- Разбираем видео Утечки памяти в SSR (Владимир Захаров) · AsForJS · 2023-09-09
- Измерение производительности кода и оптимизация · 2018-10-24