Пять распространенных ошибок памяти в JavaScript

в 11:58, , рубрики: javascript, React, ReactJS, утечки памяти

Или советы по предотвращению утечек памяти в ваших веб-приложениях.

В JavaScript нет примитивов управления памятью. Вместо этого память управляется виртуальной машиной JavaScript посредством процесса восстановления памяти, который известен как Garbage Collection.

Но если мы не можем заставить его работать, как мы узнаем, что он будет работать правильно? Что мы знаем об этом? Выполнение скрипта приостанавливается во время процесса — это освобождает память для недоступных ресурсов. Скрипт недетерминирован и не будет проверять всю память за один раз, а будет выполняться в несколько циклов. Этот процесс непредсказуем и будет выполняться при необходимости.

Значит ли это, что нам не нужно беспокоиться о выделении ресурсов и памяти? Конечно, нет. Если вы не будете осторожны, у вас будут утечки памяти.

Пять распространенных ошибок памяти в JavaScript - 1

Перевод. Источник — Хосе Гранха.

Что такое утечка памяти?

Утечка памяти — это выделенная часть памяти, которую программное обеспечение не может восстановить.

Если JavaScript предоставляет вам процесс сборки мусора, это не означает, что вы защищены от утечек памяти. Чтобы иметь право на сборку мусора, на объект не должно быть ссылок в другом месте. Если вы храните ссылки на неиспользуемые ресурсы, вы предотвратите их «нераспределение», так называемое непреднамеренное удержание памяти.

Утечка памяти может привести к более частым запускам сборщика мусора. Поскольку этот процесс не позволит запускать скрипты, то может замедлить работу вашего веб-приложения, что будет замечено пользователем. Это может даже привести к сбоям.

Как предотвратить утечку памяти в нашем веб-приложении? Мы должны избегать сохранения ненужных ресурсов. Давайте рассмотрим распространенные сценарии, в которых это может произойти.

#1. Прослушиватели таймера

Давайте посмотрим на setIntervalтаймер. Это часто используемая функция веб-API.

setInterval() – это метод, предлагаемый в интерфейсах WindowandWorker. Многократно вызывает функцию или выполняет фрагмент кода с фиксированной временной задержкой между каждым вызовом. Он возвращает идентификатор интервала, который однозначно идентифицирует интервал, поэтому вы можете удалить его позже, вызвавclearInterval(). Этот метод определяется WindowOrWorkerGlobalScopemixin ”. — 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:

Пять распространенных ошибок памяти в JavaScript - 2

Видно, как по мере нажатия кнопки выделяется все больше и больше памяти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функция очищает listdiv и добавляет к нему новый элемент как дочерний. Этот вновь созданный элемент добавляется в elementsмассив.

При следующем выполнении addElement, этот элемент будет удален из listdiv. Однако он не будет иметь права на сборку мусора, поскольку он хранится в массиве elements. Это делает его доступным. Это даст вам оценку Node при каждом выполнении addElement.

Давайте проверим функцию после нескольких выполнений:

Пять распространенных ошибок памяти в JavaScript - 3

На скриншоте выше мы можем видеть, как происходит утечка узлов. Как мы можем это исправить? Очистка массива elements сделает их пригодными для сборки мусора.

Заключение

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

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

Рекомендуется периодически запускать инструменты профилировщика браузера в вашем веб-приложении. Это единственный способ убедиться, что ничего не утекает и не остается позади. Вкладка разработчика Chrome performance— это место, где можно начать обнаруживать некоторые аномалии. После того, как вы обнаружили проблему, вы можете углубиться в нее с помощью вкладки profiler , сделав снимки и сравнив их.

Иногда мы тратим время на оптимизацию методов и забываем, что память играет большую роль в производительности нашего веб-приложения.

Автор:
alfredobob

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js