Skip to main content

Таймери та слухачі подій

Таймери і слухачі подій треба очищати тому, що кожен із них це живе посилання на контекст свого колбека, а GC звільняє лише недосяжні об'єкти. setTimeout, setInterval і addEventListener створюють асинхронні задачі, які живуть окремо від основного стека виконання JS, і поки така задача активна, все, до чого звертається її callback, вважається досяжним.

Теорія

TL;DR

  • Таймер або слухач тримає замикання, а замикання тримає всі змінні, до яких звертається колбек.
  • Поки посилання живе, GC вважає об'єкти досяжними і не звільняє пам'ять.
  • Видалення DOM-елемента не знімає слухача: вузол стає detached DOM node і лишається в heap.
  • У SPA це накопичується, бо сторінка не перезавантажується до закриття вкладки.
  • Крім пам'яті, страждає CPU: сотні активних інтервалів і дублювання спрацювань старих слухачів.
  • Лікування просте: clearInterval / clearTimeout, removeEventListener, unsubscribe, return () => cleanup() в useEffect.

Швидкий приклад

javascript
function startTimer() { const bigData = new Array(1e6).fill('x'); setInterval(() => console.log(bigData.length), 1000); } startTimer();

Тут bigData ніколи не звільняється:

  • інтервал живе вічно;
  • замикання тримає посилання на bigData;
  • GC вважає bigData «живим», і пам'ять росте.

Треба очищати:

javascript
const id = setInterval(tick, 1000); clearInterval(id);

Що саме утримує пам'ять

Зі слухачами подій історія та сама:

javascript
const button = document.getElementById('btn'); button.addEventListener('click', () => console.log('clicked')); button.remove(); // the element is gone, the listener is not

Тепер елемент button видалено з DOM, але callback досі зберігається в пам'яті, бо на нього лишилося посилання з системи подій. GC не може прибрати вузол, бо на нього «дивиться» зареєстрований обробник. Саме такі вузли видно в heap snapshot як Detached DOM nodes.

Рішення:

javascript
button.removeEventListener('click', handler);

Зверніть увагу: зняти анонімну стрілкову функцію неможливо, бо removeEventListener порівнює посилання. Обробник треба зберігати в змінній.

Об'єктПосиланняЧому не очищається
ТаймерКолбек (closure)Тримає посилання на всі змінні замикання
СлухачCallback плюс DOM-елементЖивуть, поки не викликати removeEventListener
WebSocket, RxJS, setIntervalАктивна підпискаТримає весь контекст підписки
React useEffectБез cleanupЕфект не скасовується, посилання зберігаються

GC вважає все це досяжним саме тому, що має активні посилання на колбеки.

Чому це особливо критично в SPA

У SPA сторінка не перезавантажується. Усе, що не очищається, живе, поки користувач не закриє вкладку.

Наприклад:

  • ви переходите між сторінками;
  • компоненти монтуються і розмонтовуються;
  • якщо таймери і слухачі не знімаються, кожен «старий» компонент продовжує існувати в пам'яті, і сміття накопичується.

Через 30 хвилин роботи React-застосунку можна побачити:

  • сотні активних інтервалів;
  • десятки «зомбі»-слухачів;
  • heap memory, що росте і не повертається до базового рівня.

Як правильно очищати

Таймери:

javascript
useEffect(() => { const id = setInterval(() => { /* ... */ }, 1000); return () => clearInterval(id); }, []);

Слухачі подій:

javascript
useEffect(() => { const handler = () => console.log('scroll'); window.addEventListener('scroll', handler); return () => window.removeEventListener('scroll', handler); }, []);

Підписки (RxJS, WebSocket, власні події):

javascript
useEffect(() => { const sub = stream$.subscribe(handleValue); return () => sub.unsubscribe(); }, []);

Для слухачів, які потрібно зняти пачкою, зручний AbortController: усі підписки з одним сигналом знімаються одним викликом abort().

Що станеться, якщо не чистити

НаслідокЩо відбувається
Зростання пам'ятіЗамикання колбеків утримують великі об'єкти
Лаги UIEvent loop забитий активними колбеками
Дубльовані викликиСтарі слухачі спрацьовують разом із новими
Зростання CPUЗабагато активних інтервалів працюють паралельно
«Out of memory»Браузер або вкладка падає
«Зомбі-компоненти»Старі React-компоненти живуть далі і виконують ефекти

Інструменти, якими це видно:

  • Chrome DevTools, Memory, Heap snapshot: шукаємо Detached DOM nodes і об'єкти Timeout, EventListener.
  • Performance, Record: дивимось на Long tasks і графік пам'яті.
  • React DevTools Profiler: перевіряємо, що компоненти справді розмонтовуються.
  • Sentry Performance: зростання heap, витоки, деградація FPS на проді.

Підсумок:

Що треба пам'ятатиЧому
setTimeout / setInterval треба очищатиІнакше callback лишається живим
addEventListener треба зніматиІнакше DOM-вузол і функція не звільняються
У React завжди return () => cleanup() в useEffectЗапобігає «зомбі»-ефектам
У SPA немає «перезапуску» пам'ятіУсе копиться, поки не очистиш вручну
Витоки пам'яті це лаги плюс падінняGC не може прибрати «живі» посилання

Таймер або слухач це як відкрита вкладка в пам'яті. Якщо не закрити, таких вкладок стають сотні, і браузер «помирає».

Типові помилки

  • Підписуватися анонімною функцією. addEventListener('click', () => ...) неможливо зняти: removeEventListener порівнює посилання на функцію, тому обробник треба тримати у змінній.
  • Вважати, що element.remove() знімає слухачів. Не знімає; вузол лишається в пам'яті як detached DOM node, поки живе обробник.
  • Не зберігати id таймера. setInterval(fn, 1000) без збереженого id уже не зупинити з коду.
  • Знімати слухача з іншого елемента або з іншим типом події. removeEventListener спрацює лише при повному збігу цілі, типу події і опцій (наприклад capture).
  • Забувати cleanup у useEffect з порожнім масивом залежностей. Такий ефект виглядає як «одноразовий», але виконується на кожному монтуванні компонента.
  • Ігнорувати підписки, які не схожі на слухачів. IntersectionObserver, ResizeObserver, MutationObserver і WebSocket теж треба зупиняти явно: disconnect(), close(), unsubscribe().

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.