Или советы по предотвращению утечек памяти в ваших веб-приложениях.
В JavaScript нет примитивов управления памятью. Вместо этого память управляется виртуальной машиной JavaScript посредством процесса восстановления памяти, который известен как Garbage Collection.
Но если мы не можем заставить его работать, как мы узнаем, что он будет работать правильно? Что мы знаем об этом? Выполнение скрипта приостанавливается во время процесса — это освобождает память для недоступных ресурсов. Скрипт недетерминирован и не будет проверять всю память за один раз, а будет выполняться в несколько циклов. Этот процесс непредсказуем и будет выполняться при необходимости.
Значит ли это, что нам не нужно беспокоиться о выделении ресурсов и памяти? Конечно, нет. Если вы не будете осторожны, у вас будут утечки памяти.
Перевод. Источник — Хосе Гранха.
Что такое утечка памяти?
Утечка памяти — это выделенная часть памяти, которую программное обеспечение не может восстановить.
Если JavaScript предоставляет вам процесс сборки мусора, это не означает, что вы защищены от утечек памяти. Чтобы иметь право на сборку мусора, на объект не должно быть ссылок в другом месте. Если вы храните ссылки на неиспользуемые ресурсы, вы предотвратите их «нераспределение», так называемое непреднамеренное удержание памяти.
Утечка памяти может привести к более частым запускам сборщика мусора. Поскольку этот процесс не позволит запускать скрипты, то может замедлить работу вашего веб-приложения, что будет замечено пользователем. Это может даже привести к сбоям.
Как предотвратить утечку памяти в нашем веб-приложении? Мы должны избегать сохранения ненужных ресурсов. Давайте рассмотрим распространенные сценарии, в которых это может произойти.
#1. Прослушиватели таймера
Давайте посмотрим на setInterval
таймер. Это часто используемая функция веб-API.
“
setInterval()
– это метод, предлагаемый в интерфейсахWindow
andWorker
. Многократно вызывает функцию или выполняет фрагмент кода с фиксированной временной задержкой между каждым вызовом. Он возвращает идентификатор интервала, который однозначно идентифицирует интервал, поэтому вы можете удалить его позже, вызвавclearInterval()
. Этот метод определяетсяWindowOrWorkerGlobalScope
mixin ”. — MDN Web Docs
Давайте создадим компонент, который вызывает функцию обратного вызова, чтобы сигнализировать, что это сделано после x
циклов. Я использую React для моего конкретного примера, но логика распространяется и на другие фреймворки.
import React, { useRef } from 'react';
const Timer = ({ cicles, onFinish }) => {
const currentCicles = useRef(0);
setInterval(() => {
if (currentCicles.current >= cicles) {
onFinish();
return;
}
currentCicles.current++;
}, 500);
return (
<div>Loading ...</div>
);
}
export default Timer
На первый взгляд кажется, что все в порядке. Давайте создадим компонент, который запускает этот таймер, и проанализируем его производительность памяти:
import React, { useState } from 'react';
import styles from '../styles/Home.module.css'
import Timer from '../components/Timer';
export default function Home() {
const [showTimer, setShowTimer] = useState();
const onFinish = () => setShowTimer(false);
return (
<div className={styles.container}>
{showTimer ? (
<Timer cicles={10} onFinish={onFinish} />
): (
<button onClick={() => setShowTimer(true)}>
Retry
</button>
)}
</div>
)
}
После нескольких нажатий на кнопку retry
, посмотрим как используется память с помощью инструментов разработчика Chrome:
Видно, как по мере нажатия кнопки выделяется все больше и больше памятиretry
. Это означает, что предыдущая выделенная память не была освобождена. Таймеры интервалов все еще работают, а не заменяются.
Как исправить? Возвращаемый идентификатор setInterval
мы можем использовать для отмены интервала. В этом конкретном сценарии мы можем вызвать clearInterval
после размонтирования компонента.
useEffect(() => {
const intervalId = setInterval(() => {
if (currentCicles.current >= cicles) {
onFinish();
return;
}
currentCicles.current++;
}, 500);
return () => clearInterval(intervalId);
}, [])
Иногда выявить эти проблемы в коде сложно. Лучшая практика — создавать абстракции, в которых вы можете управлять всей этой сложностью.
Поскольку мы используем React, мы можем обернуть всю эту логику в пользовательский хук:
import { useEffect } from 'react';
export const useTimeout = (refreshCycle = 100, callback) => {
useEffect(() => {
if (refreshCycle <= 0) {
setTimeout(callback, 0);
return;
}
const intervalId = setInterval(() => {
callback();
}, refreshCycle);
return () => clearInterval(intervalId);
}, [refreshCycle, setInterval, clearInterval]);
};
export default useTimeout;
Теперь, когда вам нужно использовать a setInterval
, вы можете сделать:
const handleTimeout = () => ...;
useTimeout(100, handleTimeout);
Теперь вы можете использовать этот хукuseTimeout
, не беспокоясь о утечке памяти, все это управляется абстракцией.
#2. Прослушиватели событий
Веб-API предоставляет множество прослушивателей событий, к которым вы можете подключиться. Ранее мы рассмотрели setTimeout
. Теперь рассмотрим addEventListener
.
Давайте создадим функциональность сочетания клавиш для нашего веб-приложения. Поскольку у нас разные функции на разных страницах, мы создадим разные функции быстрого доступа:
function homeShortcuts({ key}) {
if (key === 'E') {
console.log('edit widget')
}
}
// user lands on home and we execute
document.addEventListener('keyup', homeShortcuts);
// user does some stuff and navigates to settings
function settingsShortcuts({ key}) {
if (key === 'E') {
console.log('edit setting')
}
}
// user lands on home and we execute
document.addEventListener('keyup', settingsShortcuts);
Кажется, что все хорошо, за исключением того, что мы не очистили предыдущуюkeyup
, когда выполняли вторуюaddEventListener
. Вместо замены нашего слушателя keyup
, этот код будет добавлять другой callback
. Это означает, что при нажатии клавиши запускаются обе функции.
Чтобы очистить предыдущий обратный вызов, нам нужно использовать removeEventListener
.
document.removeEventListener(‘keyup’, homeShortcuts);
Давайте реорганизуем код, чтобы предотвратить это нежелательное поведение:
function homeShortcuts({ key}) {
if (key === 'E') {
console.log('edit widget')
}
}
// user lands on home and we execute
document.addEventListener('keyup', homeShortcuts);
// user does some stuff and navigates to settings
function settingsShortcuts({ key}) {
if (key === 'E') {
console.log('edit setting')
}
}
// user lands on home and we execute
document.removeEventListener('keyup', homeShortcuts);
document.addEventListener('keyup', settingsShortcuts);
#3. Наблюдатели
Наблюдатели — это функция веб-API браузера, которая неизвестна многим разработчикам. Они эффективны, если вы хотите проверить изменения в видимости или размере элементов HTML.
Давайте, например, проверим API Intersection Observer:
«API Intersection Observer предоставляет способ асинхронного наблюдения за изменениями в пересечении целевого элемента с элементом-предком или с областью просмотра документа верхнего уровня» — MDN Web Docs
Каким бы мощным он ни был, вы должны использовать его ответственно. Как только вы закончите наблюдение за объектом, вам нужно отменить процесс мониторинга.
Давайте посмотрим на код:
const ref = ...
const visible = (visible) => {
console.log(`It is ${visible}`);
}
useEffect(() => {
if (!ref) {
return;
}
observer.current = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) {
visible(true);
} else {
visbile(false);
}
},
{ rootMargin: `-${header.height}px` },
);
observer.current.observe(ref);
}, [ref]);
Приведенный выше код выглядит нормально. Однако, что происходит с наблюдателем после размонтирования компонента? Он не будет очищен, поэтому у вас будет утечка памяти. Как мы можем это решить? Просто используя disconnect
метод:
const ref = ...
const visible = (visible) => {
console.log(`It is ${visible}`);
}
useEffect(() => {
if (!ref) {
return;
}
observer.current = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) {
visible(true);
} else {
visbile(false);
}
},
{ rootMargin: `-${header.height}px` },
);
observer.current.observe(ref);
return () => observer.current?.disconnect();
}, [ref]);
Теперь мы можем быть уверены, что при отключении компонента наш наблюдатель будет отключен.
#4. Объект Window
Добавление объектов в окно — распространенная ошибка. В некоторых сценариях его может быть трудно найти, особенно если вы используете ключевое слово this
из контекста выполнения окна.
Давайте посмотрим на следующий пример:
function addElement(element) {
if (!this.stack) {
this.stack = {
elements: []
}
}
this.stack.elements.push(element);
}
Выглядит безобидно, но это зависит от того, из какого контекста вы вызываетеaddElement
. Если вызываете addElement
из контекста окна, вы начнете видеть, как накапливаются элементы.
Другой проблемой может быть определение глобальной переменной по ошибке:
var a = 'example 1'; // область действия ограничена местом, где был создан varb = 'example 2'; // добавлен в объект Window
Чтобы предотвратить такого рода проблемы, всегда выполняйте JavaScript в строгом режиме:
"use strict"
Используя строгий режим, вы намекаете компилятору JavaScript, что хотите защитить себя от такого поведения. Вы все равно можете использовать окно, когда оно вам нужно. Однако вы должны использовать его явным образом.
Как строгий режим повлияет на наши предыдущие примеры:
В
addElement
функцииthis
будет не определено при вызове из глобальной области.Если вы не укажете
const | let | var
в переменной, вы получите следующую ошибкуUncaught ReferenceError: b is not defined
.
#5. Хранение ссылок на DOM
Узлы DOM также зависимы от утечек памяти. Вы должны быть осторожны, чтобы не содержать ссылки на них. В противном случае сборщик мусора не сможет их очистить, поскольку они все еще доступны.
Давайте посмотрим небольшой пример кода, чтобы проиллюстрировать это:
const elements = [];
const list = document.getElementById('list');
function addElement() {
// clean nodes
list.innerHTML = '';
const divElement= document.createElement('div');
const element = document.createTextNode(`adding element ${elements.length}`);
divElement.appendChild(element);
list.appendChild(divElement);
elements.push(divElement);
}
document.getElementById('addElement').onclick = addElement;
Обратите внимание, что addElement
функция очищает list
div и добавляет к нему новый элемент как дочерний. Этот вновь созданный элемент добавляется в elements
массив.
При следующем выполнении addElement
, этот элемент будет удален из list
div. Однако он не будет иметь права на сборку мусора, поскольку он хранится в массиве elements
. Это делает его доступным. Это даст вам оценку Node
при каждом выполнении addElement
.
Давайте проверим функцию после нескольких выполнений:
На скриншоте выше мы можем видеть, как происходит утечка узлов. Как мы можем это исправить? Очистка массива elements
сделает их пригодными для сборки мусора.
Заключение
В этой статье мы рассмотрели наиболее распространенные способы утечки памяти. Понятно, что JavaScript не пропускает память сам по себе. Скорее, это вызвано непреднамеренным удержанием памяти со стороны разработчика. Пока код аккуратный, и мы не забываем убирать за собой, никаких утечек не произойдет.
Понимание того, как память и сборка мусора работают в JavaScript, обязательно. У некоторых разработчиков создается ложное впечатление, что, поскольку это происходит автоматически, им не нужно беспокоиться об этом.
Рекомендуется периодически запускать инструменты профилировщика браузера в вашем веб-приложении. Это единственный способ убедиться, что ничего не утекает и не остается позади. Вкладка разработчика Chrome performance
— это место, где можно начать обнаруживать некоторые аномалии. После того, как вы обнаружили проблему, вы можете углубиться в нее с помощью вкладки profiler
, сделав снимки и сравнив их.
Иногда мы тратим время на оптимизацию методов и забываем, что память играет большую роль в производительности нашего веб-приложения.
Комментарии (6)
ermouth
17.07.2022 23:21В JavaScript нет примитивов управления памятью.
Это не совсем так. Если для ресурса сгенерён сессионный URL, его надо освобождать явно.
victor-homyakov
19.07.2022 12:54Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer.
Это не управление памятью. Как и в остальных случаях (
clearTimeout
,removeEventListener
,disconnect
, зануление неиспользуемых ссылок на объекты), это лишь способ сказать сборщику мусора: "оно мне больше не нужно, когда будешь собирать мусор - забери себе".Примитивы управления памятью - это
malloc
/free
и им подобные.ermouth
19.07.2022 14:12В отличие от остальных случаев здесь не произойдёт освобождения, если ссылок больше нет, GC не сработает. Освобождать всегда надо явно, передавая строковый идентификатор.
От того, что это не похоже на malloc/free, оно не перестало быть ручным управлением памятью, и если вы не будете освобождать память, занятую такими объектами, память будет течь, и быстро, потому что такие ObjectURL делают обычно для блобов существенного размера, сгенерённых прямо на странице.
Alexandroppolus
20.07.2022 12:33В отличие от остальных случаев здесь не произойдёт освобождения, если ссылок больше нет
Это именно ссылка, только "внутренняя". Функция createObjectURL возвращает строку, которая по определению не может быть "ссылкой на объект" (в терминологии сборки мусора), значит, где-то внутри есть словарик, который держит ссылки на блобы и позволяет получить ссылку по строке как по ключу. А функция revokeObjectURL удаляет запись из этого словарика по ключу, вследствие чего блоб перестает быть достижимым и попадает в стандартную сборку мусора. Приблизительно так.
gwg605
18.07.2022 02:30+4Может все таки перевести статью на русский ;-) для примера: "Чтобы иметь право на сборку мусора, на объект не должно быть ссылок в другом месте"
Alexandroppolus
useTimeout не подхватывает изменение колбэка. Можно допилить так:
Изменение refreshCycle обрабатывается, но если он вдруг станет часто меняться, то до срабатывания таймера дело может не дойти. Хотя на практике многократное изменение интервала - редкий кейс и можно просто забить. Ну или можно упороться и таки сделать аккуратную замену старого на новое, если надо.