Ваше приложение работает нормально в течение десяти минут. Затем прокрутка начинает «залипать». Через полчаса вкладка разрастается до гигабайта. В конце концов страница «умирает» с ошибкой out-of-memory, и у вас нет никакого stack trace, чтобы понять причину.
Это не проблема производительности рендеринга. React DevTools Profiler будет показывать спокойную картину, потому что проблема не в частоте перерисовки компонентов. Проблема в том, что остается «живым» после их размонтирования (unmount). Случайная ссылка где-то в куче (heap) JavaScript удерживает целое дерево DOM-узлов, замыканий и состояний. Браузер не может освободить эту память, поэтому потребление растет, пока процесс не упадет.
Чтение исходного кода не поможет найти утечку. Баг кроется в разрыве между тем, что, по вашему мнению, было размонтировано, и тем, что на самом деле видит сборщик мусора (garbage collector). V8 освобождает только те объекты, у которых нет путей удержания (retaining paths). Если «бродячий» обработчик событий, незакрытый observer или долгоживущее замыкание удерживает хотя бы один указатель на fiber или DOM-узел, все поддерево компонентов продолжает существовать. Вы размонтируете модальное окно, но его отсоединенные (detached) узлы остаются в памяти, потому что слушатель на window все еще ссылается на обработчик, определенный внутри этого окна.
Чтобы доказать, где именно находится утечка, нужно смотреть на кучу (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. Теперь у вас есть два «снимка» памяти. Измените режим отображения с Summary на Comparison. В качестве области сравнения (comparison scope) выберите первый снимок. Инструмент покажет только то, что изменилось между двумя снимками, отсекая лишний шум среды выполнения.
Отсортируйте по Delta. Ищите увеличившееся количество объектов. Обратите особое внимание на такие конструкторы, как Detached HTMLElement, Array, Function или даже именованные экземпляры классов из вашего собственного кода. Растущая дельта означает, что объекты были созданы во время вашего действия и не были собраны мусорщиком после него.
Отслеживание пути удержания (Retaining Path)
Когда вы обнаружите утекший элемент, выберите его. В нижней панели отобразится retaining path — цепочка ссылок, объясняющая, почему этот объект все еще жив. Цепочка может идти от отсоединенного div через внутренние свойства React в замыкание и, наконец, привести к обработчику событий, зарегистрированному внутри одного из ваших компонентов. Последнее звено в этой цепочке — это номер строки в вашем коде.
Здесь вы переходите от диагностики к поиску первопричины. Если путь удержания заканчивается на window.addEventListener, значит, глобальный слушатель удерживает ваш компонент «в заложниках». Если он заканчивается на экземпляре IntersectionObserver, значит, наблюдатель все еще следит за узлом, который должен был быть удален сборщиком мусора.
Распространенные виновники в React
Утечки памяти в React обычно делятся на три типа.
Осиротевшие глобальные слушатели. useEffect подписывается на window или document, чтобы отслеживать позицию прокрутки, нажатия клавиш или события изменения размера окна. Если эффект не возвращает функцию очистки (cleanup function), которая вызывает removeEventListener, слушатель будет жить до тех пор, пока жива страница. Поскольку слушатель является замыканием, он удерживает в памяти всю область видимости компонента еще долго после того, как React размонтировал компонент.
Неочищенные наблюдатели. IntersectionObserver и ResizeObserver — мощные инструменты, но они создают нативные ссылки вне контроля React. Если вы создаете экземпляр наблюдателя внутри компонента и забываете вызвать disconnect() на этапе очистки, наблюдатель удерживает целевой DOM-узел, а DOM-узел удерживает React-файберы, пропсы и состояние.
Ловушки замыканий. Когда вы определяете функцию внутри компонента и передаете её сторонней библиотеке, в глобальный кэш или даже в setTimeout, эта функция замыкает в себе все переменные из своей лексической области видимости. Если внешний владелец сохраняет ссылку на функцию, он сохраняет и всю область видимости вашего компонента.
Паттерны очистки, которые действительно работают
Устранение утечки означает разрыв всех путей удержания (retaining paths), которые вы обнаружили в снимке кучи.
Всегда возвращайте функцию очистки из useEffect. Если вы добавляете слушатель в эффекте, удаляйте его там же.
Используйте useCallback для любого обработчика, который вы прикрепляете к DOM или window. Без этого каждый рендеринг создает новую ссылку на функцию. Если вы вызываете addEventListener с одной ссылкой, а затем вызываете removeEventListener с другой, удаление не сработает незаметно. Оригинальный слушатель навсегда останется на window. useCallback сохраняет стабильность ссылки, чтобы добавление и удаление точно совпадали.
Работайте с наблюдателями с той же дисциплиной. Сохраняйте экземпляр наблюдателя в ref или локальной переменной внутри эффекта. В функции очистки вызывайте observer.disconnect(). Не полагайтесь на то, что размонтирование компонента уничтожит наблюдателя. Это не так.
Если ваш компонент публикует что-либо в глобальном пространстве имен или в сервисе-синглтоне, удаляйте эти ссылки при размонтировании. Движок V8 может освободить память только тогда, когда объект становится действительно недостижимым. Оставленный хук на window или запись в Map на уровне модуля создают невидимый мост, из-за которого куча продолжает расти.
Главный вывод
Утечки памяти не приводят к немедленному краху приложения. Они накапливаются по одному отсоединенному узлу за раз в ходе длительных пользовательских сессий. Решение заключается не в обновлении библиотеки или использовании флага компилятора. Это привычка проверять свою логику очистки с помощью снимков кучи (heap snapshots).
Сделайте базовый снимок, запустите подозрительный сценарий, принудительно вызовите сборку мусора и сравните результаты. Если дельта показывает рост, изучите путь удержания, найдите слушатель или наблюдатель, которых не должно существовать, и разорвите ссылку. Запустите тест снова. Когда дельта перестанет расти, значит, вы действительно решили проблему. Ваше приложение останется отзывчивым, а пользователи не потеряют свою работу из-за зависшей вкладки браузера.
