Групування змін DOM
Групування змін DOM, це підхід, за якого всі читання геометрії роблять разом, потім разом роблять усі записи, і все це вкладається в один кадр. Так браузер перераховує layout один раз замість десятків разів, а інтерфейс перестає гальмувати на списках, скролі та анімаціях.
Теорія
TL;DR
- Спочатку всі читання (measure), потім усі записи (mutate), не частіше ніж раз на кадр.
- Читання
getBoundingClientRect(),offsetHeight,scrollTop,getComputedStyle()викликають синхронний layout; чергування читань і записів дає layout thrashing. requestAnimationFrameкоалесцює записи в один кадр (близько 16.6 мс при 60 FPS).- Нові вузли збирайте у
DocumentFragmentабо з<template>і вставляйте однією операцією. - Один
classList.add()дешевший за десяток присвоєньstyle.*. - Анімуйте лише
transformіopacity, обмежуйте перерахунок черезcontainіcontent-visibility, а великі списки віртуалізуйте.
Швидкий приклад
// Погано: write, read, write, read, кожне читання форсує layout
el.style.width = '400px';
el.offsetHeight; // forced reflow
el.style.height = '200px';
el.getBoundingClientRect(); // forced reflow
// Добре: спочатку всі читання, потім усі записи в одному кадрі
const h = el.offsetHeight;
const r = el.getBoundingClientRect();
requestAnimationFrame(() => {
el.style.width = '400px';
el.style.height = '200px';
});Measure, потім mutate
Читання геометрії (getBoundingClientRect, offsetHeight, scrollTop, getComputedStyle) змушують браузер синхронно перерахувати layout, бо він мусить віддати вам актуальне значення. Якщо чергувати читання і запис, ви отримаєте layout thrashing: кожна ітерація циклу коштує повного перерахунку.
Правильний порядок:
// Збираємо все читання геометрії...
const rects = items.map((el) => el.getBoundingClientRect());
// ...а потім однією пачкою застосовуємо записи (стилі, класи)
requestAnimationFrame(() => {
items.forEach((el, i) => {
el.style.transform = `translateY(${rects[i].top}px)`; // лише запис
});
});Бетчинг через requestAnimationFrame зводить усі зміни до одного кадру:
let pending = [];
function batchStyle(fn) {
pending.push(fn);
if (pending.length === 1) {
requestAnimationFrame(() => {
const jobs = pending;
pending = [];
jobs.forEach((job) => job());
});
}
}
// Використання
batchStyle(() => el.classList.add('active'));
batchStyle(() => (el.style.opacity = '1'));Будувати вузли поза документом
DocumentFragment живе поза деревом документа, тож наповнення його нічого не коштує, а вставка робить один reflow:
const frag = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Row ${i}`;
frag.appendChild(li);
}
list.appendChild(frag); // один reflow замість 1000Те саме з <template>, коли розмітка рядка описана в HTML:
const tpl = document.getElementById('row-tpl');
const frag = document.createDocumentFragment();
data.forEach((d) => {
const node = tpl.content.cloneNode(true);
node.querySelector('.title').textContent = d.title;
frag.appendChild(node);
});
list.appendChild(frag);Третій варіант, innerHTML, коли це безпечно: зібрати рядок і вставити його один раз. Важливо: вміст, що приходить від користувача, так вставляти не можна, це XSS.
Менше записів і менший обсяг DOM
Клас замість десятка інлайнових стилів. Один classList.add('expanded') дешевший за 10 присвоєнь style.*, бо це одна зміна для рушія стилів.
el.classList.add('expanded'); // всі властивості вже описані в CSSСховати, змінити, показати. Інколи вигідно виключити блок з потоку, внести пачку правок і повернути його назад.
container.style.display = 'none';
/* багато DOM-операцій */
container.style.display = '';Використовуйте обережно: це скидає проміжні вимірювання, позицію скролу і фокус усередині блоку.
Анімуйте лише transform і opacity. Вони не спричиняють reflow, зазвичай це лише repaint і composite, тобто дешево.
.card {
will-change: transform, opacity;
}CSS-контейнери і пропуск рендера. content-visibility: auto дозволяє браузеру пропускати layout і paint для невидимого контенту, а contain: layout paint обмежує область перерахунку межами елемента.
.section {
content-visibility: auto;
contain-intrinsic-size: 1000px; /* щоб layout не стрибав */
}Debounce і throttle подій. Scroll, resize та введення тексту можуть тригерити сотні апдейтів на секунду, тож збирайте їх у пачки.
const onScroll = throttle(() => {
requestAnimationFrame(updateStickyHeader);
}, 100);Віртуалізація великих списків. Рендерте лише видимі елементи (react-window, virtual scroller або власна реалізація). Це радикально скорочує обсяг DOM, а отже і роботу layout та paint.
Готовий шаблон measure / mutate
const reads = [];
const writes = [];
export function read(fn) {
reads.push(fn);
schedule();
}
export function write(fn) {
writes.push(fn);
schedule();
}
let scheduled = false;
function schedule() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
// 1) усі читання
let r;
while ((r = reads.shift())) r();
// 2) усі записи
let w;
while ((w = writes.shift())) w();
scheduled = false;
});
}Використання:
read(() => {
boxRect = box.getBoundingClientRect();
});
write(() => {
box.style.transform = `translateY(${boxRect.top}px)`;
});Типові помилки
- Читати геометрію всередині циклу, який одразу ж пише стилі. Це і є layout thrashing, і він найдорожчий з усього переліченого.
- Вставляти вузли по одному в
document.bodyзамість того, щоб зібрати їх у фрагмент. - Вважати, що
requestAnimationFrameсам по собі прискорює код. Він лише переносить роботу в потрібну фазу кадру; якщо всередині колбека чергувати читання і записи, thrashing нікуди не подінеться. - Ставити
will-changeна все підряд: кожен шар композиції коштує пам'яті, надлишкові шари гальмують сторінку. - Використовувати
display: noneяк універсальний трюк, забуваючи, що він скидає скрол, фокус і стан програвання медіа всередині. - Вставляти користувацький рядок через
innerHTML: це пряма XSS-вразливість, а не оптимізація. - Оптимізувати наосліп. Спершу запис у вкладці Performance, пошук довгих Layout і Recalculate Style, і лише потім зміни в коді.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.