Главная / Статьи

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

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

Издатель: REACTAPP.TOP16 мин чтения3720 слов

Источники: Browser Rendering Pipeline Explained: From HTML to Pixels + Reflow vs Repaint

Одна строка маркировки, такая как заголовок, кажется простой, однако браузер выполняет длинную цепочку операций, прежде чем она превратится в пиксели на экране. Знание этой цепочки позволяет отвечать на практические вопросы: почему изменение размера элемента может замедлить загрузку страницы, почему анимации обычно создаются с использованием transform, почему одно изменение может повлиять на десятки других элементов и почему интенсивная манипуляция DOM приводит к тормозам интерфейса. В этом руководстве рассматривается каждый этап процесса отрисовки, а затем с помощью этой модели объясняется разница между процессами перерисовки и обновления содержимого, а также привычки, которые способствуют быстрой работе интерфейса.

<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. Полезным способом разделения этих двух структур является определение вопроса, на который отвечает каждая из них:

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 описывает этот этап как определение размеров и местоположения каждого узла в дереве отрисовки. Поскольку элементы зависят друг от друга, макетирование представляет собой задачу для всего дерева, а не отдельного элемента.

Перерасчёт макета: повторная обработка лейаута

Первый этап обработки геометрии просто называется лейаутом. Любой последующий этап, запускаемый из-за изменений, обычно называется перерасчётом макета, а некоторые команды для обозначения того же явления используют неформальный термин «перелейаут»:

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 является частью позиции элемента в макете, каждый шаг анимации требует пересчета макета и повторной отрисовки. То же визуальное движение можно реализовать с помощью свойства transform:

.box {
  transform: translateX(0);
}

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

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

Какие этапы на самом деле запускаются при изменении

При большинстве обновлений стилей работа распределяется по одной из двух веток после пересчёта стилей:

                    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, должны возвращать текущие значения. Чтение их сразу после записи заставляет движок синхронно и немедленно обрабатывать все накопившиеся операции по стилям и макету. Если делать это многократно, часто в цикле над большим количеством элементов, возникает явление layout thrashing: множество полных проходов по обработке макета, в то время как хватило бы одного.

Решение заключается в группировке операций таким образом, чтобы сначала выполнялись все операции чтения, а затем — все операции записи:

Bad:
Write
Read
Write
Read
Write
Read

Better:
Read
Read
Read
Write
Write
Write

Способ реструктуризации кода зависит от приложения, но принцип всегда один и тот же: не заставляйте браузер пересчитывать макет чаще, чем это необходимо. В блоге представлены более подробные объяснения по теме проблем пересчета макета и использования динамических коллекций 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

Панель производительности в 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.
  • Браузер всегда выполняет реальную работу по преобразованию кода в пиксели. Цель не в том, чтобы устранить эту работу, а в том, чтобы не требовать от него больше, чем действительно нужно странице.