5 распространенных ошибок памяти в веб-приложениях на JavaScript
Понимание того, как память и сборка мусора работают в JavaScript, крайне важно. Поскольку это происходит автоматически, у некоторых разработчиков создается ложное впечатление, что им не нужно уметь в этом разбираться. Об ошибках памяти в JavaScript и советах по предотвращению ее утечек рассказывает в своем материале фронтенд-разработчик Хосе Гранха. Делимся им с вами.
JavaScript не предоставляет никаких примитивов для управления памятью. Ее освобождение и управление осуществляется посредством JavaScript VM. Этот процесс называется сборкой мусора.
Но как быть точно уверенным, что сборщик мусора работает корректно? Что нам известно о нем:
- Во время процесса выполнение скрипта приостанавливается.
- Освобождает память для недоступных ресурсов.
- Он недетерминирован.
- Проверяет всю память не за один раз, а за несколько циклов.
- Непредсказуем. Включается в работу тогда, когда это необходимо.
Так стоит ли нам беспокоиться о ресурсах и распределении памяти? Конечно же нет. Но необходимо быть предельно осторожным, чтобы не допустить утечки памяти.
Что такое утечка памяти?
Утечка памяти — это выделенный фрагмент памяти, которую программа не может контролировать.
То, что JavaScript предоставляет вам процесс сборки мусора, не означает, что вы застрахованы от утечек памяти. Чтобы сборщик мусора работал корректно, на объект нельзя ссылаться в другом месте.
Утечка памяти может привести к более частому запуску сборщика мусора. Поскольку этот процесс препятствует запуску скриптов, он может замедлить работу вашего приложения, привести к зависаниям и сбоям. И конечный пользователь точно не будет этому рад.
Как же предотвратить утечку памяти в приложении? Все просто: избегайте удержания ненужных ресурсов. Давайте рассмотрим наиболее распространенные сценарии, в которых это может произойти.
1. Timer Listeners
Взглянем на таймер setInterval. Это одна из часто используемых функций Web API.
«Метод setInterval () в интерфейсах Window и Worker многократно вызывает функцию или выполняет сниппет кода с фиксированной временной задержкой между каждым вызовом. Он возвращает ID интервала, поэтому вы можете удалить его позже, вызвав clearInterval (). Этот метод описан в WindowOrWorkerGlobalScope» — 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 DevTools:
При нажатии на кнопку retry выделяется все больше и больше памяти. Это означает, что ранее выделенная память не была освобождена. Таймеры интервалов все еще работают, а не заменяются.
Как это пофиксить? Возвращаемый setInterval — это ID интервала, который мы можем использовать для отмены интервала. В этом конкретном сценарии мы можем вызвать 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;
Теперь при использовании setInterval, вы можете сделать так:
const handleTimeout = () => ...; useTimeout(100, handleTimeout);
Используйте этот хук, управляемый абстракцией, не беспокоясь об утечке памяти.
2. Event Listeners
Web API предоставляет множество event listeners. Мы уже рассмотрели 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 listener этот код добавит еще один 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. Observers
Observers — это Web API функция браузера, которая неизвестна многим разработчикам. Если вы хотите проверить изменения в видимости или размере HTML-элементов, то для этой цели она очень эффективна.
Давайте проверим, например, Intersection Observer API:
«Intersection Observer API обеспечивает асинхронное наблюдение за изменениями пересечения целевого элемента с вышестоящим элементом или с viewport документом верхнего уровня» — MDN Web Docs
Используйте Observer ответственно. Как только закончите наблюдение за объектом, отмените процесс мониторинга.
Посмотрите на код:
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]);
Код выглядит хорошо. Однако что происходит с observer после размонтирования компонента? Он не очистится, поэтому у вас будет утечка памяти. Как можно это решить? Просто используйте метод 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]);
Теперь при отключении компонента observer будет отключен.
4. Объект Window
Добавление объектов в Window — распространенная ошибка. В некоторых сценариях его может быть трудно найти, особенно если вы используете кейворд this из Window Execution context.
Посмотрим на пример:
function addElement(element) { if (!this.stack) { this.stack = { elements: [] } } this.stack.elements.push(element); }
Все выглядит безобидно, но это зависит от того, из какого контекста вы вызываете addElement. Если вы вызовете addElement из Window Context, вы начнете видеть скопление элементов.
Другая проблема может заключаться в ошибочном определении глобальной переменной:
var a = 'example 1'; // scoped to the place where var was created b = 'example 2'; // added to the Window object
Чтобы избежать таких проблем, всегда выполняйте JavaScript в строгом режиме:
"use strict"
Используя строгий режим, вы даете понять компилятору JavaScript, что хотите защитить себя от ошибок.
Вы по-прежнему можете использовать Window, когда это необходимо.
Как строгий режим повлияет на предыдущие примеры:
- В 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 не является причиной утечки памяти. Скорее, это вызвано непроизвольным сохранением памяти со стороны разработчика. Пока код аккуратный и мы не забываем убирать ненужные объекты, утечек не будет.
Рекомендуется периодически запускать инструменты профилирования браузера в приложении. Это единственный способ убедиться, что все в порядке и нет утечки памяти. Вкладка Chrome Developer performance — это место, с которого можно начать обнаружение аномалий. Обнаружив проблему, вы можете глубже изучить ее с помощью вкладки profiler, сделав снапшоты и сравнив их.
Иногда мы тратим время на оптимизацию методов, но забываем о том, что память играет большую роль в производительности нашего приложения.
Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: