Ваш застосунок працює добре протягом десяти хвилин. Потім прокрутка стає «липкою». Через пів години вкладка досягає одного гігабайта. Зрештою сторінка «вмирає» з помилкою out-of-memory, і у вас немає жодного стек-трейсу, щоб це пояснити.
Це не проблема продуктивності рендерингу. React DevTools Profiler виглядатиме спокійним, оскільки проблема не в тому, як часто компоненти перемальовуються. Проблема в тому, що залишається «живим» після їхнього розмонтування (unmount). Випадкове посилання десь у JavaScript heap утримує ціле дерево DOM-вузлів, замикань та стану. Браузер не може звільнити жодної з цих частин, тому споживання пам'яті зростає, поки процес не завершиться аварійно.
Читання вихідного коду не допоможе виявити витік. Баг ховається в розриві між тим, що, на вашу думку, було розмонтовано, і тим, що насправді бачить збирач сміття (garbage collector). V8 звільняє лише ті об'єкти, які мають нуль шляхів утримання (retaining paths). Якщо сторонній слухач подій, неочищений спостерігач або довгоживуче замикання тримає хоча б одне посилання на fiber або DOM-вузол, усе піддерево компонента виживає. Ви розмонтовуєте модальне вікно, але його від'єднані (detached) вузли залишаються в пам'яті, тому що слухач на window все ще вказує на обробник, визначений всередині цього модального вікна.
Щоб довести, де саме знаходиться витік, вам потрібно дивитися на heap, а не на редактор коду.
Чому heap говорить правду
Chrome DevTools дає вам прямий доступ до того, що бачить збирач сміття. Вкладка Memory дозволяє записувати heap snapshots: повні інвентаризації кожного об'єкта, DOM-вузла та замикання, які наразі утримуються в пам'яті JavaScript. Порівнюючи два знімки — один до підозрюваного витоку, а інший після — ви можете точно виокремити об'єкти, які не були видалені.
Це не абстрактна теорія. Один витік React-компонента може утримувати тисячі від'єднаних об'єктів HTMLElement. Ці об'єкти більше не прив'язані до видимого документа, але посилання JavaScript заважають їх збору. Вони з'являються в режимі порівняння з назвою конструктора Detached HTMLElement. Коли ви бачите, як їхня кількість зростає, ви знайшли свій витік.
Робочий процес у Chrome DevTools
Почніть з чистого аркуша. Закрийте непотрібні вкладки браузера, вимкніть сторонні розширення та дайте застосунку перейти у стабільний стан. Відкрийте Chrome DevTools, перейдіть на вкладку Memory і виберіть Heap snapshot. Натисніть Take snapshot. Цей базовий знімок фіксує початковий обсяг пам'яті.
Тепер виконайте саме ту дію користувача, яка викликає підозру. Відкрийте та закрийте те важке модальне вікно. Розмонтуйте та змонтуйте віджет. Перейдіть на інший маршрут і назад. Коли інтерфейс повернеться до свого початкового візуального стану, натисніть на іконку кошика у вкладці Memory. Це примусово запустить глобальний цикл збору сміття. Тимчасові об'єкти з циклу рендерингу мають зникнути. Усе, що залишиться, є реальним кандидатом на витік.
Знову натисніть Take snapshot. Тепер у вас є два «фотографії» пам'яті. Змініть режим перегляду
Незачищені спостерігачі. IntersectionObserver та ResizeObserver є потужними інструментами, але вони створюють нативні посилання поза межами контролю React. Якщо ви створюєте екземпляр спостерігача всередині компонента і забуваєте викликати disconnect() на етапі очищення, спостерігач утримує цільовий DOM-вузол, а DOM-вузол утримує React fibers, props та state.
Пастки замикань. Коли ви визначаєте функцію всередині компонента і передаєте її сторонній бібліотеці, глобальному кешу або навіть у setTimeout, ця функція замикає всі змінні у своєму лексичному середовищі. Якщо зовнішній власник зберігає функцію, він також зберігає все середовище вашого компонента.
Патерни очищення, які справді працюють
Виправлення витоку означає розрив кожного шляху утримання (retaining path), який ви знайшли у знімку пам'яті (snapshot).
Завжди повертайте функцію очищення з useEffect. Якщо ви додаєте слухач (listener) у ефекті, видаляйте його саме там.
Використовуйте useCallback для будь-якого обробника, який ви приєднуєте до DOM або window. Без цього кожен рендер створює нове посилання на функцію. Якщо ви викликаєте addEventListener з одним посиланням, а згодом викликаєте removeEventListener з іншим, видалення відбудеться без помилок, але фактично не спрацює. Початковий слухач назавжди залишиться у window. useCallback забезпечує стабільність посилання, щоб операції додавання та видалення точно збігалися.
Поводьтеся зі спостерігачами з такою ж дисципліною. Зберігайте екземпляр спостерігача в ref або локальній змінній всередині ефекту. У функції очищення викликайте observer.disconnect(). Не сподівайтеся, що розмонтування (unmounting) компонента автоматично зупинить спостерігача. Це не так.
Якщо ваш компонент публікує щось у глобальному просторі імен або в сервісі-синглтоні, видаляйте ці посилання під час розмонтування. Рушій V8 може звільнити пам'ять лише тоді, коли об'єкт стає справді недоступним. Залишення хука у window або запису в Map на рівні модуля створює невидимий місток, через який купа (heap) продовжує зростати.
Головний висновок
Витоки пам'яті не призводять до миттєвого краху додатка. Вони накопичуються по одному від'єднаному вузлу за раз під час тривалих сесій користувачів. Виправлення — це не оновлення бібліотеки чи прапорець компілятора. Це звичка перевіряти свою логіку очищення за допомогою знімків купи (heap snapshots).
Зробіть базовий знімок (baseline), запустіть підозрілу дію, примусово запустіть збирання сміття (garbage collection) і порівняйте результати. Якщо дельта показує зростання, перевірте шлях утримання, знайдіть слухача або спостерігача, якого не має бути, і розірвіть посилання. Запустіть тест знову. Коли дельта залишиться стабільною, ви справді вирішили проблему. Ваш додаток залишатиметься чуйним, а користувачі не втратять свою роботу через зависання вкладки браузера.
