🖼 Rendering Pipeline: Parse → Style → Layout → Paint → Composite

💡 Основная идея

Не все изменения стилей одинаково затратны. То, что триггерит конкретное CSS-свойство — Layout, Paint или просто Composite — решает, будет ли анимация плавной или начнёт терять кадры.


🧭 Полный пайплайн

Parse HTML → DOM
Parse CSS  → CSSOM
DOM + CSSOM → Render Tree (исключает display:none)
Render Tree → Layout      (геометрия: x, y, width, height)
Layout      → Paint       (пиксели: цвет, текст, тени)
Paint       → Composite   (GPU собирает слои)

element.style.width = '100px' не триггерит layout немедленно. Браузер просто помечает элемент (и часто его поддерево) как “грязный” и откладывает пересчёт до следующей естественной точки рендера — шага, который идёт сразу после того, как очередь микрозадач опустеет и отработают колбэки rAF в Event Loop.

Важное различие: CSSOM (распарсенные правила из <style>/стилшитов) и инлайновый element.style (объект CSSStyleDeclaration, привязанный к атрибуту style одного элемента) — разные объекты. Запись el.style.width = ... никогда не трогает CSSOM — она помечает “грязным” только этот один элемент.


⚡ Что триггерит каждую стадию

Layout:          width/height, top/left/right/bottom, margin/padding,
                  border-width, font-size, display, добавление/удаление DOM-узлов
Paint:            color, background, box-shadow, border-radius, visibility, outline
Только Composite: transform, opacity

Почему transform/opacity дёшевы

Не из-за того, что означают эти свойства — а из-за того, где они обрабатываются. Если у элемента уже есть свой слой компоузинга (текстура GPU — от will-change, идущей анимации, position: fixed, <video>, <canvas> и т.д.), изменение transform/opacity — это просто применение матрицы трансформации или значения альфа-канала к уже отрастеризованной текстуре, целиком на потоке компоузитора. Layout и Paint вообще не выполняются.

  • transform — это не категория “анимационных свойств”, это геометрические трансформации (translate/scale/rotate/skew/matrix), которые просто дёшево анимировать.
  • opacity — это не яркость, это альфа-канал (0 = невидимо, 1 = непрозрачно).

Все функции transform компилируются в одну каноническую форму:

  • translate(x, y) — сдвиг по осям (translateX/Y/Z, translate3d)
  • scale(x, y) — изменение размера (scale(1.5) = больше в 1.5 раза); scaleX/scaleY независимо по каждой оси
  • rotate(angle) — вращение вокруг точки (по умолчанию: центр элемента); rotateX/Y/Z для 3D
  • skew(x-angle, y-angle) — сдвиг (shear): превращает прямоугольник в параллелограмм
  • matrix(a, b, c, d, e, f) — низкоуровневая аффинная матрица 2×3, в которую компилируется всё вышеперечисленное (matrix3d — 4×4, для 3D)

🧵 Main thread vs Compositor thread

Main thread:        выполнение JS → Style calc → Layout → Paint
Compositor thread:  Composite (transform/opacity)

Main thread — это место, где живут Event Loop, стек вызовов и весь JS — Style, Layout и Paint тоже находятся там. Если main thread заблокирован (долгий синхронный JS, forced reflow), всё останавливается: скролл, клики и любая анимация на width/top.

Compositor thread отдельный и не блокируется main thread’ом. К моменту выполнения Composite DOM/CSSOM уже не имеют значения: Paint (на main thread) уже превратил элемент в отрастеризованную GPU-текстуру. Compositor thread видит только готовые текстуры и матрицы трансформаций — он никогда не трогает дерево DOM. Composite — это не “рисование поверх HTML/CSS-скелета”, это “размещение уже готовых изображений на экране”.

Как формируются слои компоузинга

Слой — это не DOM-элемент, а область пикселей плюс правило её позиционирования. Он состоит из:

  • display list — записанные командами рисования от Paint (“залить этот прямоугольник”, “нарисовать этот текст”) для той части DOM, что входит в слой
  • растеризованной текстуры (bitmap) — результата выполнения display list, обычно нарезанного на тайлы (например, 256×256px) для эффективной перезагрузки
  • свойств трансформации — матрицы (transform), opacity и позиции в системе координат компоузитора

Шаги формирования:

  1. Style — вычисляются значения CSS
  2. Layout — вычисляется геометрия (размеры, позиции)
  3. Построение дерева слоёв (layerization) — main thread решает, какие элементы получат собственный слой компоузинга. Триггеры: анимируемые transform/opacity, will-change, position: fixed, <video>, <canvas>. Всё остальное рисуется в слое родителя.
  4. Paint — main thread не рисует пиксели, а записывает display list для каждого слоя
  5. Растеризация — display list превращается в реальные пиксели, обычно через GPU (например, GPU-бэкенд Skia), часто на отдельных потоках растеризации
  6. Compositing — compositor thread собирает готовые текстуры слоёв в финальный кадр, применяя transform/opacity каждого слоя целиком

Шаги 1–4 выполняются на main thread; шаги 5–6 — на отдельных потоках. Если у элемента, у которого уже есть слой, меняется только transform/opacity, шаги 1–4 полностью пропускаются — сразу к шагу 6 с уже существующей текстурой.

Почему каждый слой рендерится в свою текстуру: её можно переиспользовать без повторной отрисовки. Если слой уже содержит что-то дорогое (текст, тени, градиенты) и его просто перемещают (translateX), нет нужды заново выполнять команды рисования — GPU просто перепозиционирует готовую текстуру. Это также распараллеливает работу: разные слои растеризуются одновременно на разных потоках, и перерисовывается только тот слой, который реально изменился.


🐢 Принудительный синхронный layout

Если вы записываете стиль и сразу читаете зависящее от layout свойство (offsetWidth, getBoundingClientRect(), clientHeight, scrollTop, …), браузер вынужден пересчитать геометрию синхронно, прямо здесь, чтобы дать точный ответ — он не может отложить это до следующей точки рендера.

// плохо: 1000 forced layouts, по одному на итерацию
items.forEach((item) => {
  item.style.width = "100px" // запись — помечает элемент грязным
  console.log(item.offsetWidth) // чтение — форсирует полный пересчёт СЕЙЧАС
})
 
// хорошо: один layout на весь батч
items.forEach((item) => {
  item.style.width = "100px"
}) // сначала все записи
items.forEach((item) => {
  console.log(item.offsetWidth)
}) // затем все чтения — первое чтение пересчитывает, остальные берутся из кэша

Почему это дорого: layout вычисляет геометрию не только для затронутого элемента — обычно для большой затронутой части дерева (элементы влияют друг на друга через flexbox, проценты, реflow текста). Это как перемерять 1000 стен и вызывать инспектора после каждой, вместо того чтобы вызвать его один раз в конце.


🧠 В одном предложении

Изменения width/top стоят Layout + Paint на main thread; изменения color/shadow стоят только Paint; transform/opacity пропускают оба этих шага и остаются на compositor thread — в этом вся причина предпочитать анимировать именно их.

Browser Event Loop — как рендер встраивается в цикл browser