Головна / Статті

Перерозподіл, перемальовування, композиція: скільки кожна зміна CSS коштує браузеру

Дізнайтеся, як працюють HTML та CSS у DOM, CSSOM, під час формування макету, фарбування та композиції елементів, зрозумійте, чому зміни ширини коштують більше зусиль, ніж зміни кольору, та як уникнути проблем із формуванням макету.

Видавець: REACTAPP.TOP16 хв читання3720 слів

Джерела: Browser Rendering Pipeline Explained: From HTML to Pixels + Reflow vs Repaint

Одна рядок маркування, наприклад заголовок, здається простим, проте браузер виконує довгий ланцюжок операцій, перш ніж він стане пікселями на екрані. Розуміння цього ланцюжка дозволяє відповісти на практичні запитання: чому зміна розміру елемента може уповільнити сторінку, чому анімації зазвичай пишуться за допомогою transform, чому одна зміна може вплинути на десятки інших елементів та чому інтенсивна маніпуляція DOM призводить до труднощів у роботі інтерфейсу. Цей посібник розглядає кожен етап процесу відображення, а потім за допомогою цієї моделі пояснює різницю між процесами reflow та repaint, а також звички, які забезпечують швидку роботу інтерфейсу.

<h1>Hello World</h1>

Огляд процесу

У своїй суті процес відображення — це процедура, яка перетворює три види вхідних даних: маркування, стилі та скрипти, на зображення, яке може бути відображено.

HTML + CSS + JavaScript
          ↓
       Pixels
          ↓
       Screen

Якщо розділити цей процес на окремі етапи, він виглядає приблизно так:

Parse HTML
   ↓
Build DOM
   ↓
Parse CSS
   ↓
Build CSSOM
   ↓
Create Render Tree
   ↓
Calculate Styles
   ↓
Layout / Reflow
   ↓
Paint / Repaint
   ↓
Composite
   ↓
Pixels on Screen

Коли люди говорять про те, наскільки швидко сторінка отримує свій перший видимий контент, вони зазвичай називають цей процес Критичним шляхом відображення. Пам’ятайте, що це модель, а не специфікація певного движка. Chrome, Firefox та Safari реалізують ці етапи по-різному, і у новіших оновленнях вони пропускають їх, якщо можуть довести, що це зайво. Проте ця модель все одно достатньо точна для аналізу продуктивності, що є важливим у повсякденній роботі з фронтендом.

Етап 1: парсинг HTML у DOM

Усе починається з документа HTML, який повертає сервер — наприклад, з невеликої сторінки з одним заголовком та одним абзацем:

<!DOCTYPE html>
<html>
  <body>
    <h1>Hello World</h1>
    <p>Welcome to my website.</p>
  </body>
</html>

Парсер читає цей маркап та створює Document Object Model — дерево вузлів, у якому кожен елемент та кожен фрагмент тексту має своє місце. У розгорнутому вигляді дерево для наведеного вище документа виглядає так:

HTML

<html>
  │
  └── body
       │
       ├── h1
       │    └── "Hello World"
       │
       └── p
            └── "Welcome..."

DOM фіксує те, що містить документ, та спосіб його ієрархічного розташування. Це також інтерфейс, з яким працюють ваші скрипти; тому подібний до наведеного нижче виклик пройшовся по цьому дереву, щоб знайти відповідний вузол:

document.querySelector("h1");

Парсинг відбувається поступово. Браузер не чекає на завантаження всього файлу, перш ніж почати створювати вузли, тому довга сторінка може почати відображатися, поки решта її змісту все ще завантажується.

Етап 2: парсинг CSS у CSSOM

Сама структура нічого не говорить про зовнішній вигляд. Для цього браузеру потрібні таблиці стилів, наприклад, ці два прості правила:

h1 {
  color: blue;
  font-size: 32px;
}

p {
  color: gray;
}

CSS обробляється та перетворюється на другу структуру — CSS Object Model. Корисним способом розділення цих двох структур є питання, на яке відповідає кожна з них:

DOM
 ↓
"What exists on the page?"

CSSOM
 ↓
"How should those things look?"

Для відображення потрібні відповіді на обидва питання. Саме тому шаблони стилів заважають процесу відображення.

Етап 3: об’єднання обох структур у дерево відображення

Коли DOM та CSSOM готові, браузер об’єднує їх у дерево відображення.

        DOM
         +
       CSSOM
         ↓
    Render Tree

У дереві відображення зберігається лише те, що буде фактично намальовано, разом із стилями, які до нього застосовуються. Розглянемо цей фрагмент:

<div>
  <h1>Hello</h1>
  <p>World</p>
</div>

Після об’єднання його можна уявити як дерево видимих елементів, які містять інформацію про свої стилі:

Render Tree

div
│
├── h1
│   ├── font-size: 32px
│   └── color: blue
│
└── p
    └── color: gray

Усе, що не бере участі у відображенні, залишається поза увагою. Елемент із display: none зовсім не має запису, що відрізняється від visibility: hidden: прихований елемент все одно займає простір, тому залишається в дереві елементів та продовжує брати участь у формуванні макету.

Етап 4: обчислення розрахованих стилів

Далі браузер обчислює остаточний, розрахований стиль для кожного елемента, який буде відображатися. Справжні таблиці стилів містять такі перекриваючі один одного правила:

.card {
  width: 300px;
}

.card p {
  font-size: 16px;
}

Для кожного елемента движок мусить у порядку вирішити три питання:

Which rules apply?
        ↓
Which rule wins?
        ↓
What is the final computed style?

Відповідь на них означає застосування всіх правил резолюції CSS:

  • каскадування
  • спадкування від елементів-батьків
  • специфічності селектора
  • перетворення на розраховані значення
  • стилі, які залежать від запитів до медіа

Результатом є один набір властивостей на елемент:

DOM + CSSOM
     ↓
Style Calculation
     ↓
Computed Styles

Етап 5: макетування, де визначається геометрія

На цьому етапі браузер знає, що потрібно показати та як це має виглядати. Проте він ще не знає, де розташувати кожну частину та яким буде її розмір. Вирішення цих питань — завдання макетування, яке перетворює вікно перегляду на набір розташованих коробок:

Viewport
┌────────────────────────────┐
│                            │
│   Header                   │
│                            │
├──────────────┬─────────────┤
│ Sidebar      │ Content     │
│              │             │
│              │             │
└──────────────┴─────────────┘

Під час макетування движок обчислює для кожної коробки в дереві відображення:

  • ширину та висоту
  • позицію
  • маржі та внутрішні проміжки
  • спосіб взаємозв’язку кожної коробки з її сусідами та предками
  • загальну геометрію сторінки

MDN підсумовує цей етап як визначення розміру та положення кожного вузла в дереві відображення. Оскільки коробки залежать одна від одної, макетування є проблемою всього дерева, а не окремого елемента.

Рефлоу: знову обчислення макету

Перший етап обробки геометрії просто називається макетуванням. Будь-який наступний етап, який запускається через зміну, зазвичай називається рефлоу, а деякі команди використовують неформальний термін relayout для позначення того ж процесу:

Layout
  ↓
Initial calculation

Reflow / ReLayout
  ↓
Subsequent layout calculation

У решті цього посібника використовується термін рефлоу, оскільки саме його можна знайти у більшості обговорень щодо продуктивності та інструментів.

Що спричиняє рефлоу

Почнемо з коробки, яка має фіксовану ширину:

.box {
  width: 200px;
}

Тепер скрипт змінює цю ширину під час виконання:

box.style.width = "500px";

Ця одна присвоєння ставить низку запитань, на які браузер мусить відповісти перед тим, як намалювати наступну картинку:

The box changed width.

What else is affected?
Does its parent change?
Do neighboring elements move?
Does the text wrap differently?
Does the height change?
Does the entire layout need recalculation?

Тому частина або весь сторінок може потребувати нового етапу макетування, після чого будуть виконані наступні етапи обробки:

Change width
    ↓
Style recalculation
    ↓
Layout / Reflow
    ↓
  Paint
    ↓
Composite

Властивості, які впливають на геометрію

Будь-яка властивість, яка впливає на розмір чи положення, може спричинити зміни в макеті. Найпоширеніші причини – це:

width
height
margin
padding
top
left
right
bottom
font-size

Зміна однієї з цих властивостей може змістити чи змінити розмір самого елемента, а також потенційно всього, що знаходиться навколо нього. Наприклад, обидві наступні зміни можуть змусити браузер перерахувати макет:

element.style.width = "500px";
element.style.marginLeft = "100px";

font-size також має бути у цьому списку, оскільки зміна розміру тексту впливає на висоту рядка та його форматування, що змінює висоту контейнерного блоку, а отже, і положення всього, що знаходиться після нього.

Чому перерахунок макету є складним

Розгляньмо типову структуру сторінки з кількома регіонами та вкладеним контентом:

Page
│
├── Header
├── Navigation
├── Sidebar
├── Main Content
│    ├── Card
│    ├── Card
│    ├── Card
│    └── Table
└── Footer

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

One Geometry Change
        ↓
Layout Recalculation
        ↓
Many Elements Recalculated
        ↓
      Paint
        ↓
    Composite

Таке розширення змушує виконувати зайві операції формування макету, що є дорогим, особливо коли це відбувається під час анімації чи перетягування.

Етап 6: малювання

Як тільки вирішуються три основні питання, браузер може почати створювати пікселі:

What to display
       +
Where to display it
       +
How it should look

Малювання перетворює розташовані елементи на операції малювання, а зрештою — на пікселі. Отриманий контент включає:

  • текст
  • кольори та фони
  • крайки
  • тіні
  • зображення
  • будь-які інші видимі декоративні елементи
Layout
  ↓
Paint
  ↓
Pixels

MDN описує малювання як крок, під час якого елементи перетворюються з формату макету на справжні пікселі на екрані.

Поновлене малювання: нові пікселі, та сама геометрія

Поновлене малювання потрібне тоді, коли елемент виглядає інакше, але займає точно ту саму площу. Класичним прикладом є зміна кольору тексту:

color: red;

Новий колір ніяк не впливає на жоден з наступних елементів:

Width
Height
Position
Layout relationships

Тож браузер може повністю пропустити процес формування макету та використати коротший шлях:

Style Recalculation
       ↓
Paint / Repaint
       ↓
   Composite

Reflow проти repaint поруч один з одним

Це розрізнення є основою більшості порад щодо продуктивності відображення, тому варто точно описати кожен аспект.

Reflow перераховує геометрію

Reflow означає, що розмір або положення принаймні одного елемента потрібно знову обчислити. Стандартним прикладом є встановлення ширини:

element.style.width = "500px";

Потім виконується повна послідовність етапів:

Change
  ↓
Style
  ↓
Layout / Reflow
  ↓
Paint
  ↓
Composite

Repaint лише змінює вигляд

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

element.style.color = "red";

Процес формування макету видаляється з цієї послідовності:

Change
  ↓
Style
  ↓
Paint / Repaint
  ↓
Composite

Кожен етап скорочується до одного рядка:

RELayout / Reflow
→ Geometry changes
Repaint
→ Visual appearance changes

Кнопка, яка змінює ширину або колір

┌─────────────────────┐
│       Buy Now       │
└─────────────────────┘

Припустимо, що її ширина змінюється з першого значення на друге:

width: 200px;
width: 400px;

Тепер кнопка займає вдвічі більше горизонтального простору, що може витіснити сусідні елементи або спричинити перенесення рядка. Це зміна геометрії, тому браузер перерозподіляє елементи та потім перемальовує їх:

Width changes
     ↓
Geometry changes
     ↓
  Reflow
     ↓
  Repaint

Тепер припустимо, що змінюється лише колір фону:

background-color: blue;

Кнопка зберігає свої точні розміри та положення, а змінюються лише її пікселі:

Color changes
     ↓
Geometry unchanged
     ↓
  Repaint

Взаємозв’язок є однонапрямковим

Коли змінюється макет, елементи опиняються в нових місцях, а для цього потрібні нові пікселі:

Layout changes
     ↓
Browser needs new pixels
     ↓
   Paint

Коротко кажучи, після перерозподілу елементів зазвичай відбувається їхнє перемальовування:

Reflow
   ↓
Usually followed by
   ↓
Repaint

Це правило не діє у зворотному напрямку. Перемальовування може відбутися самостійно без будь-якої обробки макету:

Repaint
   ↓
Does NOT necessarily require
   ↓
Reflow

Етап 7: об’єднання шарів у кадр

Сучасні двигуни не завжди малюють всю сторінку як єдине плоске зображення. Вони можуть розділити вміст на окремі шари та зібрати їх на останньому етапі об’єднання:

Layer 1
   +
Layer 2
   +
Layer 3
   ↓
Composite
   ↓
Final Frame

Наприклад, сторінка може мати шар фону, картку на окремому шарі та анімований елемент на ще одному шарі:

┌──────────────────────┐
│ Background Layer     │
│                      │
│   ┌──────────────┐   │
│   │ Card Layer   │   │
│   └──────────────┘   │
│                      │
│   ┌──────────────┐   │
│   │ Animation    │   │
│   └──────────────┘   │
└──────────────────────┘

Система об’єднання шарів складає їх один на одного для створення кожного кадру. Деякі елементи можуть бути підняті на власний шар, а у відповідних випадках браузери передають обробку об’єднання на GPU. Зміни, які лише переміщують або змішують існуючий шар, можна застосувати без повторного малювання його вмісту.

Чому transform є стандартом для анімації

Ось ефект наведення, який переміщує коробку шляхом анімації значення left, починаючи з нуля:

.box {
  left: 0;
}

і закінчуючи на відстані 100 пікселів праворуч:

.box:hover {
  left: 100px;
}

Оскільки left є частиною положення елемента в макеті, кожен крок анімації може вимагати перерахунку макету та повторного намальовування. Ту саму візуальну переміщення можна досягти за допомогою трансформації:

.box {
  transform: translateX(0);
}

.box:hover {
  transform: translateX(100px);
}

Трансформація переміщує вже намальований елемент, не змінюючи його макетну рамку, тож браузер часто може обробити всю анімацію в композиторі, без необхідності перерахунку макету чи намальовування кожної кадру. opacity має схожі властивості та зазвичай використовується для ефектів затемнення.

Вважайте це сильною стандартною практикою, а не обов’язковим правилом. Те, чи залишиться трансформація в композиторі, залежить від елемента, інших стилів, які до нього застосовані, способу розташування шарів та конкретного движка браузера.

Які етапи насправді запускаються після змін

У більшості випадків оновлення стилів розгортаються по одній з двох гілок після перерахунку стилів:

                    Change
                      ↓
              Recalculate Style
                      ↓
              ┌───────┴───────┐
              ↓               ↓
           Layout           No Layout
              ↓               │
            Paint             │
              ↓               ↓
              └───────┬───────┘
                      ↓
                  Composite
                     ↓
                  Screen

Чим менше дорогих етапів торкається часте оновлення, тим легше підтримувати плавну візуалізацію. MDN описує ту саму послідовність: перерахунок стилів, потім розташування елементів, потім нанесення кольору та композиція, де це необхідно. Зміни в параметрах transform або opacity на скомпонованому шарі можуть пропустити навіть етап нанесення кольору, який є найдешевшим з усіх.

Бюджет кадру: приблизно 16,67 мс

Більшість дисплеїв оновлюються приблизно 60 разів на секунду, що залишає фіксований проміжок часу для кожного кадру:

1 second
   ÷
60 frames
   ≈
16.67ms per frame

Іншими словами, щоб підтримувати 60 FPS, браузер має приблизно стільки часу на кожен кадр:

16.67ms

Цей бюджет кадру ділиться між усім, що має відбутися перед його відображенням: вашим JavaScript, обчисленням стилів, формуванням макету, нанесенням зображень та внутрішніми процедурами браузера. Коли загальний обсяг перевищує цей ліміт, кадр не відображається, і користувач бачить затримки:

16.67ms
   ↓
Exceeded
   ↓
Frame missed
   ↓
 Jank

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

  • переглядання з вертикальним скроллінгом
  • анімації
  • функція «перетягнути та відпустити»
  • зміна розміру вікна чи панелі
  • панелі керування, які оновлюють багато значень одночасно

MDN також підкреслює це: обчислення стилів, формування макету та нанесення зображень мають вписуватися в ліміт кадру, щоб анімація виглядала плавною.

Проблеми з формуванням макету: операції, які змушують виконувати синхронне формування макету

Особливо поширений спосіб марнування бюджету кадру на перший погляд здається безневинним у коді. У наведеному нижче фрагменті спочатку встановлюється ширина, потім читається розмір, після цього встановлюється висота та знову читається розмір:

element.style.width = "500px";
console.log(element.offsetWidth);
element.style.height = "500px";
console.log(element.offsetHeight);

Шаблон, який він створює, — це чергування записів та читань:

Write
 ↓
Read layout
 ↓
Write
 ↓
Read layout

Зазвичай браузер відкладає обробку макету до моменту малювання наступної кадру, тож кілька записів стилів можна обробити за один прохід. Однак властивості на кшталт offsetWidth та offsetHeight повинні повертати поточні значення. Читання їх одразу після запису змушує двигун синхронно та негайно обробити всі заплановані зміни стилів та макету. Якщо робити це постійно, часто всередині циклу над багатьма елементами, виникає проблема з обробкою макету: багато повних проходів обробки макету, коли достатньо було б лише одного.

Рішення полягає у групуванні операцій так, щоб спочатку відбувалося лише читання, а вже після цього — записи:

Bad:
Write
Read
Write
Read
Write
Read

Better:
Read
Read
Read
Write
Write
Write

Спосіб реструктурування коду залежить від застосунку, але принцип завжди однаковий: не змушуйте браузер перераховувати макет частіше, ніж це необхідно. У блозі є детальніший огляд проблеми з макетом та колекціями live DOM, якщо ви хочете більше прикладів примусового оновлення макету.

Практичні техніки для ефективнішого відображення

Анімація transform та opacity

Замість того, щоб переміщувати елемент за допомогою властивостей макету, наприклад:

left: 100px;

використовуйте властивість transform, яка відповідає дизайну:

transform: translateX(100px);

Для поступового зникнення та з’явлення елемента змінюйте ці два значення:

opacity: 0;
opacity: 1;

це зазвичай кращий варіант, ніж анімувати властивості, які впливають на макет.

Мінімізуйте зміни в DOM

Кожна мутація DOM може призвести до додаткової роботи для браузера. Додавання вузлів один за одним у такій послідовності:

element.appendChild(...);
element.appendChild(...);
element.appendChild(...);

часто краще замінити на створення нового контенту поза документом (наприклад, DocumentFragment) та його одноразове вставлення. Чим більший та складніший DOM, тим більшою є вартість кожної зайвої зміни.

Групове оновлення замість чергування

Код, який чергує оновлення з роботою над форматуванням, у кінцевому підсумку має таку послідовність:

Update
 ↓
Layout
 ↓
Update
 ↓
Layout
 ↓
Update
 ↓
Layout

Мета полягає у тому, щоб надати браузеру групу змін, які він може обробити разом:

Update
Update
Update
 ↓
Browser processes the changes

Для анімацій та іншої візуальної роботи високої частоти requestAnimationFrame() планує виконання оновлень безпосередньо перед тим, як браузер відобразить наступну кадр, що дозволяє узгодити їх із циклом відображення, а не протидіяти йому.

Не анімуйте властивості макету

Властивості, які змінюють геометрію, є поганими кандидатами для чогось, що виконується кожну фрейм:

width
height
margin
padding
top
left
right
bottom
font-size

Кожна з них може спричинити оновлення макету. Там, де це дозволяє візуальний результат, краще анімувати саме їх:

transform
opacity

Звільніть місце для зображень

Тег зображення без розмірів не дає браузеру жодних підказок щодо необхідного простору:

<img src="image.jpg">

Коли файл надходить та стає відомим його реальний розмір, навколишній контент зміщується, і макет оновлюється знову. Вказання внутрішнього розміру заздалегідь уникає цього:

<img
  src="image.jpg"
  width="800"
  height="600"
  alt="Example"
/>

CSS-властивість aspect-ratio досягає такого ж ефекту для адаптивних зображень. У будь-якому випадку браузер може звільнити потрібний простір до завантаження зображення, що запобігає помітному зміщенню макету.

Вимірюйте перед оптимізацією

Не кожна зміна зовнішнього вигляду чи етап формування макету є проблемою, яку варто вирішувати; двигуни сильно оптимізовані та швидко виконують більшу частину цієї роботи. Орієнтуйтеся на докази за допомогою простого циклу:

Performance Problem
       ↓
    Measure
       ↓
Find Bottleneck
       ↓
    Optimize
       ↓
 Measure Again

Панель Performance у Chrome DevTools фіксує часову шкалу, яка показує:

  • виконання JavaScript
  • перерахунок стилів
  • формування макету
  • нанесення зображень
  • обробку композиції
  • тривалі завдання
  • час виконання кожної кадру

Виправляйте ту проблему, яку насправді виявляє профілювання, а не код, який лише здається витратним при читанні.

Увесь процес у одному зображенні

Якщо розглядати шлях від джерела до екрана поетапно, він виглядає так:

                HTML
                  │
                  ↓
             Parse HTML
                  │
                  ↓
                 DOM
                  │
                  │
CSS ──→ Parse CSS ──> CSSOM
                  │
                  ↓
          DOM + CSSOM
                  │
                  ↓
             Render Tree
                  │
                  ↓
          Style Calculation
                  │
                  ↓
           Layout / Reflow
                  │
                  ↓
            Paint / Repaint
                  │
                  ↓
             Composite
                  │
                  ↓
           Pixels on Screen

А коли щось змінюється після першого відображення, ключовим моментом прийняття рішень є те, чи змінилась геометрія:

CSS / DOM Change
       ↓
Style Recalculation
       ↓
Does geometry change?
       │
    ┌──┴──┐
   YES    NO
    │      │
    ↓      ↓
 Layout   Paint
    │      │
    └──┬───┘
       ↓
   Composite
       ↓
     Screen

Основні висновки

Три короткі узагальнення охоплюють основне. Перерозподіл елементів — це дорогий варіант:

RELayout / Reflow
        ↓
Changes geometry
        ↓
Width / Height / Position / Layout
        ↓
Usually leads to Paint

Перемальовування — дешевший варіант:

     Repaint
        ↓
Changes appearance
        ↓
Color / Background / Shadow / etc.
        ↓
Doesn't necessarily require Layout

А щодо анімацій, вибір властивостей у значній мірі визначає витрати:

Layout properties
    ↓
Potentially expensive

transform + opacity
    ↓
Often better for smooth animations

Вам не потрібно знати внутрішню структуру кожного двигуна, але ви повинні мати змогу зрозуміти загальний процес:

HTML
 ↓
DOM

CSS
 ↓
CSSOM

DOM + CSSOM
 ↓
Render Tree
 ↓
Style
 ↓
Layout
 ↓
Paint
 ↓
Composite
 ↓
Pixels

З урахуванням цієї моделі:

  • Зміни геометрії, такі як width, спричиняють перерозподіл елементів, а потім перемальовування; зміни вигляду, такі як color, зазвичай потребують лише перемальовування.
  • transform та opacity часто дозволяють пропустити етапи розташування та малювання, тому вони підходять для анімацій.
  • Змішування записів у DOM із читанням даних про розташування призводить до проблем з розташуванням елементів; краще спочатку здійснювати читання, а потім записи.
  • Зображення повинні містити інформацію про розміри або співвідношення сторін, а кожна оптимізація має починатися з аналізу профілю у DevTools.
  • Браузер завжди виконує реальну роботу з перетворення коду на пікселі. Мета не в тому, щоб усунути цю роботу, а щоб не вимагати більше зусиль, ніж насправді потрібно для сторінки.