В этой статье мы познакомимся с эффективными приемами работы с DOM, которые помогут минимизировать потребление памяти и обеспечить высокую производительность ваших приложений.


Краткий обзор Document Object Model (DOM)


Когда HTML-документ отображается в браузере, созданное в памяти представление этого документа называется DOM (Document Object Model — объектная модель документа). Вот что можно увидеть в разделе "Elements" инструментов разработчика:





DOM можно представить в виде древовидной структуры, где каждый HTML-элемент на странице соответствует листу дерева. Существует целый набор API, специально предназначенных для модификации дерева элементов.


Краткий список распространенных DOM API:


  • querySelector()
  • querySelectorAll()
  • createElement()
  • getAttribute()
  • setAttribute()
  • addEventListener()
  • appendChild()

Все эти методы привязаны к document, поэтому их можно использовать следующим образом: const el = document.querySelector("#el");. Эти методы доступны на всех элементах DOM. Если есть ссылка на какой-либо элемент, можно применять эти методы непосредственно на элементе. При этом область их действия будет ограничена конкретным элементом.


const nav = document.querySelector("#site-nav");
const navLinks = nav.querySelectorAll("a");

Эти API доступны в браузерах для модификации структуры DOM. Однако они не будут работать в серверном JavaScript (например, в Node.js), если не использовать специальные эмуляторы DOM, например js-dom.


В современной веб-разработке большинство задач по работе с DOM возложено на фреймворки. Такие JavaScript-платформы, как React, Angular, Vue и Svelte, используют эти API под капотом. Продуктивность, обеспечиваемая фреймворками, часто перевешивает потенциальные выгоды ручной манипуляции DOM с точки зрения производительности. В этой статье мы демистифицируем этот момент.


Зачем вообще самостоятельно манипулировать DOM?


Основная причина — производительность. Использование фреймворков может приводить к появлению лишних структур данных и повторному рендерингу, что приводит к нежелательным эффектам, вроде зависаний. Это связано с тем, что сборщик мусора вынужден работать в усиленном режиме, пытаясь обработать большой объем кода.


Недостатком самостоятельной манипуляции DOM является более сложная реализация. Этот подход может усложнить код, поэтому зачастую лучше использовать фреймворки и абстракции вокруг DOM, нежели делать все вручную. Тем не менее, бывают ситуации, когда требуется дополнительная производительность.


Visual Studio Code основан на ручной манипуляции DOM


Visual Studio Code написан на чистом JavaScript, чтобы "находиться максимально близко к DOM". Такие масштабные проекты, как VS Code, должны иметь строгий контроль над производительностью. Поскольку основная функциональность VS Code реализована в виде плагинов, ядро системы должно быть компактным и оптимизированным. Это обеспечивает широкое распространение редактора.



Недавно Microsoft Edge отказался от использования React по той же причине.


Советы по эффективной манипуляции DOM


Используйте скрытие/показ элементов вместо создания новых


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


Отрендерите элемент на сервере и скрывайте/показывайте его с помощью CSS-класса (и соответствующего набора свойств CSS), например, el.classList.add('show') или el.style.display = 'block', вместо динамического создания и вставки элемента с помощью JavaScript. Работа со статичным DOM более эффективна, так как не требует сборки мусора и сложной логики на клиенте.


Избегайте динамического создания узлов DOM на клиенте, если это возможно.


Но не забывайте об ассистивных технологиях. Для случаев, когда требуется полное скрытие элемента как визуально, так и для ассистивных технологий, рекомендуется использовать display: none;. Но если необходимо скрыть элемент, но сохранить его для ассистивных технологий, используйте другие методы.


Используйте textContent вместо innerText для чтения содержимого элемента


Метод innerText хорош тем, что учитывает текущие стили элемента. Он "знает", скрыт элемент или нет, и возвращает текст только если элемент реально отображается. Проблема в том, что проверка стилей вызывает перекомпановку (reflow) и работает медленнее.


Чтение содержимого с помощью element.textContent происходит гораздо быстрее, чем с помощью element.innerText, поэтому по возможности используйте textContent.


Используйте insertAdjacentHTML вместо innerHTML


Метод insertAdjacentHTML гораздо быстрее, чем innerHTML, потому что он не уничтожает DOM перед вставкой элемента. Также этот метод является более гибким в том, где размещается новый HTML, например:


el.insertAdjacentHTML("afterbegin", html);
el.insertAdjacentHTML("beforeend", html);

Наиболее эффективное использование insertAdjacentElement или appendChild


Подход 1: использовать тег template для создания HTML-шаблона и appendChild для вставки нового HTML


Самый быстрый способ добавления полностью сформированных элементов DOM: создание HTML-шаблона с помощью тега <template>, а затем вставка этих элементов в DOM с помощью методов insertAdjacentElement или appendChild.


<template id="card_template">
  <article class="card">
    <h3></h3>
    <div class="card__body">
      <div class='card__body__image'></div>
      <section class='card__body__content'>
      </section>
    </div>
  </article>
</template>

function createCardElement(title, body) {
  const template = document.getElementById('card_template');
  const element = template.content.cloneNode(true).firstElementChild;
  const [cardTitle] = element.getElementsByTagName("h3");
  const [cardBody] = element.getElementsByTagName("section");
  [cardTitle.textContent, cardBody.textContent] = [title, body];
  return element;
}

container.appendChild(createCardElement(
  "Заголовок",
  "Содержимое"
))

Этот подход можно увидеть в действии в новом курсе "Front-End System Design", где Евгений с нуля создает бесконечную ленту социальных новостей.


Подход #2: Использовать createDocumentFragment с appendChild для пакетной (batch) вставки элементов


DocumentFragment — это упрощенный, "пустой" объект документа, который может содержать узлы DOM. Он не является частью основной DOM-структуры и прекрасно подходит для подготовки к добавлению в DOM нескольких элементов сразу.


const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
document.getElementById('myList').appendChild(fragment);

Данный подход позволяет избежать частых перекомпановок и обновлений, поскольку все новые элементы добавляются в DOM за один раз, а не по отдельности.


Управление ссылками при удалении узлов


При удалении DOM-узла важно следить, чтобы не осталось "висящих" ссылок, которые могут препятствовать сборке мусора и очистке данных. Для решения этой проблемы можно использовать WeakMap и WeakRef, чтобы избежать утечек памяти.


Привязка данных к DOM-узлам с помощью WeakMap


Использование WeakMap для привязки данных к DOM-узлам обеспечивает, что при удалении этих узлов, связанные с ними данные также будут автоматически удалены, не оставляя за собой никаких следов.


const DOMdata = { 'logo': 'Frontend Masters' };
const DOMmap = new WeakMap();
const el = document.querySelector(".FmLogo");
DOMmap.set(el, DOMdata);
console.log(DOMmap.get(el)); // { 'logo': 'Frontend Masters' }
el.remove(); // данные, привязанные к элементу, будут удалены сборщиком мусора

Использование WeakMap гарантирует, что ссылки на данные не будут "висеть", если элемент был удален.


Очистка после сборки мусора с помощью WeakRef


В следующем примере создается WeakRef (слабая ссылка) на узел DOM:


class Counter {
  constructor(element) {
    // Слабая ссылка на элемент
    this.ref = new WeakRef(element);
    this.start();
  }

  start() {
    if (this.timer) {
      return;
    }

    this.count = 0;

    const tick = () => {
      // Получаем элемент по слабой ссылке, если он еще существует
      const element = this.ref.deref();
      if (element) {
        console.log("Элемент содержится в памяти, обновление счетчика...")
        element.textContent = `Значение счетчика: ${++this.count}`;
      } else {
        // Элемент был удален
        console.log("Элемент удален, выполнена сборка мусора, очистка интервала...");
        this.stop();
        this.ref = null;
      }
    };

    tick();
    this.timer = setInterval(tick, 1000);
  }

  stop() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = 0;
    }
  }
}

const counterEl = document.getElementById("counter");
const counter = new Counter(counterEl);
const timerId = setTimeout(() => {
  counterEl.remove();
  clearTimeout(timerId);
}, 5000);

После удаления DOM-узла можно наблюдать за консолью, чтобы увидеть, когда происходит фактическая сборка мусора. Или же можно принудительно вызвать сборку мусора, используя вкладку "Performance" инструментов разработчика.





Это позволит убедиться, что все ссылки удалены и таймеры очищены.


Примечание: не стоит чрезмерно использовать WeakRef — этот "магический" метод имеет свои издержки. С точки зрения производительности лучше управлять ссылками напрямую, когда это возможно.


Удаление обработчиков событий


Вручную удаляйте обработчики событий с помощью removeEventListener


function handleClick() {
  console.log("Кнопка была нажата!");
  el.removeEventListener("click", handleClick);
}

const el = document.querySelector("#button");
// Добавляем обработчик клика на кнопку
el.addEventListener("click", handleClick);

Используйте параметр once для разовых и завершенных событий


То же самое поведение, что и в примере выше, может быть достигнуто с помощью параметра once:


el.addEventListener('click', handleClick, {
  once: true
});

Добавление третьего параметра в addEventListener с логическим значением, указывает, что обработчик события должен быть вызван не более одного раза после добавления. После вызова такой обработчик автоматически удаляется.


Используйте делегирование событий для привязки меньшего количества обработчиков


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


Вместо этого можно привязать обработку события ближе к корневому элементу. Поскольку события всплывают (поднимаются вверх) по DOM-дереву, можно использовать event.target (элемент, вызвавший событие) для перехвата и обработки события.


Метод matches(selector) проверяет только текущий элемент, поэтому его лучше применять к конечным узлам:


const rootEl = document.querySelector("#root");
rootEl.addEventListener('click', (event) => {
  // Если был выполнен клик по элементу с классом "target-element"
  if (event.target.matches('.target-element')) {
    doSomething();
  }
});

Весьма вероятно, что у вас будут элементы, подобные <div class="target-element"><p>...</p></div>. В данном случае необходимо использовать метод closest(element):


const rootEl = document.querySelector("#root");
rootEl.addEventListener('click', function (event) {
  // Если был выполнен клик по элементу с классом "target-element"
  // или его дочернему элементу
  if (event.target.closest('.target-element')) {
    doSomething();
  }
});

Этот подход позволяет не беспокоиться об управлении обработчиками событий при динамическом добавлении или удалении элементов.


Используйте AbortController, чтобы удалять группы обработчиков событий


const button = document.getElementById('button');
const controller = new AbortController();
const { signal } = controller;

button.addEventListener(
  'click',
  () => console.log('clicked!'),
  { signal }
);

// Удаляем обработчик
controller.abort();

AbortController позволяет удалять группы обработчиков:


const controller = new AbortController();
const { signal } = controller;

button.addEventListener('click', () => console.log('clicked!'), { signal });
window.addEventListener('resize', () => console.log('resized!'), { signal });
document.addEventListener('keyup', () => console.log('pressed!'), { signal });

// Удаляем все обработчики
controller.abort();

Спасибо Alex MacArthur за этот пример.


Профилирование и отладка


Для оптимизации производительности важно следить за размером DOM-дерева.


Вот пошаговая инструкция по использованию Chrome DevTools для профилирования памяти:


  1. Открываем Chrome DevTools.
  2. Переходим на вкладку "Memory".
  3. Выбираем "Heap snapshot" и нажимаем "Take snapshot".
  4. Выполняем необходимые действия с DOM-элементами.
  5. Делаем еще один снимок памяти.
  6. Сравниваем снимки, чтобы выявить изменения в использовании памяти.




Ключевые аспекты, на которые следует обращать внимание:


  • удаленные, но удерживаемые в памяти DOM-элементы
  • большие массивы или объекты, которые не очищаются должным образом
  • увеличение потребления памяти со временем (признак потенциальной утечки памяти)

Дополнительно можно отслеживать динамику потребления памяти с помощью вкладки "Performance":


  1. Переходим на вкладку "Performance".
  2. Отмечаем "Memory" в списке настроек.
  3. Нажимаем "Record" для начала записи.
  4. Выполняем необходимые операции с DOM.
  5. Останавливаем запись и анализируем полученный график памяти.

Визуализация динамики потребления памяти помогает своевременно обнаруживать и устранять утечки, а также оптимизировать использование памяти при манипуляциях с DOM.


Анализ производительности JavaScript-кода


Помимо профилирования памяти, вкладка "Performance" в Chrome DevTools незаменима при анализе времени выполнения JavaScript. Это крайне важно для оптимизации кода, связанного с манипуляциями DOM.


Инструкция:


  1. Открываем Chrome DevTools и переходим на вкладку "Performance".
  2. Нажимаем "Record" для начала записи.
  3. Выполняем операции, которые хотим проанализировать.
  4. Останавливаем запись.

Детальный анализ полученной информации о времени выполнения JavaScript-кода позволяет выявлять узкие места и определять, где необходимо провести оптимизацию для повышения производительности.





Полученная временная шкала показывает:


  • выполнение JavaScript (желтые полоски)
  • операции по рендерингу (фиолетовые полоски)
  • процесс отрисовки (зеленые полоски)

На что стоит обратить внимание:


  • длинные желтые полосы, указывающие на ресурсоемкие JavaScript-операции
  • частые короткие желтые полосы, которые могут свидетельствовать об избыточных манипуляциях с DOM

Для более глубокого анализа:


  1. Кликаем по желтой полосе, чтобы увидеть конкретный вызов функции и время ее выполнения.
  2. Изучаем вкладки "Bottom-Up" и "Call Tree", чтобы определить, выполнение каких функций занимают больше всего времени.

Такой анализ производительности помогает выявлять узкие места в коде, связанные с манипуляциями DOM, и проводить целенаправленную оптимизацию для повышения общей производительности приложения.


Ресурсы для отладки производительности


Статьи от команды разработчиков Chrome:



Курсы для глубокого изучения методов анализа памяти и производительности, а также для расширения знаний об использовании Chrome DevTools:



Эффективная работа с DOM предполагает не только использование оптимальных методов, но и понимание того, когда и насколько часто следует выполнять операции с элементами. Даже при использовании эффективных методов, чрезмерные манипуляции с DOM могут негативно влиять на производительность.


Ключевые выводы для оптимизации работы с DOM


Умение эффективно работать с DOM крайне важно при разработке веб-приложений c высокими требованиями к производительности. Несмотря на то, что современные фреймворки удобны и обеспечивают высокий уровень абстракции, понимание и применение низкоуровневых техник манипуляции с DOM может существенно повысить производительность вашего приложения, особенно в ресурсоемких сценариях.


Основные рекомендации:


  1. По возможности отдавайте предпочтение модификации существующих элементов вместо создания новых.
  2. Используйте эффективные методы: textContent, insertAdjacentHTML и appendChild.
  3. Управляйте ссылками, используя WeakMap и WeakRef, чтобы избежать утечек памяти.
  4. Своевременно удаляйте обработчики событий, чтобы избежать лишней нагрузки.
  5. Рассмотрите такие методы, как делегирование событий для более эффективной обработки событий.
  6. Используйте AbortController для группового управления обработчиками событий.
  7. Используйте DocumentFragment для пакетного добавления элементов и изучайте концепцию виртуального DOM, чтобы реализовывать более комплексные стратегии оптимизации.

Важно понимать, что полный отказ от использования фреймворков и ручная работа с DOM — плохая идея. Речь идет о том, чтобы принимать обоснованные решения о том, когда применять фреймворки, а когда выполнять оптимизацию на более низком уровне. Такие инструменты, как профилирование памяти и тестирование производительности, могут помочь в принятии таких решений.




Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Комментарии (0)