Таймери та слухачі подій
Таймери і слухачі подій треба очищати тому, що кожен із них це живе посилання на контекст свого колбека, а GC звільняє лише недосяжні об'єкти. setTimeout, setInterval і addEventListener створюють асинхронні задачі, які живуть окремо від основного стека виконання JS, і поки така задача активна, все, до чого звертається її callback, вважається досяжним.
Теорія
TL;DR
- Таймер або слухач тримає замикання, а замикання тримає всі змінні, до яких звертається колбек.
- Поки посилання живе, GC вважає об'єкти досяжними і не звільняє пам'ять.
- Видалення DOM-елемента не знімає слухача: вузол стає detached DOM node і лишається в heap.
- У SPA це накопичується, бо сторінка не перезавантажується до закриття вкладки.
- Крім пам'яті, страждає CPU: сотні активних інтервалів і дублювання спрацювань старих слухачів.
- Лікування просте:
clearInterval/clearTimeout,removeEventListener,unsubscribe,return () => cleanup()вuseEffect.
Швидкий приклад
function startTimer() {
const bigData = new Array(1e6).fill('x');
setInterval(() => console.log(bigData.length), 1000);
}
startTimer();Тут bigData ніколи не звільняється:
- інтервал живе вічно;
- замикання тримає посилання на
bigData; - GC вважає
bigData«живим», і пам'ять росте.
Треба очищати:
const id = setInterval(tick, 1000);
clearInterval(id);Що саме утримує пам'ять
Зі слухачами подій історія та сама:
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.
Рішення:
button.removeEventListener('click', handler);Зверніть увагу: зняти анонімну стрілкову функцію неможливо, бо removeEventListener порівнює посилання. Обробник треба зберігати в змінній.
| Об'єкт | Посилання | Чому не очищається |
|---|---|---|
| Таймер | Колбек (closure) | Тримає посилання на всі змінні замикання |
| Слухач | Callback плюс DOM-елемент | Живуть, поки не викликати removeEventListener |
WebSocket, RxJS, setInterval | Активна підписка | Тримає весь контекст підписки |
React useEffect | Без cleanup | Ефект не скасовується, посилання зберігаються |
GC вважає все це досяжним саме тому, що має активні посилання на колбеки.
Чому це особливо критично в SPA
У SPA сторінка не перезавантажується. Усе, що не очищається, живе, поки користувач не закриє вкладку.
Наприклад:
- ви переходите між сторінками;
- компоненти монтуються і розмонтовуються;
- якщо таймери і слухачі не знімаються, кожен «старий» компонент продовжує існувати в пам'яті, і сміття накопичується.
Через 30 хвилин роботи React-застосунку можна побачити:
- сотні активних інтервалів;
- десятки «зомбі»-слухачів;
- heap memory, що росте і не повертається до базового рівня.
Як правильно очищати
Таймери:
useEffect(() => {
const id = setInterval(() => { /* ... */ }, 1000);
return () => clearInterval(id);
}, []);Слухачі подій:
useEffect(() => {
const handler = () => console.log('scroll');
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);Підписки (RxJS, WebSocket, власні події):
useEffect(() => {
const sub = stream$.subscribe(handleValue);
return () => sub.unsubscribe();
}, []);Для слухачів, які потрібно зняти пачкою, зручний AbortController: усі підписки з одним сигналом знімаються одним викликом abort().
Що станеться, якщо не чистити
| Наслідок | Що відбувається |
|---|---|
| Зростання пам'яті | Замикання колбеків утримують великі об'єкти |
| Лаги UI | Event 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().
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.