Живі колекції DOM, проблема руйнування макету та пояснення проблеми з атрибутами
Дізнайтеся, чому DOM є динамічно відображуваним деревом, як це призводить до пропуску елементів циклу та примусового форматування, і чому для кастомних атрибутів даних потрібна функція getAttribute.
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 у більшості випадків витіснив старіші API пошуку з сучасних кодових баз. Якщо ви змушені працювати з активним набором даних, ітерація ззаду до першого індексу також допомагає уникнути змін, хоча статична копія зазвичай є зрозумілішою.
Проблеми з лейаутом: справжня причина повільної роботи коду 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. Один модель мислення замість трьох підводних каменів
Ці поведінки не є окремими дрібницями. Усі вони випливають з одного факту: DOM — це динамічна структура, яка постійно відображається, а не нерухомі дані, які заповнюються лише один раз.
- Колекції, які є динамічними, змінюються під час їх обходу, тому зробіть їх копію або використайте
querySelectorAllперед змінами. - Читання геометрії змушує браузер негайно обчислювати макет, тому виконуйте читання пакетно перед записом.
- Властивості JavaScript існують для зручності поверх атрибутів та не відображають усі з них, тому отримуйте користувацькі дані через
getAttributeабоdataset.
Як тільки ви почнете розглядати DOM як живе дерево, а не повільну структуру даних, ці явища більше не будуть несподіванками, а стануть передбачуваними наслідками. Щоб дізнатися більше про те, як у контексті фреймворку від змін стану до формування пікселів відбувається відображення, перегляньте як React перетворює оновлення стану на пікселі екрана.
Пов’язана література
- React Rendering Explained: State Updates to Screen Pixels — Дізнайтеся, як фази рендерингу, узгодження та збереження даних у React пов’язані з процесами формування макету, нанесення кольорів та об’єднання елементів браузером для створення пікселів.