Skip to main content

Коли відбуваються reflow та repaint у браузері

Reflow відбувається, коли браузер перераховує геометрію елементів після змін, що впливають на layout. Repaint перемальовує пікселі для візуальних змін, які нічого не зміщують.

Теорія

TL;DR

  • Reflow схожий на перестановку меблів: коли один предмет зміщується, решта теж можуть зрушити
  • Repaint схожий на перефарбовування стіни: нічого не рухається, змінюються тільки кольори пікселів
  • Кожен reflow викликає repaint, але repaint не викликає reflow
  • Зміна width або height = reflow + repaint. Зміна кольору = тільки repaint
  • Анімації через transform та opacity можуть обійти обидва процеси (compositor-only)

Швидкий приклад

html
<div id="box" style="width:100px; height:100px; background:red; margin:20px;"></div> <div id="sibling">Сусідній елемент</div> <script> const box = document.getElementById('box'); // Тільки repaint: колір не впливає на геометрію, сусід залишається на місці box.style.background = 'blue'; // Reflow + repaint: зміна висоти зміщує сусідній елемент вниз box.style.height = '200px'; </script>

Зміна кольору швидка: браузер перемальовує тільки пікселі. Зміна висоти запускає повний перерахунок layout: браузер піднімається до батька, потім спускається до сусідів і оновлює координати всіх залежних елементів.

Як браузер обробляє зміни

Браузер будує render tree з DOM та CSSOM. Під час layout (reflow) рушій призначає кожному вузлу точні пікселі та розміри за допомогою formatting contexts. Результат растеризується на екран через GPU у Blink/Chromium або через CPU у Gecko.

Коли щось змінюється, браузер позначає залежні вузли як "dirty". На наступному paint-фреймі (приблизно кожні 16ms при 60fps) він перераховує layout-піддерево від зміненого вузла вгору до предків і вниз до нащадків та сусідів. Саме це поширення робить reflow дорогим на складних сторінках.

Repaint пропускає весь геометричний етап. Рушій перерастеризує тільки пікселі для зміненої візуальної властивості. Сусідні елементи не зачіпаються.

Головна різниця

Reflow перебудовує геометрію від зміненого вузла назовні: батьки, діти та сусіди можуть усі зміститися. Repaint оновлює тільки пікселі самого елемента. Тому display: none викликає reflow (елемент виходить з document flow, простір стискається), а visibility: hidden викликає тільки repaint (простір залишається, нічого не зміщується).

Коли відбувається reflow

  • Додавання або видалення DOM-елементів
  • Зміна width, height, margin, padding, border-width
  • Зміна position, top, left, float
  • Зміна font-size, font-family, line-height
  • Перемикання display між none та block
  • Зміна розміру вікна браузера
  • Читання layout-властивостей: offsetWidth, offsetHeight, getBoundingClientRect()

Останній пункт часто дивує розробників. Читання layout-властивості в середині фрейму змушує браузер негайно скинути чергу "dirty"-вузлів та перерахувати layout, навіть якщо це відбувається під час анімації.

Коли відбувається repaint (без reflow)

  • Зміна color, background-color, background-image
  • Зміна border-color, box-shadow, outline
  • Перемикання visibility (не display)
  • Зміна opacity на некомпозитованих елементах

Примусовий синхронний reflow

html
<div id="anim" style="width:100px; transition:width 1s; background:blue;"></div> <script> const el = document.getElementById('anim'); el.style.width = '200px'; // додає вузол до черги "dirty", reflow ще не виконано // Читання offsetWidth тут змушує браузер негайно скинути чергу // Це руйнує плавність анімації console.log(el.offsetWidth); // примусовий синхронний reflow // Рішення: читати layout-властивості до будь-яких записів у тому ж фреймі </script>

У Chrome DevTools це видно як жовті спайки "Layout" у flame chart вкладки Performance. useLayoutEffect у React навмисно запускається після reflow і до repaint, саме тому він блокує відмальовування.

Типові помилки

Читання та запис у циклі

js
// Погано: кожне читання offsetTop скидає чергу - N reflow для N елементів for (let i = 0; i < elements.length; i++) { elements[i].style.top = elements[i].offsetTop + 10 + 'px'; } // Правильно: спочатку всі читання, потім всі записи const tops = elements.map(el => el.offsetTop); // один reflow elements.forEach((el, i) => { el.style.top = tops[i] + 10 + 'px'; // тільки записи });

Анімація layout-властивостей

js
// Погано: reflow на кожному фреймі анімації element.style.left = currentX + 'px'; // Правильно: compositor обробляє це без reflow element.style.transform = `translateX(${currentX}px)`;

Таблиці з динамічними рядками

css
/* Додавання одного рядка перераховує всі комірки таблиці */ table { display: table; } /* Рішення: flexbox або grid для динамічних списків */ .list { display: flex; flex-direction: column; }

Завантаження шрифту без font-display

css
/* Без font-display браузер ховає текст до завантаження шрифту, а потім викликає reflow при його появі */ @font-face { src: url(font.woff2); font-display: swap; /* запобігає layout shift при завантаженні */ }

Де зустрічається в реальних проектах

  • React: useLayoutEffect запускається після reflow і до repaint для DOM-вимірювань, використовується в react-window для розрахунків віртуальних списків
  • GSAP: xPercent та scaleX не викликають reflow, на відміну від анімації left або width
  • Swiper.js: contain: layout на контейнерах слайдів ізолює reflow каруселі від решти сторінки
  • Chart.js: рендеринг через <canvas> повністю обходить DOM reflow для анімацій графіків

Я якось налагоджував карусель, де читання offsetWidth всередині циклу анімації давало 60 reflow на секунду. Перенесення читання за межі циклу скоротило час layout з ~60ms до менш ніж 2ms на фрейм.

Питання на співбесіді

Q: Яка різниця між reflow, repaint та compositing?
A: Compositing змішує вже відмальовані шари на GPU без будь-якого звернення до DOM. transform та opacity на promoted-шарах тільки компонуються. Reflow відповідає за геометрію, repaint за пікселі, compositing просто змішує шари.

Q: Як виміряти reflow у браузері?
A: Відкрий Chrome DevTools, перейди до вкладки Performance, запиши дію та шукай події "Layout" довше 1ms. "Recalculate Style" це робота з CSSOM, а не reflow.

Q: Чи не бере участь у reflow елемент з position: fixed?
A: Ні, fixed-елементи все одно викликають reflow. contain: layout на компоненті справді ізолює поширення reflow.

Q: Як CSS contain впливає на поширення reflow?
A: contain: layout повідомляє браузеру, що зміни всередині елемента не впливають на layout зовні. Це зупиняє поширення reflow вгору по дереву, що важливо для web components та великих компонентних бібліотек.

Q: Чому display: none викликає reflow, а visibility: hidden тільки repaint?
A: display: none видаляє елемент з document flow повністю: сусіди зміщуються, батько змінює розміри. visibility: hidden зберігає простір, тому нічого навколо не рухається.

Q: Чому в React StrictMode reflow відбувається двічі?
A: React StrictMode навмисно запускає ефекти двічі в режимі розробки для виявлення побічних ефектів. У production кожен lifecycle запускається один раз.

Приклади

Зміна кольору vs. зміна розміру

html
<!DOCTYPE html> <html> <body> <div id="box" style="width:100px; height:100px; background:red; margin:20px;">Box</div> <div id="sibling" style="background:lightgray; padding:10px;">Сусід</div> <script> const box = document.getElementById('box'); // Тільки repaint: сусід залишається на місці box.style.background = 'blue'; setTimeout(() => { // Reflow: сусід зміщується вниз зі збільшенням висоти box box.style.height = '200px'; }, 1000); </script> </body> </html>

Зміна кольору проходить тільки через repaint. Зміна висоти перераховує позиції і box, і сусіда. На 100 елементах при 60 кадрах на секунду різниця у часі відмальовування помітна без профайлера.

Групування читань та записів у grid-інтерфейсі

js
// Контекст: зміна розміру карток у dashboard // Погано: перемежовані читання та записи, один reflow на картку function resizeCardsBad(cards) { cards.forEach(card => { const h = card.offsetHeight; // читання - викликає reflow card.style.height = h + 50 + 'px'; // запис }); } // Правильно: спочатку всі читання, потім всі записи (один reflow) function resizeCardsGood(cards) { const heights = cards.map(card => card.offsetHeight); // всі читання cards.forEach((card, i) => { card.style.height = heights[i] + 50 + 'px'; // всі записи }); } // Ще краще: ResizeObserver прибирає явні layout-читання з коду const observer = new ResizeObserver(entries => { entries.forEach(entry => { const { height } = entry.contentRect; // браузер надає це без примусового reflow entry.target.setAttribute('data-height', height); }); }); cards.forEach(card => observer.observe(card));

Варіант із групуванням зводить N reflow до одного. ResizeObserver прибирає явні layout-читання з коду і дозволяє браузеру самому планувати вимірювання.

Compositor-only анімація (без reflow та repaint)

js
// Погано: left викликає reflow на кожному фреймі function animateBad(element) { let pos = 0; function step() { pos += 2; element.style.left = pos + 'px'; // reflow на кожному фреймі if (pos < 300) requestAnimationFrame(step); } requestAnimationFrame(step); } // Правильно: transform запускається на compositor thread, нуль reflow function animateGood(element) { let pos = 0; element.style.willChange = 'transform'; // переносимо на окремий GPU-шар function step() { pos += 2; element.style.transform = `translateX(${pos}px)`; // тільки compositor if (pos < 300) requestAnimationFrame(step); } requestAnimationFrame(step); }

При 60fps поганий варіант запускає 60 reflow на секунду і може блокувати main thread. Хороший варіант запускає нуль. Це зазвичай найбільший виграш у продуктивності анімацій у frontend-проектах.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?