Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як налагоджувати застосунок та знаходити витоки пам'яті». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Витік пам'яті (memory leak)** - це коли виділена пам'ять не звільняється, бо щось тримає на неї посилання і garbage collector не може її зібрати. ```js // Витік: очищення немає, interval тримає замикання вічно useEffect(() => { const id = setInterval(fetchData, 1000); }, []); // Правильно: повернути функцію очищення useEffect(() => { const id = setInterval(fetchData, 1000); return () => clearInterval(id); }, []); ``` **Головне:** GC звільняє тільки недосяжні об'єкти. Таймери, слухачі подій і замикання тримають об'єкти досяжними довше ніж потрібно. Для пошуку витоків: два heap snapshots у Chrome DevTools Memory tab до і після дій користувача, потім порівняння.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Витік пам'яті (memory leak)** - це коли виділена пам'ять не звільняється, бо код тримає на неї посилання і garbage collector не може до неї дістатися. ## Теорія ### TL;DR - Пам'ять - як столик у ресторані: зарезервував, поїв, офіціант прибрав. Витік - це брудний посуд, що накопичується, бо ніхто не прибирає, поки зала не заповниться. - JS звільняє пам'ять автоматично через garbage collection. Витоки виникають, коли замикання, таймери або слухачі подій тримають об'єкти досяжними після того, як вони вже не потрібні. - Якщо пам'ять стабільно зростає під час навігації (видно в Task Manager або Performance Monitor), спочатку профілюй, потім роби висновки. - Витоки в браузері: Chrome DevTools Memory tab. У Node.js: `--heap-prof` або `clinic.js`. У React: React Profiler разом з heap snapshots. ### Швидкий приклад ```tsx // ПОГАНО: interval продовжує працювати після демонтажу компонента function LeakyTimer() { useEffect(() => { const id = setInterval(() => console.log('tick'), 1000); // Немає return = немає очищення = interval живе вічно // Замикання тримає scope компонента, heap росте }, []); return <div>Timer</div>; } // ДОБРЕ: повертаємо функцію очищення function CleanTimer() { useEffect(() => { const id = setInterval(() => console.log('tick'), 1000); return () => clearInterval(id); // тепер GC може зібрати }, []); return <div>Timer</div>; } ``` Після демонтажу `LeakyTimer` interval продовжує спрацьовувати. Замикання тримає scope компонента, тому V8 вважає ту пам'ять досяжною і не чіпає її. Виправлення - один рядок. ### Чому GC тебе не врятує V8 використовує mark-and-sweep: починає від коренів (глобальні об'єкти, стек-фрейми, активні хендли), помічає все досяжне, а решту видаляє. Витоки виникають, коли код випадково створює кореневі посилання, що живуть довше ніж треба. `setInterval` кладе свій callback у чергу таймерів. Ця черга є коренем. Якщо callback закриває 10 МБ масиву, той масив досяжний стільки, скільки живе interval. GC його не чіпає. Стекові змінні різняться: вони зникають щойно функція завершується. Але замикання, слухачі подій і глобальний стан сидять на heap і живуть доти, доки щось явно не розриває посилання. ### Коли який інструмент - Пам'ять зростає під час навігації в браузері → Chrome DevTools Memory tab, heap snapshots до і після дій користувача - Node.js сервер повільно вичерпує пам'ять → `node --heap-prof server.js`, потім відкрий результат у DevTools - React компонент спричиняє повторне зростання пам'яті → React Profiler разом з DevTools Memory tab - Продакшн Node з OOM → модуль `heapdump`, тригер через `SIGUSR2` - Ловити витоки в CI → Puppeteer з `page.tracing` і порівнянням heap між запусками ### Порівняння інструментів | Інструмент | Де допомагає | Як використати | Обмеження | |---|---|---|---| | Chrome DevTools Memory | Браузер, React | Snapshot, дії, другий snapshot, diff у Summary view | Для Node потрібен `--inspect` | | `node --heap-prof` | Node.js сервер | `node --heap-prof server.js`, відкрий `.heapprofile` у DevTools | Сповільнює процес | | React Profiler | Повторні рендери | Запис mount/unmount, flamegraph для утриманих вузлів | Не бачить таймери і не-React код | | `clinic.js` / `heapdump` | Продакшн Node | `clinic heapusage -- node server.js` | Потрібна установка | | Performance Monitor | Швидка перевірка | DevTools → Performance Monitor → JS Heap Size | Тільки підтверджує факт витоку, без деталей | ### Як V8 обробляє це всередині V8 запускає два типи GC. Scavenge (minor GC) швидко очищає молоде покоління, збираючи короткоживучі об'єкти. Mark-Compact (major GC) обробляє старе покоління, але запускається тільки під тиском пам'яті. Об'єкти, що пережили Scavenge, переходять до старого покоління. Витоки живуть у старому поколінні. Callback таймера, що закриває великий об'єкт, тримає той об'єкт там. Кожного разу коли Mark-Compact запускається, він знаходить об'єкт досяжним і пропускає його. Після тисяч запитів або навігаційних подій ці об'єкти накопичуються до OOM або перезавантаження. Відокремлені DOM вузли - поширений браузерний варіант. Ти видаляєш елемент з DOM, але JS змінна або слухач події ще тримає посилання. Елемент відокремлений від сторінки, але живий на heap. Chrome DevTools показує такі об'єкти в Summary view як "Detached HTMLDivElement". ### Типові помилки **Забув очистити setInterval у useEffect:** ```tsx // Неправильно: interval живе до перезавантаження сторінки useEffect(() => { const id = setInterval(fetchData, 5000); }, []); // Правильно: очищення при демонтажі useEffect(() => { const id = setInterval(fetchData, 5000); return () => clearInterval(id); }, []); ``` **fetch без AbortController:** ```tsx // Неправильно: запит у польоті тримає замикання зі setData useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData); }, []); // Правильно: AbortController скасовує запит при демонтажі useEffect(() => { const controller = new AbortController(); fetch('/api/data', { signal: controller.signal }) .then(r => r.json()) .then(setData) .catch(() => {}); // AbortError очікуваний, ігноруємо return () => controller.abort(); }, []); ``` **Map замість WeakMap для кешу:** ```js // Неправильно: Map тримає сильне посилання, ключі ніколи не збираються GC const cache = new Map(); cache.set(userObject, computedData); // Правильно: WeakMap відпускає ключ, коли інших посилань немає const cache = new WeakMap(); cache.set(userObject, computedData); ``` **addEventListener без removeEventListener:** ```tsx // Неправильно: слухач залишається назавжди useEffect(() => { window.addEventListener('resize', handleResize); }, []); // Правильно useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); ``` **Express обробник, який тримає великі дані на кожен запит:** ```js // ПОГАНО: interval закриває 10 МБ буфер, витік на кожен запит app.get('/data', async (req, res) => { const largeData = await fetchBigPayload(req.query.id); const watcher = setInterval(() => { console.log('Розмір:', largeData.length); // тримає largeData }, 5000); res.json({ ok: true }); // interval не очищено, largeData ніколи не звільниться }); ``` Такий патерн я бачив на Node сервері з 500 одночасних користувачів: кожен запит додавав ~10 МБ до heap і пам'ять назад не поверталася. **Ігноруєш detached DOM вузли у heap snapshot:** Коли в snapshot бачиш "Detached HTMLDivElement", це DOM вузол, видалений зі сторінки, але ще живий у JS. Перевір слухачі подій, які не були видалені, та React `ref` об'єкти, що вказують на старі вузли. ### Де зустрічається в продакшні - React компоненти з WebSocket (чати, live дашборди) → закривати socket у return useEffect - Next.js SSR сторінки з підписками → cleanup у `getServerSideProps` - Puppeteer сервіси скріншотів → регулярний `page.close()` для відокремлених сторінок - Redux stores → не зберігати DOM вузли або великі ArrayBuffer у стані - Node/Express API → factory-функції на кожен запит замість синглтонів на рівні модуля ### Питання на співбесіді **Q:** Розкажи, як ти будеш дебажити React застосунок, що сповільнюється через 10 хвилин навігації. **A:** Спочатку Task Manager або Performance Monitor - підтвердити, що пам'ять справді зростає. Потім DevTools Memory tab, перший heap snapshot, кілька хвилин навігації, другий snapshot, diff у Summary view. Шукаю зростаючі замикання або detached DOM вузли. Далі Performance tab із записом сесії для виявлення interval-колбеків у flame chart. **Q:** Яка різниця між heap snapshot і allocation timeline? **A:** Snapshot - це фото всіх об'єктів у heap у конкретний момент. Timeline записує кожну алокацію впродовж часу, що допомагає ловити переривчасті витоки, які не видно при порівнянні двох snapshot. **Q:** Як дебажити витік пам'яті в Node.js без перезапуску сервера? **A:** Запустити Node з `--inspect` і підключитися через Chrome DevTools. Або встановити `heapdump`, слухати `SIGUSR2` і викликати `heapdump.writeSnapshot()` за сигналом. Потім аналізувати `.heapsnapshot` файл у DevTools. **Q:** Чому WeakMap допомагає запобігти витокам? **A:** У звичайному `Map` ключ - сильне посилання. Якщо кешуєш дані за об'єктом користувача, той об'єкт живе доти, доки живе Map. У `WeakMap` ключ тримається слабо: щойно інший код перестає посилатися на об'єкт, GC збирає його і запис у WeakMap зникає автоматично. **Q:** React StrictMode монтує компоненти двічі. Як це допомагає знайти витоки? **A:** StrictMode робить mount, cleanup і знову mount. Якщо `useEffect` створює таймер або слухач, але cleanup їх не видаляє, після другого mount їх буде два. Heap snapshot після другого mount покаже подвоєні алокації. **Q:** Чому витоки в старому поколінні V8 небезпечніші? **A:** Молоде покоління очищає Scavenge, який запускається часто і швидко. Об'єкти, що пережили Scavenge, переходять до старого покоління, де їх збирає тільки повний Mark-Compact GC. Об'єкти-витоки залишаються там назавжди, поступово заповнюючи heap до OOM. ## Приклади ### Базовий: витік setInterval у React компоненті Найпоширеніший варіант: polling interval, що живе після демонтажу компонента. ```tsx function PriceTracker({ symbol }: { symbol: string }) { const [price, setPrice] = useState<number>(0); useEffect(() => { // Оновлюємо ціну кожні 3 секунди const id = setInterval(async () => { const res = await fetch(`/api/price?symbol=${symbol}`); const data = await res.json(); setPrice(data.price); // попередження після демонтажу: оновлення мертвого компонента }, 3000); return () => clearInterval(id); // один рядок запобігає витоку }, [symbol]); return <div>{symbol}: ${price}</div>; } ``` Без return `id` продовжує працювати після демонтажу `PriceTracker`. Замикання захоплює `symbol` і `setPrice`, тому scope компонента ніколи не збирається. З очищенням GC бачить відсутність посилань і звільняє пам'ять. ### Середній: пошук з debounce і abort Реалістичний сценарій для адмін-панелі: користувач вводить у поле пошуку, ти робиш debounce і fetch. Потрібно очистити і таймер, і запит. ```tsx function UserSearch({ users }: { users: User[] }) { const [results, setResults] = useState<User[]>([]); useEffect(() => { const controller = new AbortController(); // Чекаємо 300 мс перед відправкою запиту const timer = setTimeout(async () => { try { const res = await fetch('/api/users/search', { signal: controller.signal, }); const data = await res.json(); setResults(data); } catch { // AbortError очікуваний при очищенні } }, 300); return () => { clearTimeout(timer); // скасувати очікуваний debounce controller.abort(); // скасувати запит у польоті }; }, [users]); return <ul>{results.map(u => <li key={u.id}>{u.name}</li>)}</ul>; } ``` Без очищення кожне натискання клавіші планує таймер, що захоплює весь масив `users`. Швидкий набір тексту = багато замикань = heap постійно росте. ### Просунутий: Express обробник з таймером, що витікає на кожен запит Цей варіант ловить senior розробників зненацька. Виглядає нешкідливо, доки графіки пам'яті о третій ночі не кажуть інше. ```js // ПОГАНО: тримає великі дані на кожен запит, назавжди app.get('/data', async (req, res) => { const largeData = await fetchBigPayload(req.query.id); // ~10 МБ буфер const watcher = setInterval(() => { // Замикання захоплює largeData. Таймер ніколи не очищається. console.log('Розмір payload:', largeData.length); }, 5000); res.json({ ok: true }); // Відповідь відправлена, але largeData живе у замиканні таймера // 100 запитів = ~1 ГБ heap }); // ДОБРЕ: очищаємо interval після завершення відповіді app.get('/data', async (req, res) => { const largeData = await fetchBigPayload(req.query.id); let watcher: ReturnType<typeof setInterval>; res.on('finish', () => clearInterval(watcher)); // спрацьовує після відповіді watcher = setInterval(() => { console.log('Розмір payload:', largeData.length); }, 5000); res.json({ ok: true }); }); ``` Щоб зловити це в продакшні: `node --heap-prof server.js`, запусти навантажувальний тест, відкрий `.heapprofile` у Chrome DevTools. Шукай `fetchBigPayload` у retainer chain і слідуй до callback таймера.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.