Пять распространенных ошибок памяти в JavaScript
- понедельник, 18 июля 2022 г. в 00:45:38
Или советы по предотвращению утечек памяти в ваших веб-приложениях.
В JavaScript нет примитивов управления памятью. Вместо этого память управляется виртуальной машиной JavaScript посредством процесса восстановления памяти, который известен как Garbage Collection.
Но если мы не можем заставить его работать, как мы узнаем, что он будет работать правильно? Что мы знаем об этом? Выполнение скрипта приостанавливается во время процесса — это освобождает память для недоступных ресурсов. Скрипт недетерминирован и не будет проверять всю память за один раз, а будет выполняться в несколько циклов. Этот процесс непредсказуем и будет выполняться при необходимости.
Значит ли это, что нам не нужно беспокоиться о выделении ресурсов и памяти? Конечно, нет. Если вы не будете осторожны, у вас будут утечки памяти.
Перевод. Источник — Хосе Гранха.
Утечка памяти — это выделенная часть памяти, которую программное обеспечение не может восстановить.
Если JavaScript предоставляет вам процесс сборки мусора, это не означает, что вы защищены от утечек памяти. Чтобы иметь право на сборку мусора, на объект не должно быть ссылок в другом месте. Если вы храните ссылки на неиспользуемые ресурсы, вы предотвратите их «нераспределение», так называемое непреднамеренное удержание памяти.
Утечка памяти может привести к более частым запускам сборщика мусора. Поскольку этот процесс не позволит запускать скрипты, то может замедлить работу вашего веб-приложения, что будет замечено пользователем. Это может даже привести к сбоям.
Как предотвратить утечку памяти в нашем веб-приложении? Мы должны избегать сохранения ненужных ресурсов. Давайте рассмотрим распространенные сценарии, в которых это может произойти.
Давайте посмотрим на 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
, не беспокоясь о утечке памяти, все это управляется абстракцией.
Веб-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);
Наблюдатели — это функция веб-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]);
Теперь мы можем быть уверены, что при отключении компонента наш наблюдатель будет отключен.
Добавление объектов в окно — распространенная ошибка. В некоторых сценариях его может быть трудно найти, особенно если вы используете ключевое слово 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
.
Узлы 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
, сделав снимки и сравнив их.
Иногда мы тратим время на оптимизацию методов и забываем, что память играет большую роль в производительности нашего веб-приложения.