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