Недостаточная забота об управлении памятью, как правило, не приводит к серьезным последствиям, когда речь идет о «старомодных» веб-страницах. Пока пользователь перемещается по ссылкам и загружает новые страницы, информация о странице удаляется из памяти при каждой загрузке.
Повышение количества SPA (Single Page Application - одностраничное приложение) побуждает нас уделять больше внимания хорошим методам кодирования, связанным с памятью. Использование SPA подразумевает нахождение на одной странице в течение гораздо более длительного времени. Если страница, которая никогда не перезагружается полностью, начинает постепенно использовать все больше и больше памяти, это может серьезно повлиять на производительность и даже вызвать сбой вкладки браузера.
В этой статье мы рассмотрим шаблоны программирования, которые вызывают утечки памяти в JavaScript, и объясним, как улучшить управление памятью.
Что такое утечка памяти и как ее обнаружить?
Браузер хранит объекты в памяти, в то время как они могут быть доступны из корня скрипта через цепочку ссылок. Сборщик мусора (Garbage Collector) - это фоновый процесс в движке JavaScript, который идентифицирует недоступные объекты, удаляет их и освобождает основную память.
Утечка памяти происходит, когда объект в памяти, который должен быть очищен в цикле сборки мусора, остается доступным из корня через непреднамеренную ссылку из другого объекта. Хранение избыточных объектов в памяти приводит к чрезмерному использованию памяти внутри приложения и может привести к снижению производительности и снижению производительности.
Как выяснить, что в нашем коде есть утечки памяти?
Ну, утечки памяти хитрые, их часто трудно заметить и локализовать. Утечка кода JavaScript ни в коем случае не считается ошибкой, поэтому браузер тоже не выдает никакой ошибки при своем запуске. Если мы заметим, что производительность нашей страницы постепенно ухудшается, встроенные в браузер инструменты могут помочь нам определить, существует ли утечка памяти, и какие объекты вызывают ее.
Самый быстрый способ проверки использования памяти - взглянуть на диспетчеры задач браузера (не путать с диспетчером задач операционной системы). Они предоставляют нам обзор всех вкладок и процессов, запущенных в настоящее время в браузере. Чтобы открыть диспетчер задач Chrome, нажмите Shift + Esc в Linux и Windows, а в диспетчер Firefox введите about:performance в адресной строке.
Помимо прочего, эти инструменты позволяют нам видеть объем памяти JavaScript каждой вкладки. Если наш сайт просто сидит и ничего не делает, но, тем не менее, использование памяти JavaScript постепенно увеличивается, есть большая вероятность, что у нас происходит утечка памяти.
Инструменты разработчика (Developer Tools) предоставляют более продвинутые методы управления памятью. С помощью инструмента производительности Chrome мы можем визуально анализировать производительность страницы во время ее работы. Некоторые шаблоны типичны для утечек памяти, например, схема увеличения использования динамической памяти, показанная ниже.
Помимо этого, и Chrome, и Firefox Developer Tools имеют отличные возможности для дальнейшего изучения использования памяти с помощью инструмента памяти. Сравнение последовательных снимков показывает нам, где и сколько памяти было выделено между двумя снимками, а также дает дополнительные сведения, помогающие нам идентифицировать проблемные объекты в коде.
Общие источники утечек памяти в коде JavaScript
Поиск причин утечки памяти - это на самом деле поиск шаблонов программирования, которые могут «обмануть» нас, чтобы сохранить ссылки на объекты, которые иначе были бы квалифицированы для сборки мусора. Ниже приведен полезный список мест в коде, которые более подвержены утечкам памяти и заслуживают особого внимания при управлении памятью.
1. Случайные глобальные переменные
Глобальные переменные всегда доступны из корня скрипта и никогда не будут собраны как мусор с помощью Garbage Collector. Некоторые ошибки вызывают утечку переменных из локальной области в глобальную область в нестрогом режиме:
- присвоение значения необъявленной переменной,
- использование ключевого слова
this
, которое указывает на глобальный объект.
1 2 3 4 5 6 7 | function createGlobalVariables() { leaking1 = 'I leak into the global scope'; // присвоение значения необъявленной переменной this.leaking2 = 'I also leak into the global scope'; // 'this' указывает на глобальный объект }; createGlobalVariables(); window.leaking1; // 'I leak into the global scope' window.leaking2; // 'I also leak into the global scope' |
Как это предотвратить: Строгий режим ("use strict"
) предотвратит случайные утечки, так как код из примера выдаст ошибку.
2. Замыкания
Переменные в локальной области любой функции будут очищены после того, как функция вышла из стека вызовов, и если за пределами функции не осталось ссылок, указывающих на них. Замыкание будет поддерживать переменные, на которые имеются ссылки, и живые, хотя функция завершила выполнение, а ее контекст выполнения и переменная среда давно исчезли.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | function outer() { const potentiallyHugeArray = []; return function inner() { potentiallyHugeArray.push('Hello'); // функция inner замкнута на переменной potentiallyHugeArray console.log('Hello'); }; }; const sayHello = outer(); // содержит определение/вызов функции inner function repeat(fn, num) { for (let i = 0; i < num; i++){ fn(); } } repeat(sayHello, 10); // каждый вызов sayHello помещает еще один элемент 'Hello' в массив potentiallyHugeArray // теперь представьте себе, что будет при вызове repeat(sayHello, 100000) |
В этом примере массив potentiallyHugeArray
никогда не возвращается ни из одной из функций, поэтому и не может быть получен, тем не менее его размер может бесконечно увеличиваться в зависимости от того, сколько раз мы вызываем функцию inner()
. Т.е. такое обращение с переменной внутри многократно вызываемой функции не приводит к каким-либо результатам, зато сильно нагружает память.
Как это предотвратить: замыкания являются неизбежной и неотъемлемой частью JavaScript, поэтому важно:
- понять, когда замыкание было создано и какие объекты оно удерживает,
- понять ожидаемую продолжительность жизни и использования замыкания (особенно если такая функция используется в качестве обратного вызова - callback-функции).
3. Таймеры
Наличие setTimeout
или setInterval
, ссылающихся на некоторый объект в функции обратного вызова, является наиболее распространенным способом предотвращения сборки мусора. Если мы установим повторяющийся таймер в нашем коде (мы можем заставить setTimeout
вести себя как setInterval
, т.е. сделать его рекурсивным), тогда ссылка на объект из обратного вызова таймера будет оставаться активной до тех пор, пока обратный вызов будет существовать.
В приведенном ниже примере объект данных может быть собран мусором только после очистки таймера. Поскольку у нас нет ссылки на setInterval
, его нельзя очистить, а data.hugeString
сохраняется в памяти до тех пор, пока приложение не остановится, хотя никогда не используется.
1 2 3 4 5 6 7 8 9 10 11 | function setCallback() { const data = { counter: 0, hugeString: new Array(100000).join('x') }; return function cb() { data.counter++; // объект data сейчас является частью области видимости callback-функции console.log(data.counter); } } setInterval(setCallback(), 1000); // как это остановить? |
Как это предотвратить: особенно если продолжительность жизни функции обратного вызова не определена или неопределенна:
- быть осведомленным об объектах, на которые ссылается обратный вызов таймера,
- используя дескриптор, связанный с таймера, отменить вызов таймера (интервала) методом
clearTimer()
(clearInterval()
) при необходимости.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | function setCallback() { // 'unpacking' the data object let counter = 0; const hugeString = new Array(100000).join('x'); // удаляется при возврате setCallback return function cb() { counter++; // только переменная counter является частью области видимости функции-callback-а console.log(counter); } } const timerId = setInterval(setCallback(), 1000); // сохраняем дескриптор вызова в переменную // некоторые действия ... clearInterval(timerId); // останавливаем вызов таймера, например, по клику на кнопке или в условной конструкции if ... else |
4. Слушатели событий
Активный слушатель событий, добавленный с помощью метода addEventListener()
, предотвратит сбор мусора всех переменных, существующих в его области. После однократного добавления слушатель событий будет действовать до тех пор, пока:
- не будет явно удален с помощью метода
removeEventListener()
- связанный со слушателем события элемент DOM будет удален.
Для некоторых типов событий ожидается, что они будут сохраняться до тех пор, пока пользователь не покинет страницу, например, для кнопок, которые должны нажиматься несколько раз. Однако иногда мы хотим, чтобы слушатель событий выполнялся определенное количество раз.
1 2 3 4 | const hugeString = new Array(100000).join('x'); document.addEventListener('keyup', function() { // анонимная функция function, которую невозможно удалить doSomething(hugeString); // переменная hugeString теперь все время находится в области видимости callback-функции }); |
В приведенном выше примере в качестве слушателя событий используется анонимная функция, а это означает, что ее нельзя удалить с помощью метода removeEventListener()
. Точно так же элемент document
не может быть удален, поэтому в памяти все время будет находится та переменная(-ые), которую он держит в своей области действия, даже в том случае, если нам нужно было запустить его всего один раз.
Как это предотвратить: лучше всегда иметь возможность отменить регистрацию слушателя события, когда он больше не нужен, создав ссылку, указывающую на него, и передав ее в метод removeEventListener()
.
1 2 3 4 5 | function listener() { doSomething(hugeString); } document.addEventListener('keyup', listener); // используем ссылку на именованную функцию здесь... document.removeEventListener('keyup', listener); // ...и здесь |
В случае, если слушатель событий должен выполняться только один раз, addEventListener()
может принимать третий параметр, который является объектом, предоставляющим дополнительные параметры. Учитывая, что объект {once: true}
передается в качестве третьего параметра метода addEventListener()
, функция слушателя будет автоматически удалена после обработки события один раз.
1 2 3 | document.addEventListener('keyup', function listener() { doSomething(hugeString); }, {once: true}); // слушатель события будет удален после первого использования |
Немного изменим скрипт, чтобы вы могли попробовать сами:
1 2 3 4 5 6 7 | let hugeString = new Array(1000).join('x '); strOutput.textContent = hugeString; document.addEventListener('keypress',function (e) { let litera = String.fromCharCode(e.keyCode); hugeString = hugeString.replace(/x/ig, litera); strOutput.textContent += hugeString; },{once: true}); |
Теперь нажмите любую клавишу на клавиатуре и увидите, что только один раз буква x
заменилась на ту букву, что вы нажали. Сколько бы раз вы не нажимали клавиши повторно, слушатель события реагировать не будет.
5. Кеш
Если мы продолжим добавлять память в кеш, не избавляясь от неиспользуемых объектов и без какой-либо логики, ограничивающей размер, кеш может расти бесконечно.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | let user_1 = { name: "Peter", id: 12345 }; let user_2 = { name: "Mark", id: 54321 }; const mapCache = new Map(); function cache(obj){ if (!mapCache.has(obj)){ const value = `${obj.name} has an id of ${obj.id}`; mapCache.set(obj, value); return [value, 'computed']; } return [mapCache.get(obj), 'cached']; } cache(user_1); // ['Peter has an id of 12345', 'computed'] cache(user_1); // ['Peter has an id of 12345', 'cached'] cache(user_2); // ['Mark has an id of 54321', 'computed'] console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") user_1 = null; // удаление неактивного пользователя // Сборщик мусора (Garbage Collector) console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // первая запись еще в кеше |
В приведенном выше примере кэш все еще удерживает объект user_1
. Поэтому нам необходимо дополнительно очистить кеш от записей, которые никогда не будут использованы повторно.
Возможное решение: чтобы обойти эту проблему, мы можем использовать WeakMap. Это структура данных со слабо удерживаемыми ссылками на ключи, которая принимает в качестве ключей только объекты. Если мы используем объект в качестве ключа, и это единственная ссылка на этот объект - соответствующая запись будет удалена из кэша и собрана сборщиком мусора. В следующем примере после обнуления объекта user_1
связанная запись автоматически удаляется из WeakMap после следующей сборки мусора.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | let user_1 = { name: "Peter", id: 12345 }; let user_2 = { name: "Mark", id: 54321 }; const weakMapCache = new WeakMap(); function cache(obj){ if (!weakMapCache.has(obj)) { const value = `${obj.name} has an id of ${obj.id}`; weakMapCache.set(obj, value); return [value, 'computed']; } return [weakMapCache.get(obj), 'cached']; } cache(user_1); // ['Peter has an id of 12345', 'computed'] cache(user_2); // ['Mark has an id of 54321', 'computed'] console.log(weakMapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321"} user_1 = null; // удаление неактивного пользователя // Сборщик мусора (Garbage Collector) console.log(weakMapCache.has(user_1)); // false - первая запись уходит в мусор console.log(weakMapCache.has(user_2)); // true - 2-й пользователь остается |
6. Отдельные элементы DOM
Если узел DOM имеет прямые ссылки из JavaScript, это предотвратит сбор мусора даже после удаления узла из дерева DOM.
В следующем примере мы создали элемент div
и добавили его в document.body
. Метод removeChild()
не работает должным образом, и снимок кучи покажет отдельный HTMLDivElement
, так как есть переменная, все еще указывающая на div
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | function createElement() { const div = document.createElement('div'); div.id = 'detached'; return div; } // эта константа будет продолжать ссылаться на элемент DOM даже после вызова deleteElement () const detachedDiv = createElement(); document.body.appendChild(detachedDiv); function deleteElement() { document.body.removeChild(document.getElementById('detached')); } deleteElement(); // Снимок покажет отключенный div#detached |
Как это предотвратить? Одним из возможных решений является перемещение ссылок DOM в локальную область функции. В приведенном ниже примере переменная, указывающая на элемент DOM, удаляется после завершения функции appendElement()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | function createElement() {...} // то же, что и выше // Ссылки DOM находятся внутри области действия функции function appendElement() { const detachedDiv = createElement(); document.body.appendChild(detachedDiv); } appendElement(); function deleteElement() { document.body.removeChild(document.getElementById('detached')); } deleteElement(); // в снимке нет элемента div#detached |
Вывод
При работе с нетривиальными приложениями выявление и устранение проблем с памятью JavaScript может превратиться в чрезвычайно сложную задачу. По этой причине неотъемлемой частью процесса управления памятью является понимание типичных источников утечек памяти, чтобы предотвратить их в первую очередь. В конце концов, когда речь идет о памяти и производительности, на карту поставлено взаимодействие с пользователем, и это самое главное.
Источник: Causes of Memory Leaks in JavaScript and How to Avoid Them