В Вашем JS-приложении происходит утечка памяти, а Вы об этом не знаете

Источник: «Your JS App Is Leaking Memory And You Don't Know»
Утечки памяти можно сравнить с утечками воды в доме: хотя небольшие капли поначалу не кажутся большой проблемой, со временем они могут нанести значительный ущерб.

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

Роль сборщика мусора

В области программирования, особенно когда речь идёт о таких языках, как JavaScript, управление памятью имеет решающее значение. К счастью, в JavaScript для этого имеется встроенный механизм, называемый Сборщиком Мусора (GC). Представьте себе добросовестного уборщика, который регулярно прочёсывает ваш дом, собирая все неиспользуемые предметы и избавляясь от них, чтобы поддерживать порядок. Именно это и делает сборщик мусора в памяти вашей программы.

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

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

1. Глобальные переменные

Довольно просто, но заслуживает упоминания.

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

Причина

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

Пример

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

function calculateArea(width, height) {
area = width * height; // Ошибочное создание глобальной переменной 'area'
return area;
}

calculateArea(10, 5);

Здесь переменная area непреднамеренно стала глобальной, поскольку не была объявлена с помощью let, const или var. Это означает, что после выполнения функции переменная area по-прежнему доступна и занимает память:

console.log(area); // Вывод: 50

Предотвращение

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

Модификация приведённого выше примера для корректного определения области видимости переменной area:

function calculateArea(width, height) {
let area = width * height; // Корректная область видимости внутри функции
return area;
}

calculateArea(10, 5);

Теперь, после выполнения функции, переменная area недоступна за её пределами, и она будет правильно убранным в мусор после выполнения функции.

2. Таймеры и обратные вызовы

JavaScript предоставляет встроенные функции для асинхронного выполнения кода по истечении определённого времени (setTimeout) или через регулярные промежутки времени (setInterval). Несмотря на свою мощь, они могут непреднамеренно приводить к утечкам памяти, если ими не управлять должным образом.

Причина

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

Пример

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

let userData = {
name: "John",
age: 25
};

let intervalId = setInterval(() => {
// Обновление userData каждые 5 секунд
userData.age += 1;
}, 5000);

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

Предотвращение

Главное — всегда останавливать таймеры, когда они не нужны. Если вы закончили работу с интервалом или таймаутом, очистите их с помощью функции clearInterval() или clearTimeout() соответственно.

В продолжение приведённого выше примера, если вы решите, что вам больше не нужно обновлять userData, вы очистите интервал следующим образом:

clearInterval(intervalId);

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

3. Замыкания

В JavaScript функции обладают особой способностью запоминать окружение, в котором они были созданы. Эта способность позволяет внутренним функциям обращаться к переменным из внешней (окружающей) функции даже после того, как внешняя функция завершила своё выполнение. Это явление известно как замыкание.

Причина

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

Пример

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

function createCountdown(start) {
let count = start;

return function() {
return count--;
};
}

let countdownFrom10 = createCountdown(10);

Здесь countdownFrom10 является замыканием. При каждом вызове она будет уменьшать переменную count на единицу. Поскольку внутренняя функция сохраняет ссылку на count, переменная count не будет собрана в мусор, даже если нигде в программе не будет другой ссылки на функцию createCountdown.

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

Предотвращение

Хотя замыкания являются мощной функцией и часто необходимы, важно знать, на что они ссылаются. Убедитесь, что вы:

  1. Захватывайте только то, что вам нужно: Избегайте захвата больших объектов или структур данных в замыканиях, если в этом нет необходимости.
  2. Разрывайте ссылки, когда закончите: Если замыкание используется в качестве слушателя событий или обратного вызова и больше не нужно, удалите слушатель или аннулируйте обратный вызов, чтобы разорвать ссылки на замыкание.

Модификация приведённого выше примера для намеренного разрушения ссылки:

function createCountdown(start) {
let count = start;

return function() {
return count--;
};
}

let countdownFrom10 = createCountdown(10);

countdownFrom10 = null;

4. Слушатели событий

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

Причина

Когда вы прикрепляете слушатель событий к элементу DOM, это создаёт привязку между функцией (часто замыканием) и этим элементом. Если элемент удаляется или слушатель больше не нужен, но слушатель не удаляется явно, связанная с ним функция остаётся в памяти, потенциально сохраняя другие переменные и элементы, на которые она ссылается.

Пример

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

const button = document.getElementById('myButton');

button.addEventListener('click', function() {
console.log('Button was clicked!');
});

Теперь, позже, в приложении вы решите удалить кнопку из DOM:

button.remove();

Даже если кнопка удалена из DOM, функция слушателя события все равно сохраняет ссылку на неё. Это означает, что кнопка не будет собрана в мусор, что приведёт к утечке памяти.

Предотвращение

Ключевым моментом является активное управление слушателями событий:

Явное удаление: Всегда удаляйте слушателей событий с помощью функции removeEventListener() перед удалением элемента или когда они больше не нужны.

Однократное использование: Если вы знаете, что событие потребуется только один раз, то при добавлении слушателя можно использовать опцию { once: true }.

Модификация приведённого выше примера для корректного управления:

const button = document.getElementById('myButton');

function handleClick() {
console.log('Button was clicked!');
}

button.addEventListener('click', handleClick);

// Позже в коде, когда мы закончим работу с кнопкой:
button.removeEventListener('click', handleClick);
button.remove();

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

5. Отделённые элементы DOM

Объектная модель документа (DOM) представляет собой иерархическое представление всех элементов на веб-странице. Когда вы изменяете DOM, например, удаляете элемент, но при этом сохраняете ссылку на него в JavaScript, вы создаёте так называемый Отсоединённый элемент DOM. Такие элементы больше не видны, но их нельзя собрать в мусор, поскольку на них по-прежнему ссылается ваш код.

Причина

Отсоединённые элементы DOM создаются, когда элементы удаляются из DOM, но на них остаются ссылки JavaScript. Эти ссылки не позволяют сборщику мусора освободить память, занимаемую этими элементами.

Пример

Допустим, у вас есть список элементов, и вы решили удалить один из них:

let listItem = document.getElementById('itemToRemove');
listItem.remove();

Теперь, даже если вы удалили элемент listItem из DOM, у вас все ещё есть ссылка на него в переменной listItem. Это означает, что реальный элемент все ещё находится в памяти, отделённый от DOM, но занимающий место.

Предотвращение

Для предотвращения утечек памяти из отделённых элементов DOM:

Нуллификация ссылок: После удаления элемента DOM следует нуллифицировать все ссылки на него:

listItem.remove();
listItem = null;

Ограничение количества ссылок на элементы: Храните ссылки на элементы DOM только в тех случаях, когда они абсолютно необходимы. Если вам нужно выполнить только одну операцию над элементом, то нет необходимости хранить на него долговременную ссылку.

Модификация приведённого выше примера для предотвращения утечек памяти:

let listItem = document.getElementById('itemToRemove');
listItem.remove();
listItem = null; // Разрушение ссылки на отсоединённый DOM-элемент

Нуллифицируя ссылку на listItem после удаления его из DOM, мы гарантируем, что сборщик мусора сможет вернуть память, занятую удалённым элементом.

6. Вебсокеты и внешние соединения

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

Причина

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

Пример

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

let socket = new WebSocket('ws://example.com/updates');

socket.onmessage = function(event) {
console.log(`Received update: ${event.data}`);
};

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

Предотвращение

Необходимо активно управлять соединениями вебсокетов:

Явное закрытие: Всегда закрывайте вебсокет соединения с помощью метода close(), когда они больше не нужны:

socket.close();

Нуллификация ссылок: После закрытия вебсокет соединения следует нуллифицировать все связанные с ним ссылки, чтобы помочь сборщику мусора:

socket.onmessage = null;
socket = null;

Обработка ошибок: Реализуйте обработку ошибок для обнаружения потери или неожиданного завершения соединения и очистки связанных с ним ресурсов.

В продолжении примера, правильное управление будет выглядеть следующим образом:

let socket = new WebSocket('ws://example.com/updates');

socket.onmessage = function(event) {
console.log(`Received update: ${event.data}`);
};

// Позже в коде, когда соединение больше не нужно:
socket.close();
socket.onmessage = null;
socket = null;

Инструменты для борьбы с утечками памяти

Одним из лучших способов предотвращения утечек памяти является их раннее обнаружение. Здесь лучшим помощником могут стать инструменты разработчика браузера, особенно Chrome DevTools. Особенно полезна вкладка "Память", позволяющая контролировать использование памяти, делать снимки и отслеживать изменения во времени.

Общие рекомендации

Помните, что, как и в реальной жизни, профилактика лучше лечения. Если быть внимательным и предусмотрительным, то можно гарантировать, что ваши JavaScript-приложения будут работать без сбоев из-за утечек памяти.

Дополнительные материалы

Предыдущая Статья

Новое в Symfony 6.4: Профилировщик команд

Следующая Статья

Регулярные выражения в JavaScript