Пошук уроків, статей та іншого контенту
Оптимізуватимете масові зміни DOM, зменшуючи кількість перерахунків стилів і перемальовувань.
Зміна DOM — це не лише операція над деревом елементів. Браузер має оновити кілька внутрішніх структур:
DOM — дерево вузлів документа.
CSSOM — правила CSS і їхні обчислені значення.
Render tree — елементи, які потрібно відобразити.
Layout — розрахунок геометрії: розмірів і позицій.
Paint — підготовка пікселів для промальовування.
Composite — об’єднання шарів і передавання результату на екран.
Спрощений цикл виглядає так:
JavaScript → Style recalculation → Layout → Paint → CompositeНе кожна зміна проходить усі етапи.
Зміна color зазвичай потребує перерахунку стилів і перемальовування.
Зміна width може спричинити перерахунок layout і paint.
Зміна transform часто обробляється на етапі композиції без нового layout.
Додавання вузла до DOM може позначити частину дерева як таку, що потребує оновлення, але фактичні обчислення часто відкладаються до завершення JavaScript-коду.
Головна мета оптимізації — не заборонити зміни DOM, а:
зменшити кількість окремих змін;
не чергувати читання layout із записами;
обмежити область, яку потрібно перерахувати;
виконувати візуальні зміни у відповідний момент кадру.
Наступний код створює та вставляє 10 000 елементів безпосередньо в документ:
const list = document.querySelector("#list");
for (let i = 0; i < 10_000; i++) {
const item = document.createElement("li");
item.textContent = `Елемент ${i}`;
list.append(item);
}Це не обов’язково спричинить 10 000 повних layout-операцій, оскільки браузер може відкласти оновлення до завершення циклу. Проте кожна вставка:
змінює DOM;
може збільшувати обсяг роботи для майбутнього layout;
створює більше проміжних станів;
ускладнює контроль над моментом оновлення.
Для великих пакетів змін краще підготувати вузли поза документом.
DocumentFragmentDocumentFragment — тимчасовий контейнер, який не є частиною основного документа. Вузли можна створити в ньому, а потім вставити весь фрагмент однією операцією.
const list = document.querySelector("#list");
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10_000; i++) {
const item = document.createElement("li");
item.textContent = `Елемент ${i}`;
fragment.append(item);
}
list.append(fragment);Фрагмент не є магічною гарантією кратного прискорення. Сучасні браузери добре оптимізують багато послідовних вставок, але фрагмент:
чітко відокремлює підготовку від вставки;
спрощує пакетування змін;
дозволяє не змінювати живий документ під час побудови великої підструктури.
innerHTMLКоли дані вже перетворені на безпечний HTML, одним із варіантів є побудова рядка та одна вставка:
const list = document.querySelector("#list");
const items = Array.from({ length: 10_000 }, (_, index) => {
return `<li class="item">Елемент ${index}</li>`;
});
list.innerHTML = items.join("");Цей підхід може бути швидким, але має важливі обмеження:
HTML-рядок із неперевіреними даними створює ризик XSS;
innerHTML замінює дочірні вузли контейнера;
обробники, додані безпосередньо до замінених елементів, будуть втрачені;
зайва заміна всього списку погана для стану, фокусу та доступності.
Для даних користувача використовуйте textContent або безпечний спосіб створення вузлів:
const list = document.querySelector("#list");
const fragment = document.createDocumentFragment();
for (const userName of userNames) {
const item = document.createElement("li");
item.textContent = userName;
fragment.append(item);
}
list.replaceChildren(fragment);replaceChildren() зручно використовувати, коли контейнер потрібно повністю синхронізувати з новим набором дочірніх вузлів.
Такий код кілька разів змінює inline-стиль:
element.style.width = "200px";
element.style.height = "100px";
element.style.backgroundColor = "tomato";
element.style.padding = "16px";Браузер може відкласти перерахунки, але краще описувати стан через CSS-клас:
element.classList.add("is-active");.card.is-active {
width: 200px;
height: 100px;
background: tomato;
padding: 16px;
}Переваги класів:
стилі залишаються в CSS;
стан елемента легко побачити в DevTools;
змінюється один логічний стан замість набору розрізнених властивостей;
простіше повертати елемент до попереднього стану.
Якщо потрібно динамічно змінити кілька залежних стилів, зручно змінити одну CSS-змінну:
const panel = document.querySelector(".panel");
panel.style.setProperty("--progress", "72%");.panel {
width: var(--progress, 0%);
background: linear-gradient(
to right,
#1479ff var(--progress, 0%),
#e5e7eb var(--progress, 0%)
);
}Це не усуває всю роботу браузера, але зменшує кількість JavaScript-змін і централізує залежності в CSS.
Деякі властивості та методи читають актуальний layout:
offsetWidth, offsetHeight;
clientWidth, clientHeight;
scrollWidth, scrollHeight;
getBoundingClientRect();
getComputedStyle() для властивостей, які залежать від актуального стилю або layout.
Якщо перед таким читанням JavaScript змінив стилі або DOM, браузеру може знадобитися негайно виконати відкладені обчислення. Це називають forced synchronous layout або layout thrashing, якщо така ситуація повторюється в циклі.
Проблемний приклад:
const cards = document.querySelectorAll(".card");
for (const card of cards) {
card.style.width = `${card.offsetWidth + 10}px`;
}На кожній ітерації відбувається така послідовність:
запис нового width;
читання offsetWidth;
вимушене завершення відкладеного layout;
наступний запис.
Це створює чергування «запис — читання — запис — читання».
Спочатку зберіть усі потрібні вимірювання, а потім виконайте зміни:
const cards = [...document.querySelectorAll(".card")];
const widths = cards.map((card) => card.offsetWidth);
cards.forEach((card, index) => {
card.style.width = `${widths[index] + 10}px`;
});Тепер браузер має більше можливостей виконати layout один раз перед читаннями та оновити його після групи записів.
Цей підхід називають read/write batching:
1. Усі читання
2. Усі записиА не:
читання → запис → читання → записДля складнішого коду корисно явно розділити функції:
function readCardMetrics(cards) {
return cards.map((card) => ({
element: card,
width: card.getBoundingClientRect().width,
}));
}
function writeCardStyles(metrics) {
for (const { element, width } of metrics) {
element.style.setProperty("--target-width", `${width + 10}px`);
}
}
const cards = [...document.querySelectorAll(".card")];
const metrics = readCardMetrics(cards);
writeCardStyles(metrics);Таке розділення допомагає виявляти небезпечні місця під час рев’ю: функції запису не повинні несподівано читати геометрію, а функції читання — змінювати DOM.
requestAnimationFramerequestAnimationFrame() планує callback перед наступним перемальовуванням. Він підходить для змін, які мають бути видимими на екрані:
const box = document.querySelector(".box");
let x = 0;
function animate() {
x += 2;
box.style.transform = `translateX(${x}px)`;
if (x < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);Браузер викликає callback у момент, коли зміна може потрапити до найближчого кадру. Це краще за setInterval для візуальної анімації, оскільки:
callback узгоджений із частотою оновлення екрана;
невидима вкладка може отримувати менше викликів;
браузер може пропустити непотрібні кадри;
не створюється зайва робота між кадрами.
Якщо різні джерела часто запитують оновлення, не потрібно створювати окремий callback для кожного запиту:
let frameRequested = false;
let latestProgress = 0;
function setProgress(progress) {
latestProgress = progress;
if (frameRequested) {
return;
}
frameRequested = true;
requestAnimationFrame(() => {
frameRequested = false;
document.querySelector(".progress-bar")
.style.setProperty("--progress", `${latestProgress}%`);
});
}Якщо setProgress() викликається багато разів між кадрами, на екрані буде застосовано лише останнє значення.
Зміни таких властивостей часто потребують нового layout:
width;
height;
top, left, right, bottom;
margin;
padding;
font-size;
grid-template-columns;
flex-basis.
Це не означає, що їх ніколи не можна змінювати. Проте масова або часта анімація цих властивостей може бути дорогою.
transform і opacityДля переміщення, масштабування та обертання часто краще використовувати transform:
.dialog {
transform: translateY(0);
opacity: 1;
transition:
transform 180ms ease,
opacity 180ms ease;
}
.dialog.is-hidden {
transform: translateY(16px);
opacity: 0;
pointer-events: none;
}const dialog = document.querySelector(".dialog");
dialog.classList.add("is-hidden");transform та opacity часто дозволяють браузеру виконати основну роботу на етапі композиції. Але це не гарантія: результат залежить від браузера, інших стилів, фільтрів, розмірів елемента та конкретного пристрою.
Не додавайте will-change до всіх елементів. Це підказка браузеру, яка може збільшити споживання пам’яті:
.animated-panel {
will-change: transform, opacity;
}Використовуйте її лише для елементів, які справді часто анімуються, і за можливості додавайте підказку безпосередньо перед анімацією.
containВластивість contain дозволяє обмежити вплив елемента на решту сторінки:
.widget {
contain: layout paint;
}У цьому прикладі:
layout обмежує вплив layout елемента;
paint обмежує промальовування його вмісту.
Для компонента з незалежною геометрією іноді підходить:
.widget {
contain: content;
}content поєднує обмеження для layout, стилів і paint. Застосовувати containment потрібно обережно: він може змінити поведінку розмірів, позиціонування та переповнення.
content-visibilityДля великих частин сторінки, які ще не потрапили у viewport, може бути корисним:
.article-section {
content-visibility: auto;
contain-intrinsic-size: 800px;
}Браузер може пропустити частину роботи для невидимого вмісту. contain-intrinsic-size задає приблизний резервний розмір, щоб прокрутка не поводилася непередбачувано до фактичного розрахунку секції.
Це особливо корисно для:
довгих списків;
сторінок із багатьма секціями;
великих блоків контенту нижче видимої області.
Це не заміна віртуалізації. Якщо список містить сотні тисяч елементів, краще взагалі не створювати всі елементи одночасно.
display: noneЯкщо компонент тимчасово не потрібен, його можна оновлювати поза активним layout:
const panel = document.querySelector(".panel");
panel.hidden = true;
for (let i = 0; i < 5_000; i++) {
const item = document.createElement("div");
item.textContent = `Рядок ${i}`;
panel.append(item);
}
panel.hidden = false;Атрибут hidden за замовчуванням реалізується як приховування через CSS. Поки елемент не бере участі у відображенні, браузеру не потрібно розкладати його в поточній сторінці.
Однак у цього підходу є нюанси:
прихований елемент не можна виміряти так само, як видимий;
показ великого піддерева все одно потребує роботи;
приховування може впливати на доступність і фокус;
не слід приховувати елемент, якщо користувач очікує бачити поступове оновлення.
Для довгих списків не обов’язково створювати DOM-вузол для кожного запису. Замість цього можна створити лише видимі елементи та змінювати їх у відповідь на прокручування.
Спрощена ідея:
1. Визначити висоту одного рядка.
2. Створити лише кілька десятків DOM-вузлів.
3. За позицією прокрутки визначити видимий діапазон.
4. Оновити текст і позиції видимих вузлів.
5. Залишити великий внутрішній простір для правильної прокрутки.Віртуалізація складніша за звичайний список, особливо коли рядки мають різну висоту, але для дуже великих наборів даних вона часто дає більший ефект, ніж мікрооптимізація окремих DOM-викликів.
Нижче наведено приклад, який:
створює елементи у DocumentFragment;
вставляє їх однією операцією;
використовує делегування подій;
не виконує вимірювання всередині циклу записів;
планує оновлення прогресу не частіше одного разу на кадр.
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>Пакетні зміни DOM</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 24px;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.list {
display: grid;
gap: 6px;
max-width: 480px;
}
.item {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
transition: background-color 120ms ease;
}
.item.is-selected {
background: #dbeafe;
border-color: #2563eb;
}
.progress {
height: 8px;
margin-bottom: 16px;
border-radius: 999px;
background: #e5e7eb;
overflow: hidden;
}
.progress::before {
content: "";
display: block;
width: var(--progress, 0%);
height: 100%;
background: #2563eb;
}
</style>
</head>
<body>
<div class="toolbar">
<button id="add-items" type="button">Додати 1000 елементів</button>
<button id="select-all" type="button">Вибрати всі</button>
</div>
<div class="progress" aria-label="Прогрес"></div>
<ul id="list" class="list"></ul>
<script>
const list = document.querySelector("#list");
const addItemsButton = document.querySelector("#add-items");
const selectAllButton = document.querySelector("#select-all");
const progress = document.querySelector(".progress");
let nextId = 1;
let frameRequested = false;
let pendingProgress = 0;
function appendItems(count) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < count; i++) {
const item = document.createElement("li");
item.className = "item";
item.dataset.id = String(nextId);
item.textContent = `Елемент ${nextId}`;
fragment.append(item);
nextId++;
}
// Одна вставка змінює живий документ після завершення підготовки.
list.append(fragment);
}
function scheduleProgressUpdate(value) {
pendingProgress = value;
if (frameRequested) {
return;
}
frameRequested = true;
requestAnimationFrame(() => {
frameRequested = false;
progress.style.setProperty("--progress", `${pendingProgress}%`);
});
}
addItemsButton.addEventListener("click", () => {
const startCount = list.childElementCount;
appendItems(1000);
const endCount = list.childElementCount;
const percent = Math.min(100, (endCount / 5000) * 100);
scheduleProgressUpdate(percent);
console.log(
`Додано ${endCount - startCount} елементів. Усього: ${endCount}`
);
});
selectAllButton.addEventListener("click", () => {
// Делегування подій не потребує окремого обробника для кожного елемента.
list.querySelectorAll(".item").forEach((item) => {
item.classList.add("is-selected");
});
});
list.addEventListener("click", (event) => {
const item = event.target.closest(".item");
if (!item || !list.contains(item)) {
return;
}
item.classList.toggle("is-selected");
});
appendItems(100);
scheduleProgressUpdate(2);
</script>
</body>
</html>Події scroll, pointermove, input і resize можуть надходити частіше, ніж потрібно для візуального оновлення.
Поганий підхід:
window.addEventListener("scroll", () => {
element.style.transform = `translateY(${window.scrollY}px)`;
});Краще обмежити оновлення одним callback на кадр:
let latestScrollY = 0;
let scrollFrame = null;
window.addEventListener("scroll", () => {
latestScrollY = window.scrollY;
if (scrollFrame !== null) {
return;
}
scrollFrame = requestAnimationFrame(() => {
scrollFrame = null;
element.style.transform = `translateY(${latestScrollY}px)`;
});
}, { passive: true });passive: true повідомляє браузеру, що обробник не викликатиме preventDefault(). Для прокручування це може допомогти браузеру не затримувати початок scrolling через очікування JavaScript.
Для простої реакції на розмір контейнера часто краще використовувати ResizeObserver, а не постійно опитувати геометрію через таймер:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log("Нова ширина:", entry.contentRect.width);
}
});
observer.observe(document.querySelector(".panel"));Оптимізацію слід починати з вимірювання. Перевіряйте:
час виконання JavaScript;
кількість і тривалість layout;
час paint;
довгі завдання понад приблизно 50 мс;
пропущені кадри;
обсяг створених і вилучених DOM-вузлів;
використання пам’яті.
Для базового вимірювання можна використати performance.mark() і performance.measure():
performance.mark("dom-update-start");
const fragment = document.createDocumentFragment();
for (let i = 0; i < 5000; i++) {
const item = document.createElement("li");
item.textContent = `Елемент ${i}`;
fragment.append(item);
}
document.querySelector("#list").append(fragment);
performance.mark("dom-update-end");
performance.measure(
"dom-update",
"dom-update-start",
"dom-update-end"
);
const measurement = performance.getEntriesByName("dom-update").at(-1);
console.log(`JavaScript-частина: ${measurement.duration.toFixed(2)} мс`);Цей вимір показує час виконання JavaScript, але не обов’язково включає всі відкладені етапи layout і paint. Для повної картини використовуйте Performance panel у DevTools:
відкрийте запис продуктивності;
виконайте сценарій масової зміни;
знайдіть довгі JavaScript-виклики;
перевірте події Recalculate Style, Layout, Paint;
порівняйте кількість і тривалість таких подій;
повторіть тест на реалістичному обсязі даних.
Під час аналізу важливо відрізняти:
власне JavaScript;
перерахунок стилів;
layout;
paint;
роботу композитора.
Покращення одного етапу не завжди покращує весь кадр.
Для масової зміни DOM можна застосовувати такий алгоритм:
Визначте, які дані справді змінилися.
Не замінюйте весь піддеревовий фрагмент, якщо достатньо змінити кілька вузлів.
Створюйте нові вузли поза живим документом.
Вставляйте групу вузлів однією операцією.
Об’єднуйте стилі в CSS-класи.
Виконайте всі вимірювання до змін стилю або DOM.
Виконайте всі записи після вимірювань.
Для анімації використовуйте requestAnimationFrame.
Для переміщення та прозорості спочатку розгляньте transform і opacity.
Для великих невидимих секцій розгляньте content-visibility і contain.
Для дуже великих списків використовуйте віртуалізацію.
Перевірте результат у Performance panel на цільових пристроях.
Браузери відкладають і групують роботу. Не варто автоматично вважати, що цикл із багатьма append() завжди виконує стільки ж layout-операцій.
Правильніше виміряти конкретний сценарій. Водночас підготовка через фрагмент або рядок часто робить пакетування явним і зменшує проміжну роботу.
for (const item of items) {
item.classList.add("expanded");
console.log(item.offsetHeight);
}Таке чергування може примусити браузер синхронно завершувати layout на кожній ітерації. Збирайте вимірювання окремо від змін.
innerHTML із неперевіреними данимиcontainer.innerHTML = `<p>${userInput}</p>`;Якщо userInput походить від користувача або ненадійного джерела, це може призвести до XSS. Для тексту використовуйте textContent:
const paragraph = document.createElement("p");
paragraph.textContent = userInput;
container.replaceChildren(paragraph);Повна заміна через innerHTML може бути швидкою для невеликого списку, але вона знищує існуючі вузли. Це може скинути:
фокус;
виділення;
локальний стан;
обробники подій;
позицію курсора у формах.
Для часто оновлюваних списків оновлюйте лише змінені елементи або використовуйте стабільні вузли.
will-change всюдиwill-change не є універсальним прискорювачем. Надмірне створення шарів збільшує використання пам’яті та іноді погіршує продуктивність.
top або left, коли достатньо transformДля частого переміщення елемента зазвичай варто перевірити варіант із transform. Проте остаточне рішення потрібно підтвердити вимірюванням.
Події прокручування або руху вказівника можуть надходити дуже часто. Пакетуйте візуальні зміни через requestAnimationFrame, а обчислення, які не потрібні для поточного кадру, відкладайте.
Малі DOM-зміни в більшості інтерфейсів не потребують складної інфраструктури. Спочатку визначте вузьке місце, потім змінюйте структуру коду та повторно вимірюйте.
Зміни DOM можуть запускати перерахунок стилів, layout, paint і композицію.
Найкращий базовий підхід — пакетувати масові зміни та вставляти їх групою.
DocumentFragment, replaceChildren() і контрольоване використання innerHTML допомагають зменшити кількість операцій над живим документом.
Розділяйте читання геометрії та записи стилів, щоб не створювати layout thrashing.
Для частих візуальних оновлень використовуйте requestAnimationFrame.
Для анімацій перевіряйте, чи можна застосувати transform та opacity.
contain і content-visibility можуть обмежити область роботи браузера.
Для дуже великих списків ефективніша віртуалізація, ніж створення всіх елементів.
Завжди перевіряйте оптимізацію через профілювання, а не лише за припущеннями.