Главная / Статьи / Живые коллекции DOM, проблема смещения макета и объяснение разницы в атрибутах

Живые коллекции DOM, проблема смещения макета и объяснение разницы в атрибутах

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

1407 слов

DOM считается медленным, и обычное объяснение заключается в том, что это просто тяжелая структура. Однако это объяснение в основном неверно. Операции с DOM выполняются довольно быстро; проблема возникает из-за распространённого шаблона программирования, при котором в цикле постоянно запрашивается информация у DOM и затем задаются указания изменить его. Чтобы понять почему, сначала необходимо иметь точное представление о том, что такое DOM, и именно это представление объясняет ещё два явления, которые часто сбивают разработчиков с толку: циклы, пропускающие элементы, и пользовательские атрибуты, возвращающие значение undefined.

DOM — это живое дерево, а не копия вашего HTML

Когда браузер парсит HTML, он не сохраняет маркировку в виде текста. Для каждого элемента создаётся объект, которые затем вложены друг в друга согласно структуре маркировки, образуя дерево. Рассмотрим небольшой каталог товаров:

<div id="catalog">
  <div class="product">
    <h3>Desk Lamp</h3>
    <span class="price">$34</span>
  </div>
  <div class="product">
    <h3>Standing Mat</h3>
    <span class="price">$58</span>
  </div>
</div>

На основе этого браузер создаёт дерево активных объектов, к которым может обращаться JavaScript: элемент #catalog содержит два элемента .product, и каждый из них в свою очередь содержит элемент <h3> и элемент <span>. Ключевой момент заключается в том, что это дерево — не описание, сгенерированное один раз и затем отложенное в сторону. Это структура, которую браузер отрисовывает в данный момент. Если изменить свойство одного из этих узловых объектов, страница сразу отреагирует на это изменение; не требуется отдельного шага сохранения или вызова функции отрисовки.

document.querySelector(".product h3").textContent = "Desk Lamp (Sale)";

Это утверждение не является запросом к браузеру об обработке его позже от вашего имени. Само присваивание — это и есть изменение. Учитывать эту «живую» природу данных — самая полезная модель понимания DOM, и именно она является причиной ошибок, которые почти каждый программист делает в какой-то момент.

Почему цикл над динамическим коллекцией пропускает элементы

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

const outOfStockCards = document.getElementsByClassName("out-of-stock");

for (let i = 0; i < outOfStockCards.length; i++) {
  outOfStockCards[i].remove();
}

Он кажется правильным, но тем не менее молча оставляет каждый второй элемент. Причина в том, что getElementsByClassName не возвращает фиксированный массив. Он возвращает динамическую структуру HTMLCollection, которая обновляется по мере изменения документа. Как только .remove() удаляет первую карточку, количество элементов в коллекции сокращается на один, и все оставшиеся карточки смещаются на один индекс ниже. Счетчик цикла продолжает увеличиваться, поэтому элемент, который только что оказался на индексе 0, так и не обрабатывается. Размер коллекции меняется во время выполнения цикла, и примерно половина элементов остается незамеченной.

Решение заключается в том, чтобы сначала создать статическую копию перед началом изменений:

const outOfStockCards = Array.from(document.getElementsByClassName("out-of-stock"));

for (const card of outOfStockCards) {
  card.remove();
}

Array.from создаёт копию динамического набора в обычный массив, поэтому последующее удаление элементов не может уменьшить объём данных, по которым происходит итерация. Проще использовать document.querySelectorAll(".out-of-stock") — этот метод с самого начала возвращает статический объект NodeList и не требует преобразований. Именно такая предсказуемость является одной из причин, по которой querySelectorAll в основном вытеснил более старые методы поиска из современных кодовых баз. Если необходимо работать с динамическим набором, итерация от последнего индекса также позволяет избежать смещения элементов, хотя статическая копия обычно представляется более понятной.

Проблемы с лейаутом: настоящая причина медленной работы кода DOM

Теперь о самой проблеме производительности. Расчёт макета, то есть точных размеров и положения каждого элемента, требует значительных ресурсов. Браузеры стараются делать это как можно реже: при изменении стилей они не сразу пересчитывают макет. Они собирают все ожидаемые изменения и выполняют расчёт макета один раз, незадолго до отрисовки следующего кадра.

Эта стратегия работает только тогда, когда это позволяет ваш код. Некоторые свойства и методы, включая offsetWidth, offsetHeight и getBoundingClientRect(), нуждаются в актуальных геометрических данных, чтобы вернуть значение. Их чтение заставляет браузер выполнять расчёт макета синхронно, поскольку он не может указать ширину элемента без её расчёта.

В следующем цикле для каждой карточки чередуются операции чтения и записи:

// forces a full layout recalculation on every single iteration
const cards = document.querySelectorAll(".product");
cards.forEach((card) => {
  const width = card.offsetWidth; // read: forces layout
  card.style.width = width + 10 + "px"; // write: invalidates layout again
});

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

Разделите работу на два этапа: сначала все операции чтения, а затем все операции записи:

const cards = document.querySelectorAll(".product");
const widths = Array.from(cards).map((card) => card.offsetWidth); // all reads, together
cards.forEach((card, i) => {
  card.style.width = widths[i] + 10 + "px"; // all writes, together
});

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

Из этого следуют несколько практических замечаний:

  • То же правило применимо и к другим операциям с геометрией, таким как clientWidth, scrollTop и getComputedStyle().
  • В более крупных приложениях считывание и запись данных часто происходят в разных функциях или компонентах, поэтому проблемы могут возникать даже если ни один цикл не выглядит подозрительным. Инструменты для анализа производительности браузера выделяют случаи принудительного пересчёта макета, что облегчает их выявление.
  • Планирование записей с помощью requestAnimationFrame — это распространённый способ группировать их непосредственно перед отрисовкой.
  • Свойства и атрибуты не всегда являются одним и тем же

    Ещё один сюрприз связан с отношением между атрибутами HTML и свойствами JavaScript. Обычно они отражают друг друга, но это отражение имеет ограничения, и эти ограничения становятся значимыми при добавлении пользовательских данных. Возьмём этот элемент:

    <div class="product" data-sku="LAMP-2201"></div>
    

    И этот код, который читает из него три разных способа:

    const el = document.querySelector(".product");
    console.log(el.className); // "product" — standard attributes map to properties directly
    console.log(el.sku);       // undefined — custom attributes don't
    console.log(el.getAttribute("data-sku")); // "LAMP-2201" — this is how you actually reach it
    

    Стандартные атрибуты, известные браузеру, такие как href, src и class, автоматически представляются в виде соответствующих свойств. Атрибут class отображается как className, поскольку class является зарезервированным словом в JavaScript. Собственные атрибуты не получают такого обращения, включая те, что имеют префикс data- — стандартный механизм для хранения собственных метаданных на элементах. Свойства el.sku не существует, поэтому результат поиска — undefined, и для чтения или изменения значения необходимо использовать методы getAttribute и setAttribute. Для дополнительного удобства браузеры также предоставляют доступ к атрибутам data- через объект dataset, поэтому el.dataset.sku возвращает то же значение. Эта несоответствие незначительно, но именно оно приводит к путанице при работе с элементами.

    Когда впервые вы ожидаете, что пользовательское свойство будет вести себя как href, возникает значение undefined.

    Один модель мышления вместо трех подводных камней

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

    • Динамические коллекции меняются во время их перебора, поэтому делайте их копию или используйте querySelectorAll перед изменениями.
    • Чтение геометрических данных заставляет браузер немедленно вычислить макет, поэтому выполняйте чтение пачками перед записью.
    • Свойства JavaScript представляют собой удобство, добавленное поверх атрибутов, и не отражают все из них; поэтому для доступа к пользовательским данным используйте getAttribute или dataset.

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

    Связанные материалы

    • React Rendering Explained: State Updates to Screen Pixels — Узнайте, как этапы отрисовки, сопоставления и сохранения данных в React связаны с процессами формирования макета, отрисовки и комбинирования элементов в браузере для создания пикселей.
  • React 19.2: Обзор компонента Activity, хука useEffectEvent и частичной статической отрисовки — Узнайте, как новый компонент Activity, хук useEffectEvent и возможность частичной статической отрисовки в React 19.2 устраняют скрытые проблемы с производительностью в современных интерфейсах.