Главная / Статьи / Поиск утечек памяти в JavaScript: доступность, удерживающие объекты и очистка

Поиск утечек памяти в JavaScript: доступность, удерживающие объекты и очистка

Узнайте, почему JavaScript с механизмом сбора мусора всё ещё имеет утечки памяти, какие повседневные паттерны удерживают память, и как найти причину с помощью снимков кучи и цепочек удерживающих объектов.

4420 слов

Веб-приложение, которое работает мгновенно в 9 утра, но становится медленным к 5 вечера, — один из наиболее распространенных симптомов утечки памяти: клики отвечают с небольшой задержкой, прокрутка теряет плавность, анимации сбоили, использование оперативной памяти превышает гигабайт, а перезагрузка страницы вновь восстанавливает нормальную работу. Подобные утечки не вызывают исключений, не нарушают тесты и проходят проверки CI без проблем, поскольку они проявляются только тогда, когда приложение остается открытым в течение нескольких часов. В этом руководстве объясняется, почему даже языки с механизмом сбора мусора могут иметь утечки, рассматриваются типичные сценарии, приводящие к утечкам в реальных условиях, а также предлагается пошаговая процедура с использованием Chrome DevTools для поиска конкретного объекта, который удерживает память в активном состоянии.

Сбор мусора освобождает недоступные данные, а не те, что не используются

Поскольку JavaScript никогда не просит вас вызывать malloc() или free(), возникает соблазн думать, что система выполнения полностью занимается управлением памятью. Однако это мнение верно лишь отчасти. Коллектор не освобождает объект, когда ваш код с ним закончил работу; он освобождает объект тогда, когда до него больше нельзя добраться. Если какая-то забытая ссылка всё ещё указывает на объект, движок не может понять, что этот объект является бесполезным. С точки зрения системы выполнения любой доступный объект может ещё понадобиться.

Именно в этой промежутке между состояниями «больше не используется» и «больше недоступен» скрываются некоторые из самых сложных ошибок производительности в современном веб-разработании.

Сценарий: панель управления, замедлявшая работу каждый день после обеда

Представьте панель управления операциями, которую сотрудники держат открытой в течение всего сменного времени. В режиме тестирования все работает быстро: мгновенная первоначальная загрузка, эффективные запросы к API, высокие оценки по инструменту Lighthouse. Затем поступают отзывы из продакшена. Через пять-шесть часов переключение вкладок становится медленным, графики отрисовываются медленно, а даже простое модальное окно требует заметного времени для открытия.

Типичной первой реакцией является обвинение бэкенда. Команда настраивает запросы к базе данных, убеждается, что время ответа API не превышает 100 миллисекунд, и видит низкую загрузку процессора на серверах. Ничто из этого не объясняет замедление, усиливающееся со временем суток.

Этот прорыв связан с менеджером задач Chrome. Объем памяти этой вкладки утром составлял примерно 150 МБ, а к концу дня достиг почти 1,4 ГБ. Каждое перемещение внутри страницы, каждое диалоговое окно, каждая обновление виджета оставляли за собой небольшое количество памяти. Каждая выделенная порция памяти была незначительной, но тысячи таких операций в сумме имели значительный эффект. Процесс отрисовки, задержка в сети и скорость выполнения кода были в порядке. Приложение просто никогда не освобождало объекты, в использовании которых больше не было необходимости.

Как движки решают, какие объекты оставить

Для создания объекта в JavaScript совсем не требуется никаких сложных процедур:

const user = {
  id: 101,
  name: "Emma"
};

Когда ничто больше не ссылается на user, движок может забрать его обратно. Интересно то, как он принимает такое решение. V8 (Chrome и Node.js), SpiderMonkey (Firefox) и JavaScriptCore (Safari) отслеживают граф объектов, связанных между собой ссылками. Корни, такие как глобальный объект, находятся в верхней части этого графа, а все элементы, создаваемые вашим приложением, связаны с ними: экземпляр приложения, маршрутизатор, хранилище данных, деревья компонентов и любые глобальные переменные.

Цикл сбора мусора начинается с этих корней и следует по всем доступным ссылкам. Те объекты, до которых он добирается, остаются в системе. Те же, до которых добраться невозможно, становятся кандидатами на удалиение. Ключевым словом здесь является кандидаты на удалиение, а ключевым свойством — недоступные. Объект собирается не потому, что он устарел, неиспользуем или забыт; он собирается только потому, что к нему нет никаких путей через ссылки.

Предположим, вы загружаете большой список записей:

const employees = fetchEmployees();

Через некоторое время интерфейс больше не отображает эти данные, но другой объект по-прежнему ссылается на них:

cache.employees = employees;

Даже если никто больше не будет читать cache.employees, сборщик мусора не может этого предполагать. Ссылка существует, поэтому весь массив остается в памяти. Работа движка соответствует заложенной схеме; утечка происходит из-за кода приложения.

Мышление через ссылки вместо объектов

Утечки становятся гораздо проще понимать, когда вы перестаете сосредотачиваться на объектах и начинаете рассматривать ссылки, которые на них указывают. Возьмем функцию, которая создает объект и возвращает его:

function createUser() {
  const user = {
    name: "Alice"
  };
return user;
}
const employee = createUser();

После её выполнения одна ссылка связывает переменную с объектом:

employee
   │
   ▼
{ name: "Alice" }

Если затем устранить эту ссылку, у объекта не останется входящих связей, и следующий процесс сбора мусора сможет его удалить:

employee = null;

Одна корректировка, если вы попробуете это сделать сами: в предыдущем фрагменте переменная employee была объявлена с использованием ключевого слова const, поэтому её переопределение вызывает ошибку TypeError. Используйте ключевое слово let, если планируете позже устранить ссылку на эту переменную. Суть остаётся прежней: именно устранение последней ссылки позволяет объекту быть собранным в одно целое.

Теперь немного измените пример так, чтобы функция хранила созданные объекты в массиве на уровне модуля:

const users = [];
function createUser() {
  const user = {
    name: "Alice"
  };  users.push(user);
}

После возврата функции createUser() каждый объект пользователя по-прежнему находится в списке users, а сам список доступен с верхнего уровня:

Window
 │
 ▼
users
 │
 ├── User 1
 ├── User 2
 ├── User 3
 └── User 4

Пока объект users доступен, доступны и все элементы внутри него. Именно поэтому утечки обычно растут постепенно: ни один отдельный объект не является крупным, но тысячи мелких объектов накапливаются за часы или дни.

Утечки накапливаются с каждым взаимодействием

Фраза «утечка памяти» зачастую вызывает образ одного огромного объекта, занимающего сотни мегабайт. На практике же проблема почти всегда связана с мелкими и повторяющимися утечками.

Представьте, что при закрытии диалогового окна настроек остается примерно 20 КБ данных. Само по себе это незначительно. Однако опытный пользователь, который открывает и закрывает это окно 500 раз за рабочий день, уже теряет около 10 МБ. Если добавить пять компонентов с похожими незначительными потерями на каждое взаимодействие, восьмичасовую сессию работы, несколько открытых вкладок одновременно и постоянные обновления, приходящие каждые несколько секунд, то эти незначительные объемы превращаются в сотни мегабайт.

Эта арифметика также объясняет, почему разработчики редко это замечают. Во время разработки страницу перезагружают каждые несколько минут, что полностью сбрасывает все данные. Пользователи же не перезагружают страницу — они продолжают работать.

Почему одностраничные приложения страдают больше всего

Классические многостраничные сайты имели своего рода случайный защитный механизм: при каждой навигации загружался новый документ, и весь JavaScript-хэш, включая утечки объектов, удалялся.

Приложения с одной страницей, созданные с использованием React, Angular, Vue, Svelte или подобных фреймворков, могут работать часами без полной перезагрузки. Это отлично сказывается на пользовательском опыте, и именно поэтому так важна дисциплина в использовании памяти. Каждая смена маршрута, появление модального окна, уведомления, сообщение через WebSocket или обновление графика создают новые объекты. Если они не освобождаются должным образом, они существуют столько же времени, сколько и сама страница.

Здесь есть ирония: чем лучше пользовательский опыт, тем дольше люди остаются в приложении, и тем больше шансов, что незначительные утечки памяти накопятся.

Механизм сбора мусора — сложный инструмент, а не предвидящее существо

Современные движки используют поэтапный и генерационный сбор мусора, одновременную маркировку объектов, их сжатие и сбор во время периодов бездействия. Эти методы позволяют эффективно и незаметно управлять памятью, но они не могут исправить логические ошибки.

Представьте, что вы одолжили кому-то книгу и так и не попросили её вернуть. Они не могут знать, что вы об этом забыли, поэтому с их точки зрения вы всё ещё хотите, чтобы книга была возвращена однажды. Ссылки работают аналогичным образом. Пока ваше приложение сохраняет ссылку на объект, движок предполагает, что этот объект важен, независимо от того, будет ли ваш код когда-либо снова им пользоваться. Он не может понять намерения; он просто следует связям в графе.

Это изменение влияет на способ отладки. Вместо того чтобы задаваться вопросом, почему механизм освобождения памяти не работает, вы задаёте более продуктивный вопрос: что всё ещё сохраняет ссылку на этот объект? Почти всегда именно там находится ошибка.

Паттерны, лежащие в основе большинства утечек памяти в реальном мире

Сам сборщик мусора не сломан; утечки происходят потому, что код сохраняет ссылки, которые следовало бы уничтожить. Такие ссылки редко кажутся подозрительными. Они появляются в обычном, казалось бы, нормальном коде, а не в экзотических алгоритмах или ошибках браузера. Следующие шаблоны описывают утечки, с которыми вы чаще всего сталкиваетесь в производственной среде.

Слушатели событий, которые никогда не удаляются

Слушатели событий — одна из наиболее частых причин утечек, особенно в SPA. Их настройка обычно кажется невинной: берется элемент и привязывается обработчик.

const button = document.getElementById("save");
button.addEventListener("click", saveDocument);

Позже пользователь уходит с страницы, и кнопка исчезает с неё. Однако удаление элемента из DOM само по себе не прерывает все связанные с ним ссылки в JavaScript. Если обработчик всё ещё зарегистрирован, а что-то другое сохраняет доступ к элементу или обработчику, то и слушатель, и те данные, к которым он имеет доступ, остаются в памяти. Утечка данных наиболее серьёзна, когда слушатели привязаны к долговечным объектам, таким как window или document, поскольку эти объекты никогда не исчезают. Решением является явная дерегистрация обработчика:

button.removeEventListener("click", saveDocument);

В React, Angular или Vue следует выполнять это на этапе размонтирования или уничтожения компонента в рамках его жизненного цикла. Полезной привычкой является рассматривание каждого вызова addEventListener() как обязательства: когда вы добавляете такой вызов, необходимо знать, когда и где он будет удален. Передача сигнала AbortController нескольким обработчикам и его отмена при уничтожении компонента — удобный способ выполнить это обязательство для всех обработчиков одновременно.

Таймеры, действующие дольше времени отображения экрана

Таймеры также могут приводить к утечкам ресурсов незаметно. Панель управления, которая запрашивает новые данные каждые пять секунд, может выглядеть следующим образом:

const timer = setInterval(() => {
    loadLatestData();
}, 5000);

Если пользователь покидает страницу, а интервал так и не очищается, функция-обратный вызов продолжает выполняться на фоне. Она сохраняет свою замыкательную функцию в активном состоянии, а вместе с ней — любые функции, переменные или даже целые экземпляры компонентов, на которые она ссылается. Несколько забытых интервалов могут занимать гораздо больше памяти, чем можно было ожидать. Очищайте их, когда их владелец уходит:

clearInterval(timer);

Та же дисциплина применяется к setTimeout() (с помощью clearTimeout()) и requestAnimationFrame() (с помощью cancelAnimationFrame()).

Отделенные узлы DOM

Отделенный узел — это элемент, который больше не является частью документа, но по-прежнему упоминается в JavaScript. Типичным способом создания такого узла является извлечение модального окна и его удаление:

const modal = document.getElementById("modal");
modal.remove();

Кажется, что элемент устранён, но если какая-либо переменная, массив, замыкание или объект состояния всё ещё указывает на него, его невозможно удалить, как и его дочерние элементы. Приложения, которые динамически создают модальные окна, подсказки, выпадающие списки или панели уведомлений, особенно подвержены этой проблеме. Каждый узел невелик по размеру, но после сотен взаимодействий отсоединённые поддеревья могут занимать удивительно большое количество памяти.

Замыкания, хранящие больше данных, чем необходимо

Замыкания — одна из самых мощных особенностей JavaScript, но они также способствуют случайному сохранению памяти. Рассмотрим пример фабрики, которая выделяет большой массив перед возвратом функции:

function createLogger() {
    const largeData = new Array(100000).fill("data");
    return function () {
        console.log("Logging...");
    };
}

Возвращаемая функция никогда не обращается к переменной largeData. То, останется ли этот массив в памяти, зависит от того, как движок представляет вложенный область видимости. На практике V8 сохраняет только те переменные, на которые фактически ссылаются какие-либо замыкания в данной области, поэтому этот конкретный фрагмент кода обычно не сохраняет массив. Риск возникает, когда второе замыкание, созданное в той же области, действительно использует largeData: замыкания делитят один объект контекста, поэтому замыкание-логгер с долгим сроком жизни также остаётся в памяти и сохраняет большой массив. Использование eval внутри области видимости также заставляет движок сохранять всё.

Ничто из этого не делает замыкания плохими; современный JavaScript от них зависит. Главное — тщательно продумывать, что может видеть функция с длительным сроком жизни. Если ей нужно только одно значение, передайте или скопируйте именно это значение вместо того, чтобы использовать замыкание над целым объектом или набором данных. Небольшие корректировки области видимости могут значительно снизить использование памяти.

Кэши без политики удаления

Кэширование позволяет избежать повторной обработки данных, но кэш, который постоянно растет, — это всего лишь медленная утечка ресурсов под предлогом хороших намерений. Вот пример минимальной системы мемоизации:

const cache = {};
function getUser(id) {
    if (!cache[id]) {
        cache[id] = fetchUser(id);
    }    return cache[id];
}

Сначала она работает хорошо. Однако через шесть месяцев эксплуатации в ней может оказаться сотни тысяч записей, которые больше никто не будет запрашивать. Вместо того чтобы позволять кэшу расти без ограничений, стоит учитывать следующее:

  • Максимальный размер
  • Срок действия записей, устаревших со временем
  • Стратегию удаления по принципу LRU (наименее часто используемые)
  • WeakMap — когда кэш ключеван объектами, срок жизни которых должен определять срок жизни записи
  • Обратите внимание, что WeakMap принимает в качестве ключей только объекты (или незарегистрированные символы), поэтому он не заменяет механизм LRU для кэшей, ключеванных числовыми идентификаторами, как в примере выше. Если вам нужно вспомнить, как работают слабые ссылки, ознакомьтесь с нашим обзором Символов, WeakMaps, прокси и генераторов. Кэш без стратегии удаления на самом деле не является кэшем; это постоянное хранилище.

    Глобальные переменные, существующие вечно

    Всё, что привязано к глобальному области видимости, существует столько же, сколько и само приложение. Это одновременно удобно и рискованно. Коллекция на уровне модуля, подобная этой:

    let allUsers = [];
    

    постоянно увеличивается каждый раз, когда добавляются новые данные:

    allUsers.push(...newUsers);
    

    Если код явно не очищает массив, его размер будет только увеличиваться. В течение месяцев разработки крупные глобальные объекты часто превращаются в места накопления данных. При анализе проблем с производительностью глобальное состояние — одно из первых мест, которые стоит проверить.

    WebSockets и другие долговечные соединения

    Функции реального времени часто основаны на WebSockets, и для их открытия достаточно одной строки кода:

    const socket = new WebSocket(url);
    

    Ошибка заключается в неправильном закрытии соединения. Открытый сокет продолжает принимать сообщения, вызывать обратные вызовы и сохранять состояние приложения даже после того, как пользователь ушел в другое место. Закрывайте соединения, когда функция, которая ими управляет, больше не используется:

    socket.close();
    

    То же правило применяется к объектам типа observables, потокам, пользовательских эмиттерам событий и любым другим механизмам подписки, которые могут существовать дольше своих потребителей.

    Общая закономерность

    На первый взгляд эти примеры кажутся разными, но у них есть одна общая причина: что-то сохраняет ссылку на объект, который должен был стать недоступным. Обычно это бывает одно из следующего:

    • Зарегистрированный слушатель
    • Ожидающий интервал или таймаут
    • Область видимости закрытия
    • Постоянно растущая кэш-память
    • Переменная уровня модуля или глобальная переменная
    • Открытый сокет или другая подписка

    Если начать думать о ссылках, а не об объектах, поиск утечек становится гораздо более интуитивным. Вместо того чтобы спрашивать, почему память продолжает расти, стоит спросить, что всё ещё удерживает объект. Этот вопрос обычно напрямую ведёт к выявлению утечки.

    Обнаружение утечки раньше, чем это сделают ваши пользователи

    Знание причин полезно, но в реальном кодовом базисе главный вопрос заключается просто в том, где происходит утечка. Большое приложение может содержать тысячи компонентов и сотни слушателей, при этом объекты создаются каждую секунду. Отгадывание редко помогает. Инструменты браузера отличны; разницу создаёт их использование в последовательном и систематическом порядке, а не попытки изучить каждую функцию DevTools.

    Подтвердите, что действительно происходит утечка

    Увеличение объёма памяти не обязательно означает утечку. Движки выделяют память по мере работы приложения и освобождают её во время сборки мусора, поэтому здоровое приложение демонстрирует зубчатую кривую:

    Memory
     ^
     |        /\      /\       /\
     |       /  \    /  \     /  \
     |______/____\__/____\___/____\____ Time
    

    Использование памяти растёт во время активности и снижается после каждой сборки мусора. Приложение с утечкой выглядит иначе:

    Memory
     ^
     |          /\        /\
     |         /  \      /  \
     |        /    \    /    \
     |_______/______\__/______\________
     |              /
     |             /
     |            /
     |___________/________________ Time
    

    Небольшие падения показывают, что процесс сбора мусора выполняется, но каждый минимум находится выше предыдущего. Память никогда не возвращается к своему первоначальному уровню, что является первым реальным признаком того, что объекты остаются в памяти. Прежде чем делать выводы, вручную запустите сбор мусора (иконка мусорного ведра в панелях «Память» и «Производительность»), поскольку высокий уровень памяти, обусловленный лишь тем, что сбор мусора ещё не выполнялся, не является утечкой памяти.

    Шаг 1: открыть панель «Память»

    В Chrome есть несколько инструментов для анализа памяти, но вам не нужны все они сразу. Откройте DevTools и перейдите в панель Память. В зависимости от версии Chrome вы увидите такие способы профилирования, как снимок состояния кучи памяти, отслеживание выделения памяти по временной шкале и выборочный анализ выделения памяти; точные названия и опции могут отличаться в разных версиях, поэтому проверьте документацию к текущим DevTools, если у вас что-то отличается.

    Для большинства расследований снимок кучи памяти является подходящей отправной точкой, поскольку он показывает, что в настоящее время занимает память.

    Шаг 2: зафиксируйте исходное состояние

    Перед тем как использовать подозрительную функцию, сделайте снимок первоначального состояния приложения, «фотографию» кучи памяти. Затем неоднократно выполняйте действия, которые вас настораживают. Например:

    • Десять раз открывайте и закрывайте модальное окно
    • Переходите туда-обратно между страницами
    • Загружайте файлы
    • Применяйте фильтры к большой таблице данных
    • Постоянно переключайтесь между вкладками панели управления

    Когда закончите, сделайте второй снимок. Теперь у вас есть два состояния для сравнения.

    Шаг 3: сравните снимки

    Именно здесь начинается настоящее расследование. Если процесс очистки работает корректно, временные объекты, созданные во время взаимодействия, должны исчезать после сбора данных. Если же это не происходит, количество определенных типов объектов продолжает расти. К типичным виновникам относятся:

    • Отсоединенные элементы DOM
    • Большие массивы
    • Прослушиватели событий
    • Собственные классы приложения
    • Компоненты фреймворка, которые должны были быть уничтожены

    Вам не обязательно понимать каждый элемент в куче объектов. Используйте режим сравнения и ищите типы объектов, количество которых при повторении одной и той же операции увеличивается на постоянную величину. Постоянство обычно является самой сильной подсказкой.

    Отсоединенные узлы DOM — это самые легко заметные доказательства

    Отсоединенные узлы — одни из самых простых для распознавания утечек. Откройте и закройте модальное окно двадцать раз; после каждого закрытия оно должно исчезнуть. Если снимок все еще содержит двадцать элементов модального окна, что-то удерживает их. Вы можете ввести «Detached» в фильтр классов снимка, чтобы быстро их вывести.

    Сам DOM редко является истинной проблемой. Настоящая причина обычно находится где-то еще:

    • Обработчик, который все еще зарегистрирован на элементе или на долгоживущем объекте-цели
    • Интервал или таймер, чей обратный вызов упоминает этот узел
    • Закрытие, которое зафиксировало элемент
    • Хранилище, поле компонента или массив, в которых сохранен указатель на него

    Рассматривайте отсоединенный узел как симптом: доказательство того, что какая-то другая ссылка помешала очистке.

    Проследите цепочку удерживающих ссылок

    Как только вы находите объект, который явно не должен существовать, следующий вопрос заключается в том, кто поддерживает его существование. Раздел Retainers снимка кучи отвечает именно на этот вопрос. Выберите объект, и Chrome покажет цепочку ссылок, связывающих его с корнем коллекционной уборки мусора. Концептуально это может выглядеть так:

    Window
       │
    Application
       │
    UserService
       │
    cachedUsers
       │
    User Object
    

    Теперь расследование становится простым. Вместо того чтобы гадать, почему объект пользователя остается в существовании, вы можете увидеть, что на него ссылается коллекция cachedUsers внутри класса UserService. Определение объекта, который сохраняется, полезно; однако именно выяснение того, что его сохраняет, позволяет действительно устранить ошибку.

    Просмотр лайв-показателей с Performance Monitor

    Снимки идеально подходят для детального анализа, но они не являются единственным инструментом. Performance Monitor в Chrome отображает лайв-метрики, включая:

    • Размер кучи JS
    • Количество узлов DOM
    • Количество слушателей событий JS
    • Документы и фреймы

    Если количество узлов DOM или слушателей событий продолжает расти при повторении одной и той же операции, очистка не происходит. Преимущество заключается в скорости: нет необходимости ждать, пока приложение станет медленным, и подозрительные тенденции часто становятся заметными в течение нескольких минут.

    Выделите один небольшой, воспроизводимый сценарий

    Частой ошибкой является попытка исследовать всё приложение сразу. Вместо этого сосредоточьтесь на одной конкретной интеракции:

    • Покажите одно модальное окно, закройте его и повторите это двадцать раз подряд
    • Или переходите между двумя одинаковыми маршрутами пятьдесят раз

    Подобные сложные сценарии гораздо проще количественно оценить. Когда одно и то же действие приводит к росту объема памяти при каждой итерации, диапазон поиска уже существенно сужается, и ответственный фрагмент кода обычно легко находится дальше.

    Целенаправленно тестируйте длительные сессии

    Разработчики часто тестируют приложение в течение десяти–пятнадцати минут и переходят к следующему заданию. Настоящие пользователи внутренних панелей управления, торговых платформ, инструментов мониторинга или порталов поддержки могут оставлять их открытыми весь день. Включайте тесты на работу приложения в течение длительного времени: оставляйте приложение открытым, периодически взаимодействуйте с ним и наблюдайте за изменениями объема памяти. Многие утечки становятся заметными только после сотен или тысяч операций.

    Порядок отладки, исключающий догадки

    Постоянное переключение между инструментами анализа тратит время. Лучше следовать определенной последовательности действий:

    1. Проверьте, что объем памяти продолжает расти при работе с различными коллекциями.
  • Воспроизведите рост объема данных с помощью одного повторяемого действия.
  • Сделайте скриншоты стека до и после.
  • Сравните количество объектов в этих скриншотах.
  • Определите объекты, которые остаются в памяти.
  • Проследите цепочку хранения ссылок, чтобы выяснить, кто удерживает их.
  • Исправьте код и повторите тот же самый тест.
  • Это исключает необходимость догадок в процессе. Вместо того чтобы предполагать, что виноват какой-то конкретный компонент, вы позволяете доказательствам указать на него.

    Предотвращение утечек до их появления в продакшене

    Диагностика — это лишь половина дела; наилучший способ избежать проблем — с самого начала не допускать утечек памяти. Большинство утечек возникают не из-за непонимания разработчиками языка JavaScript. Они происходят потому, что современные приложения имеют длительный срок существования, являются высокоинтерактивными и постоянно занимают память, и в таких условиях легко забыть, что всё созданное в конечном итоге должно быть уничтожено. Команды, которые редко сталкиваются с проблемами памяти, не обязательно пишут более умный код; у них просто есть привычки, которые снижают вероятность утечек.

    Установите четкий срок окончания жизни для каждого ресурса

    Каждый раз, когда код создает объект с длительным сроком существования, задайте один вопрос: когда он будет уничтожен? Это касается гораздо большего, чем просто оперативной памяти:

    • Слушатели событий на элементах, объектах window или document
    • Интервалы, таймеры и кадры анимации
    • Сокеты и другие сетевые соединения
  • Объекты наблюдения, хранилища и другие подписки
  • Сохранённые ссылки на элементы DOM
  • Кэшированные ответы и вычисленные значения
  • Веб-рабочие процессы и подобные фоновые задачи
  • Настройка этого обычно проста; проблемы возникают при очистке. Есть простое правило: если у вашего кода есть начало, у него должна быть и точка окончания. Уже одно такое мышление помогает избежать значительного количества утечек ресурсов.

    Заставьте компоненты самостоятельно очищаться

    Фреймворки, основанные на компонентах, поощряют использование самодостаточных единиц, и к самодостаточности должна относиться также возможность демонтажа. Компонент, запускающий таймер, должен останавливать его при снятии с экрана. Компонент, регистрирующий обработчики событий, должен удалять их. Ничто из того, что запустил компонент, не должно продолжать работать после того, как он скроется с экрана.

    Представьте, что вы выходите из гостиничного номера: вы не уходите, оставив свет включенным, телевизор работающим и кран открытым. Качественный компонент оставляет все в том состоянии, в котором его нашел. В React это означает возвращение функции очистки из каждого useEffect, который подписывается, планирует действия или устанавливает соединение.

    Проектируйте кэши с учетом удаления, а не только добавления

    Кэши создаются с хорошими намерениями — чтобы избежать повторных вызовов API или дорогостоящих вычислений. Однако со временем в них могут накопиться тысячи объектов, в которые никто не заглядывал уже несколько недель. При проектировании кэша стоит так же тщательно продумать, как элементы будут удаляться, как и то, как они попадают в него:

    • Как долго элемент должен оставаться в памяти?
    • Каков максимальный размер?
    • Должны ли элементы автоматически истекать?
    • Могут ли редко используемые элементы быть удалены?

    Если на эти вопросы нет ответов, кэш почти наверняка будет расти со временем.

    Храните только те данные, которые вам действительно нужны

    Ещё одной распространённой проблемой является хранение целых объектов, когда используется лишь их небольшая часть. Если вы загружаете большой профиль только для отображения имени пользователя, нет причин хранить полный ответ бесконечно; сохраняйте только те поля, которые необходимы интерфейсу. Меньшие объекты потребляют меньше памяти, их легче анализировать, и они реже сохраняются случайно. Иногда решением является не добавление кода, а уменьшение объёма хранимых данных.

    Серьёзно относитесь к незначительным утечкам

    Хочется проигнорировать утечку в несколько килобайт. Проблема в том, что пользователи редко выполняют какие-либо действия всего один раз. Панель управления, работающая весь день, внутренний инструмент администрирования, используемый сотнями сотрудников, или панель мониторинга, которую никто не перезагружает, выполняют одни и те же кодовые пути тысячи раз. Утечка, которая сегодня едва заметна, может превратиться в серьёзную проблему после нескольких недель нормального использования.

    Внедрение проверок памяти в повседневную разработку

    Работа над производительностью обычно сосредотачивается на времени загрузки и задержках API, но память также заслуживает внимания. При разработке новой функции уделяйте несколько дополнительных минут проверке следующего:

    • Возвращается ли память к своему исходному уровню после использования функции?
    • Не растет ли неожиданно количество слушателей событий?
    • Исчезают ли узлы DOM после удаления компонентов?
    • Не увеличивается ли память при постоянном выполнении одной и той же операции?

    Эти проверки требуют немного усилий, но могут сэкономить часы на отладке позже.

    Чек-лист для ревью кода

    Перед слиянием пройдитесь по краткому внутреннему списку:

    • Удалены ли все добавленные слушатели событий?
    • Аннулируются ли таймеры, когда они больше не нужны?
    • Правильно ли удаляются подписки?
    • Может ли этот кэш расти без ограничений?
  • Ссылки на объекты сохраняются дольше необходимого времени?
  • Компонент удаляет всё, что создал?
  • Не обязательно применять это ко всем строкам кода, но включение этого пункта в процесс проверки значительно снижает риск выпуска программы с утечками.

    Основные выводы

    • Утечки редко приводят к сбоям сразу же; они постепенно накапливаются и сначала влияют на самых активных пользователей, что делает их особенно опасными.
    • Они также предсказуемы: объект остаётся активным только потому, что к нему всё ещё существует ссылка, поэтому каждая утечка связана со ссылкой, которую следовало удалить.
    • Когда приложение замедляется со временем, не стоит винить браузер или движок. Сделайте снимок памяти, найдите объекты, которые остались активными, и проследите цепочку их удержания.
    • Обычный ответ кажется банальным: забытый слушатель, несброшенный таймер, неограниченная кэш-память или компонент, который так и не завершил процесс очистки.
    • Быстрый JavaScript — это не только высокая скорость выполнения; речь также идет о управлении жизненным циклом выделяемых ресурсов, чтобы приложение оставалось отзывчивым независимо от того, используется ли оно пять минут или весь рабочий день.

    Связанные статьи