Паттерны для эффективного манипулирования DOM с ванильным JavaScript
querySelector
, кэширование элементов и улучшение обработки событий.В наши дни фреймворки используются повсеместно. Но иногда мы всё ещё обращаемся к манипуляциям с DOM с помощью ванильного JavaScript. Типичная схема манипулирования DOM с помощью ванильного JavaScript выглядит примерно так: сначала нужно получить ссылку на элемент DOM, с которым вы собираетесь работать. Часто это делается с помощью таких функций, как getElementById
или querySelector
.
Когда есть ссылка на элемент, можно сделать что-то вроде:
- Добавить или удалить дочерние элементы
- Прослушивать события
- Манипулировать текстовым контентом
В этой статье мы рассмотрим эффективность различных методов работы с DOM и сравним их производительность. Хотя некоторые подходы могут быть быстрее других, для приложения это может быть не так важно. Например, если один подход может выполнять миллион операций в секунду, а другой — только 700 000 операций в секунду, это, по сути, обеспечит одинаковый пользовательский опыт в типичном HTML-документе или приложении.
С учётом сказанного, давайте погрузимся в работу и рассмотрим несколько паттернов манипулирования DOM. К этой статье есть сопутствующий репозиторий GitHub, демонстрирующий некоторые различия в производительности, которые будут рассмотрены.
Выбор элементов
Распространённым способом получения ссылок на элементы DOM в современном JavaScript является использование функций querySelector
или querySelectorAll
. Они принимают в качестве аргумента строку CSS селектора и возвращают первый элемент (в случае querySelector
) или все элементы (в случае querySelectorAll
), соответствующие указанному селектору.
Производительность селектора
Прежде чем перейти к рассмотрению различий в производительности селекторов, важно понять, как браузер сопоставляет элементы с селекторами.
Браузер сопоставляет элементы, обрабатывая селектор справа налево. Это означает, что чем менее конкретны крайние правые части селектора, тем больше элементов DOM нужно оценить, и тем больше времени займёт запрос.
Рассмотрим селектор, соответствующий всем элементам, являющимся потомками элемента списка:
li *
Селектор звёздочки означает, что он должен перебрать все элементы в документе, а затем найти те, у которых предком является li
. Это приводит к снижению производительности, так как необходимо просмотреть все элементы. Вместо этого предположим, что вы знаете, что дочерние элементы интересующих элементов li
— это ссылки. Таким образом, можно сделать селектор более конкретным:
li a
Теперь браузер будет учитывать только элементы a
в документе, а не все элементы. Этот селектор, вероятно, будет более эффективным, особенно в больших документах.
В целом, можно сделать селектор более производительным, сделав самые правые части селектора как можно более конкретными.
querySelector
vs. querySelectorAll
При выборе между querySelector
и querySelectorAll
важно учитывать их поведение. querySelector
остановится, как только найдёт совпадающий элемент, в то время как querySelectorAll
выполнит полный поиск по всему документу.
Если вы ищете конкретный элемент, не являющийся первым совпавшим элементом, можно использовать querySelectorAll
и получить доступ к индексу нужного элемента.
Это работает, но было бы эффективнее уточнить поиск и использовать более конкретный селектор, чтобы получить нужный элемент, используя querySelector
. Это можно сделать, добавив идентификатор или класс к нужному элементу, или используя псевдокласс, например :nth-child
, для выбора нужного элемента.
Например, эти два запроса выбирают второй элемент в списке, но второй запрос будет более производительным, потому что он получает только один элемент:
document.querySelectorAll('.list li')[1]
document.querySelector('.list li:nth-child(2)')
Ограничение области действия запроса
Всегда можно вызвать document.querySelector
для поиска в документе, но также можно вызвать querySelector
для любого элемента DOM, если есть ссылка на него. Вместо того чтобы искать во всем документе, он будет искать только в поддереве этого элемента. Это может сократить объем поиска и повысить производительность.
Кэширование элементов
Если планируете многократно выполнять операции над элементом в течение всего времени работы приложения, лучше кэшировать элемент, возвращаемый querySelector. Для этого достаточно сохранить его в переменной, к которой можно обратиться позже. Таким образом, нужно будет только один раз запросить документ для этого элемента.
События и эффективная обработка событий
Ниже приведены шаблоны, позволяющие повысить эффективность обработки событий.
Делегирование событий
Делегирование событий — паттерн, позволяющий сократить количество активных слушателей событий в документе, что может быть более эффективным. Предположим, есть несколько кнопок под общим родительским элементом. Вместо того чтобы добавлять слушатель клика для каждой кнопки, можно добавить один слушатель клика для родительского элемента.
Это возможно благодаря тому, что события распространяются вверх по иерархии DOM. Если кликнуть по одной из кнопок, событие click
сработает на кнопке, а затем распространится, или всплывёт
, до родительского элемента. Здесь сработает слушатель клика, и вы сможете определить, какое действие предпринять в зависимости от того, какая кнопка была нажата.
Рассмотрим следующий HTML для списка, содержащего кнопки, по одной на каждого пользователя. У каждой кнопки есть атрибут data-userid
, указывающий, для какого пользователя эта кнопка предназначена:
<ul class="user-list">
<li><button data-userid="user1">User 1</button></li>
<li><button data-userid="user2">User 2</button></li>
</ul>
Делегирование событий можно применять следующим образом:
const list = document.querySelector('#user-list');
list.addEventListener('click', event => {
if (event.target.dataset.userid) {
console.log(`Clicked user: ${event.target.dataset.userid}`);
}
});
Здесь всего один слушатель событий, добавленный к элементу <ul>
. Каждый раз, когда что-либо внутри этого списка кликается, срабатывает слушатель. Вы можете определить, была ли нажата кнопка пользователя, найдя атрибут данных userid
.
Обработчики событий с дебаунсингом и троттлингом
Некоторые события могут вызываться много раз за короткий период времени. Из-за этого, в зависимости от того, что происходит в обработчике события, могут возникнуть проблемы с производительностью.
Вы можете ограничивать функцию, делая так, чтобы она выполнялась только один раз за определённый период (троттлинг). Например, если необходимо выполнить некоторые вычисления по событию scroll
, можно установить ограничения обработчика события, чтобы он запускался только раз в 500 миллисекунд.
Также можно приостановить работу обработчика события, сделав так, чтобы он срабатывал только после определённого периода времени (дебаунсинг), в течение которого событие не срабатывало. Рассмотрим текстовое поле, отправляющее API-запрос на поиск по мере ввода текста. Если это делать при каждом событии нажатия клавиши, то на сервер поступит множество запросов в быстрой последовательности. Ограничив время срабатывания обработчика этого события, можно быть уверенным, что запрос не будет отправлен до тех пор, пока пользователь не перестанет набирать текст в течение определённого времени, скажем, 500 миллисекунд.
Манипулирование текстом
Одним из наиболее распространённых типов манипуляций с DOM считается получение или установка текста, отображаемого внутри элемента. Как и в случае с большинством других операций с DOM, существует несколько способов сделать это, каждый из них имеет свои особенности в производительности.
Используя innerText
У элементов DOM есть свойство innerText
. Можно читать или записывать текст элемента, изменяя это свойство. Это работает, как и ожидается, но имеет небольшое снижение производительности. Свойство innerText
работает с текстом в том виде, в котором он отображается на экране. Это означает, что оно должно учитывать стили перед возвращением текста, поскольку CSS стили могут скрывать часть текстового содержимого.
Эта проблема возникает, если использовать innerText
для родительского элемента с несколькими дочерними элементами, содержащими текст. Стили каждого дочернего элемента должны быть учтены перед возвращением окончательного значения текста.
Используя textContent
Также можно работать с текстом с помощью свойства элемента textContent
. Это свойство работает иначе, чем innerText
, поскольку возвращает весь текст в разметке, независимо от статуса его видимости. Это означает, что, в отличие от innerText
, оно не учитывает стили.
Эту разницу необходимо знать, но на практике разницы в производительности обычной страницы или приложения вы, скорее всего, не заметите, если на странице нет большого количества элементов или стилей.
Добавление дочерних элементов
Другой распространённой операцией DOM является добавление дочернего содержимого к элементу. Как и во многих других случаях в DOM, существует несколько способов сделать это.
Используя innerHTML
Чтобы добавить дочернее содержимое к элементу, можно установить его через свойство innerHTML
, указав строку HTML. Браузер разберёт эту строку на HTML-элементы, задаст их содержимое и установит их в качестве дочернего элемента (элементов) родительского элемента.
Пример использования innerHTML
для добавления нового элемента списка в неупорядоченный список:
const list = document.querySelector('ul.myList');
list.innerHTML += '<li>New List Item</li>';
Несмотря на то, что это позволяет легко задать содержимое дочерних элементов, использование свойства innerHTML имеет ряд недостатков:
- Снижается производительность, так как браузеру приходится разбирать заданную строку и создавать дерево HTML-элементов.
- Оно полностью заменяет содержимое дочернего элемента. Если необходимо добавить дополнительное содержимое, нужно сначала прочитать свойство innerHTML, обработать возвращаемую строку, а затем вернуть её в качестве нового innerHTML.
- Может представлять угрозу безопасности. Использование ненадёжного пользовательского ввода при установке innerHTML может привести к атакам, таким как межсайтовый скриптинг (XSS).
- Поскольку HTML является строкой, вы не можете добавить слушателей событий ни к одному из содержащихся в нем элементов
Создание и вставка дочерних элементов
Другой способ добавления дочернего содержимого — это создание элементов с помощью вызова document.createElement
, а затем добавление их в дочернее содержимое элемента с помощью таких методов, как appendChild
или insertBefore
.
Пример создания и добавления нового элемента списка в неупорядоченный список:
const list = document.querySelector('ul.myList');
const item = document.createElement('li');
item.textContent = 'New List Item';
list.appendChild(item);
Избегание пересчёта (reflow) документа
Определённые операции могут вызывать перерасчёт в документе. Пересчёт (Reflow) — это когда движок браузера заново пересчитывает данные о стиле и макете для всего документа или его части. Это может быть дорогостоящей операцией и снижать производительность страницы, в зависимости от того, как часто она выполняется.
Хотя очевидно, что внесение изменений в макет или стиль элемента может вызвать пересчёт, вы можете не знать, что даже операции, доступные только для чтения, могут вызвать пересчёт. Например, можно вызвать getBoundingClientRect
для элемента, чтобы получить его размеры и положение. Однако делать это следует с осторожностью, поскольку это вызовет перерасчёт, так как браузер удостоверится, что все изменения стиля применены, прежде чем вернуть граничный прямоугольник.
Даже такое простое действие, как чтение свойства offsetWidth
элемента, вызывает пересчёт.
Избегание перестройки (thrashing) макета
Выполнение большого количества операций с DOM, вызывающих пересчёты за короткий промежуток времени, может привести к перестройке (thrashing) макета и многократному пересчёту стилей и макетов, что может снизить производительность страницы. Это может произойти, например, если читать и записывать свойства DOM в цикле.
Немного надуманный пример, иллюстрирующий эту проблему:
const elements = document.querySelectorAll('.item');
for (let i = 0; i < elements.length; i++) {
elements[i].style.width = `${elements[i].offsetWidth + 10}px`;
}
Этот код перебирает коллекцию найденных элементов. Для каждого элемента устанавливается встроенный CSS стиль. Выполнение этих действий в быстрой последовательности, как это сделано здесь, может привести к перестройке макета. Браузер постоянно считывает значение offsetWidth
, что заставляет его пересчитывать макет. Затем происходит немедленная запись в свойство style
.
Чтобы избежать этой проблемы, можно сначала выполнить все операции чтения, а затем все операции записи. Это предотвратит операции чтения/записи, приводящие к нарушению макета.
В приведённом выше примере можно сначала прочитать все ширины, а затем использовать эти ширины для обновления всех стилей:
const widths = [];
// Сначала выполняем все операции чтения вместе.
for (let i = 0; i < elements.length; i++) {
widths[i] = elements[i].offsetWidth;
}
// Затем выполняем все операции записи, используя ранее рассчитанные значения.
for (let i = 0; i < elements.length; i++) {
elements[i].style.width = `${widths[i]}px`;
}
Заключение
Мы рассмотрели некоторые способы повышения эффективности работы с DOM как с точки зрения памяти, так и с точки зрения процессора.
Хотя один способ может быть более эффективным, с точки зрения показателей производительности разница может быть не столь велика. Если одна операция занимает две миллисекунды, а другая — 20 миллисекунд, то для пользователя обе будут казаться мгновенными.
Поэтому, несмотря на то, что паттерны, описанные в этой статье, полезны, не стоит жертвовать функциональностью или простотой кода, если нет реальной проблемы с производительностью.
Тем не менее бывают случаи, когда каждая крупица производительности имеет значение — например, при выполнении анимации. Если во время выполнения анимации возникнут проблемы с производительностью, пользователь, скорее всего, увидит не плавную анимацию со скоростью 60 кадров в секунду, а какие-то рывки.