Как работает браузер: парсинг, DOM, CSSOM, рендер, композитинг
Фронтендер, который не знает, как браузер превращает текст в пиксели, обречён чинить
производительность методом тыка: «добавил will-change, вроде полегчало», «перенёс скрипт
вниз — не трогайте». Это работает до первого серьёзного проекта. Дальше начинается магия:
список из 500 строк дёргается при скролле, вёрстка прыгает при загрузке шрифта, кнопка
отвечает на клик через 400 мс, а Lighthouse ставит 40 баллов и не объясняет почему.
Магия исчезает, как только появляется модель конвейера. Браузер — классический компилятор-и-рантайм: токенизирует входной текст, строит два дерева, соединяет их, вычисляет геометрию, записывает список команд рисования, растрирует его на нескольких потоках и отдаёт видеокарте прямоугольники с текстурами. Каждая стадия имеет цену, и почти вся оптимизация фронтенда сводится к тому, чтобы не запускать дорогие стадии там, где хватает дешёвых.
Браузер — это не одна программа
Современный Chromium (по той же схеме — Firefox и WebKit) разложен на несколько процессов ОС, которые общаются через IPC.
Зачем усложнение? Надёжность — падение рендерера убивает вкладку, а не браузер. Безопасность — renderer сидит в песочнице без доступа к файлам и сети, а Site Isolation (по умолчанию с Chrome 67) кладёт каждый сайт в свой процесс, чтобы атаки вроде Spectre не читали память соседнего домена. Параллелизм — растр и композитинг уходят с главного потока.
Ключевой вывод: в renderer-процессе главный поток один. На нём живут парсинг HTML, стили, layout, paint, сборка мусора и весь ваш JavaScript: пока цикл крутится 200 мс, браузер не обработает клик и не отрисует кадр. Compositor при этом скроллит уже отрисованные слои — отсюда ощущение «скроллится, но не нажимается».
Путь от URL до первого пикселя
Запомните порядок: DOM + CSSOM → Style → Layout → Paint → Composite. Каждый шаг зависит от предыдущего, поэтому изменение на входе заставляет переиграть всё, что ниже по течению. Отсюда правило стоимости: правка, затрагивающая только композитинг, в десятки раз дешевле правки, запускающей layout всего документа.
Шаг 0. Навигация: пикселей ещё нет
Парсинг стримовый. Браузер не ждёт последнего байта HTML: если сервер шлёт ответ чанками,
можно отдать <head> со ссылками на CSS и шрифты немедленно, а тело досылать — ресурсы
поедут параллельно с генерацией страницы. На этом построены 103 Early Hints и streaming SSR
(см. Роутинг и стратегии рендеринга).
А paint holding объясняет, почему медленный сайт ощущается как «клик не сработал»:
Chrome держит старую страницу до первого осмысленного кадра новой.
<!-- В начале <head>: сокращаем сетевую латентность до чужих origin-ов -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- Шрифт первого экрана качаем сразу, не дожидаясь CSS. crossorigin обязателен:
шрифты грузятся в анонимном CORS-режиме, без него файл скачается дважды -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<!-- LCP-картинка: высокий приоритет, никакой ленивой загрузки -->
<img src="/hero.avif" fetchpriority="high" loading="eager"
width="1200" height="600" alt="Обложка статьи">
Шаг 1. HTML → DOM
Парсер HTML — конечный автомат из спецификации WHATWG
(https://html.spec.whatwg.org/multipage/parsing.html). Сначала токенизация: поток
символов превращается в StartTag(div), Character, EndTag(div). Затем построение
дерева: стек открытых элементов плюс правила «режимов вставки» создают узлы. Здесь живёт
легендарная устойчивость HTML к ошибкам — незакрытый <p>, <b> внахлёст с <i>, <td> вне
<table>: на всё есть прописанное в спецификации восстановление, поэтому HTML нельзя парсить
регулярками и валидировать как XML. При этом DOM — не «HTML в памяти», а объектная модель со
ссылками, живыми коллекциями и API: один <div> в Blink весит несколько сотен байт. 30 000
узлов — десятки мегабайт и медленные style/layout; Lighthouse не зря ругается на документы
крупнее ~1400 узлов.
Почему <script> останавливает парсер
Синхронный <script> может вызвать document.write() и дописать что угодно в поток символов,
поэтому парсер обязан остановиться и дождаться выполнения. Это parser-blocking.
Неочевидность на схеме: скрипт блокируется на незагруженном CSS. Если выше по документу
есть ещё качающийся <link rel="stylesheet">, синхронный скрипт не начнёт выполняться, пока
CSSOM не готов (он может спросить getComputedStyle()) — медленный CSS с чужого CDN тормозит
не только рендер, но и весь JS.
| Способ | Качается | Выполняется | Блокирует парсер | Порядок |
|---|---|---|---|---|
<script> |
синхронно | сразу | да | по документу |
<script async> |
параллельно | как только скачан | да, в момент выполнения | непредсказуемый |
<script defer> |
параллельно | после парсинга, до DOMContentLoaded |
нет | по документу |
<script type="module"> |
параллельно | как defer |
нет | по графу зависимостей |
<script type="module" async> |
параллельно | как только готов граф | да, в момент выполнения | непредсказуемый |
Правило: по умолчанию defer или type="module"; async — только для независимых от
DOM счётчиков; синхронный <script> в <head> — почти всегда баг.
Preload scanner: почему «вынесите скрипт вниз» устарело
Пока парсер стоит на скрипте, второй, спекулятивный парсер бежит по сырому тексту вперёд и
ищет src, href, srcset, ставя ресурсы в очередь загрузки: на реальных сайтах это даёт
до 20 % ускорения. Но видит он только разметку и слеп к вот такому:
// Preload scanner не увидит URL: загрузка начнётся только после выполнения JS
const img = new Image();
img.src = `/hero-${window.innerWidth > 800 ? "wide" : "narrow"}.avif`;
document.body.append(img);
// То же с CSS-фоном: .hero { background-image: url("/hero.avif") } —
// URL станет известен браузеру только после Style + Layout этого элемента.
Отсюда: всё, что нужно для первого экрана, должно быть либо в HTML, либо в
<link rel="preload">. Картинки первого экрана — тегом <img>, не фоном в CSS.
Шаг 2. CSS → CSSOM
CSS парсится в отдельное дерево — CSSOM, и вот главное свойство: CSS блокирует рендеринг.
Браузер не покажет ни пикселя контента, пока не построит CSSOM для всех неотложенных таблиц
стилей: неотстилизованный текст, перекрашиваемый через 300 мс (FOUC), пользователи ненавидят
сильнее ожидания. Управлять этим позволяет media — таблица, не подходящая под текущий
контекст, качается с низким приоритетом и не блокирует рендер.
<link rel="stylesheet" href="/critical.css"> <!-- блокирует рендер -->
<link rel="stylesheet" href="/print.css" media="print"> <!-- не блокирует -->
<!-- Приём для некритичных стилей: качаем как неблокирующее, потом включаем -->
<link rel="stylesheet" href="/rest.css" media="print" onload="this.media='all'">
Тот же эффект даёт инлайн критического CSS прямо в <head> — ноль запросов, ноль RTT — плюс
асинхронная догрузка остального; порог разумности инлайна — около 14 КБ, столько влезает в
первое congestion window TCP.
Селекторы сопоставляются справа налево. Для .sidebar ul li a браузер берёт каждый <a>
и поднимается вверх по предкам; правый-самый компонент (key selector) определяет стоимость.
В 2020-х селекторы редко бывают узким местом — Blink кэширует результаты и инвалидирует
поддеревья точечно. Но два антипаттерна живы: бандлы на сотни килобайт правил, где recalc
всей страницы занимает десятки миллисекунд, и правила на * вместе со сменой класса на
<body> — инвалидация стилей всего документа при переключении темы. Разбор каскада — в
CSS с нуля.
Шаг 3. Style: сливаем DOM и CSSOM
Стадия Style (в старых статьях — «построение render tree») проходит по DOM и вычисляет для
каждого элемента computed style: полный набор свойств с разрешёнными наследованием,
каскадом, var() и относительными единицами. Элементы с display: none в дерево рендера
не попадают — бокса нет, layout не считается; у visibility: hidden бокс есть и место
он занимает. Псевдоэлементы ::before/::after с непустым content бокс получают. А смена
одной переменной на :root инвалидирует стили всех элементов, которые её используют, —
типичная причина «почему переключение темы тормозит 80 мс». В DevTools → Performance это
жёлто-фиолетовые блоки Recalculate Style с колонкой «Elements affected».
Шаг 4. Layout: геометрия
Layout (в Firefox — reflow) отвечает, где и какого размера каждый бокс: рекурсивный обход с
разрешением ограничений — проценты, flex-grow, grid-template, переносы строк, float-ы.
В Chromium с версии 77 внедряется LayoutNG с неизменяемыми фрагментами, что позволяет
точнее кэшировать поддеревья. Но layout глобален: изменение ширины одного блока может
сдвинуть всё, что ниже.
Боль 1: forced synchronous layout и layout thrashing
Браузер откладывает layout до отрисовки кадра. Но если JavaScript спрашивает геометрию, ответ нужен сейчас — layout считается синхронно посреди вашего кода. Один раз терпимо, в цикле — катастрофа.
// ПЛОХО: layout thrashing. Каждый offsetWidth форсирует пересчёт layout,
// испорченного предыдущей записью. 500 элементов = 500 синхронных layout-ов.
function resizeAllBad(boxes) {
for (const box of boxes) {
const w = box.offsetWidth; // READ -> форсированный layout
box.style.width = w * 1.1 + "px"; // WRITE -> инвалидация layout
}
}
// ХОРОШО: разделяем фазы чтения и записи (паттерн read-then-write)
function resizeAllGood(boxes) {
const widths = boxes.map((box) => box.offsetWidth); // layout посчитается один раз
// Запись копит инвалидацию — layout разрешится один раз перед следующим кадром
boxes.forEach((box, i) => { box.style.width = widths[i] * 1.1 + "px"; });
}
Полный список форсирующих layout свойств и методов ведёт Пол Айриш
(https://gist.github.com/paulirish/5d52fb081b3570c81e3a); самые частые — offsetTop/Width,
clientWidth/Height, scrollTop/Height, getBoundingClientRect(), getComputedStyle(),
element.focus(). В консоли это Forced reflow is a likely performance bottleneck.
Боль 2: layout всего документа ради одного виджета
Лечится containment — обещанием, что изменения внутри элемента не выйдут наружу.
.feed-card { contain: layout paint style; } /* внутреннее не трогает документ */
/* Длинные списки: не считать layout и paint для того, что за экраном.
contain-intrinsic-size даёт оценку размера, чтобы скроллбар не прыгал. */
.feed-item { content-visibility: auto; contain-intrinsic-size: auto 320px; }
content-visibility: auto — одна из самых недооценённых оптимизаций: на длинных лентах она
сокращает время первого рендера в разы. Осторожно с доступностью пропущенных поддеревьев
(см. гайд по доступности).
Шаг 5. Paint: список команд, а не пиксели
Paint в современном Blink — это не рисование, а запись display list: «залей прямоугольник
цветом X», «нарисуй текст таким шрифтом», «наложи тень». Порядок наложения задан спецификацией
CSS 2.1 (Appendix E): фоны, float-ы, inline-контент, позиционированные элементы по z-index.
Дорого стоят box-shadow с большим blur, filter: blur(), border-radius на больших
областях, градиенты и backdrop-filter. Увидеть перерисовки: DevTools → Ctrl/Cmd+Shift+P →
Show Rendering → Paint flashing; мигает половина экрана при наведении на кнопку —
у вас проблема.
Шаг 6. Composite: слои и GPU
Display list разбивается на слои композитинга, слои режутся на тайлы (обычно 256×256), тайлы растрируются в отдельных потоках и загружаются в текстуры GPU. Compositor-поток собирает кадр из прямоугольников с текстурами — «quad-ов» — и отдаёт GPU-процессу. С Chrome 90 работает CompositeAfterPaint: слои определяются после paint, на основе property trees, поэтому создание слоя стало дешевле. Смысл слоя: изменения внутри него применяются без повторного paint — достаточно передать компоситору новую матрицу трансформации. Полный справочник стоимости свойств: https://csstriggers.com.
| Свойство | Layout | Paint | Composite | Цена |
|---|---|---|---|---|
transform |
нет | нет | да | дёшево, на compositor-потоке |
opacity |
нет | нет | да | дёшево |
filter |
нет | зависит | да | средне |
color, background-color |
нет | да | да | средне |
box-shadow |
нет | да | да | дорого |
width, height, top, left, margin |
да | да | да | дорого |
font-size, line-height |
да | да | да | очень дорого |
/* ПЛОХО: анимация left запускает layout каждый кадр */
.toast-bad { position: absolute; left: -300px; transition: left 300ms ease-out; }
.toast-bad.visible { left: 0; }
/* ХОРОШО: визуально то же, но только композитинг */
.toast-good {
position: absolute; left: 0;
transform: translateX(-300px);
transition: transform 300ms ease-out;
}
/* will-change ставим классом непосредственно перед анимацией и снимаем после */
.toast-good.animating { will-change: transform; }
.toast-good.visible { transform: translateX(0); }
will-change — не волшебная пыль. Он выделяет элементу отдельный слой, а слой стоит памяти
GPU: 1920×1080 в RGBA — около 8 МБ. Сотня «оптимизированных» элементов даёт layer explosion,
вылет вкладки на мобильном и падение FPS. Не ставьте его на элементы списка «на всякий случай»;
смотрите панель Layers. Слои также создают position: fixed, video и translateZ(0).
Event loop и бюджет кадра
Всё описанное происходит внутри event loop: взять задачу из очереди → выполнить → опустошить очередь микрозадач → если пора рисовать, выполнить «update the rendering».
Порядок шагов внутри «update the rendering» строго определён спецификацией
(https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model):
отложенные события ввода (pointermove коалесцируются) → колбэки requestAnimationFrame →
доставка ResizeObserver в цикле до стабилизации (отсюда предупреждение «ResizeObserver loop
completed with undelivered notifications») → доставка IntersectionObserver → Recalculate
Style → Layout → Pre-paint → Paint → Commit в композитор.
Практические следствия. Анимировать из JS нужно через requestAnimationFrame — он вызывается
ровно перед стадией style/layout, а setTimeout(fn, 16) с кадром не синхронизирован и даёт
джанк. Микрозадачи (Promise.then, await) выполняются до рендеринга и внутри той же
задачи: цепочка промисов блокирует кадр так же намертво, как while (true). Задача дольше
50 мс — long task: пришедший ввод будет ждать. Лечение — уступать управление браузеру.
// scheduler.yield() уступает главный поток и возвращает управление с высоким
// приоритетом (Chrome 129+). Fallback — setTimeout(0).
const yieldToMain = () =>
globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0));
async function processBatch(items, handle) {
let lastYield = performance.now();
for (const item of items) {
handle(item);
// Уступаем не после каждого элемента (дорого), а по бюджету времени
if (performance.now() - lastYield > 40) {
await yieldToMain();
lastYield = performance.now();
}
}
}
Почему тормозит: карта симптомов
| Симптом | Что происходит в конвейере | Где смотреть |
|---|---|---|
| Долгий белый экран | Render-blocking CSS/JS, медленный TTFB | Network waterfall, FCP |
| Контент появился поздно | Большая картинка или шрифт без preload, LCP-ресурс найден поздно | LCP entry |
| Страница прыгает | Нет width/height у картинок, поздние шрифты, вставка баннера |
Layout Shift Regions |
| Клик отвечает через полсекунды | Long task на main thread, тяжёлый обработчик | INP, Long Animation Frames |
| Скролл дёргается | Paint/layout каждый кадр, некомпозитные анимации, scroll без throttling |
Frame Rendering Stats, Paint flashing |
| Всё быстро, но батарея садится | Вечный rAF-цикл или анимация вне вьюпорта | Performance → Frames |
Главный источник CLS — элементы без зарезервированного места:
img, video { max-width: 100%; height: auto; } /* + width/height в HTML */
/* Шрифт: fallback показываем сразу, но подгоняем его метрики,
чтобы подмена не сдвинула строки */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2-variations");
font-display: swap;
size-adjust: 107%; /* подгон метрик под fallback-шрифт */
ascent-override: 90%;
descent-override: 22%;
}
Как измерять
Core Web Vitals — полевые метрики Google, пороги «хорошо» по 75-му перцентилю реальных пользователей (https://web.dev/articles/vitals):
- LCP — отрисован самый большой элемент первого экрана. Хорошо: ≤ 2.5 с. Про сеть.
- CLS — сумма неожиданных сдвигов. Хорошо: ≤ 0.1. Про layout.
- INP — от взаимодействия до следующего кадра; с марта 2024 заменил FID. Хорошо: ≤ 200 мс. Про занятость главного потока. Диагностические: TTFB ≤ 0.8 с, FCP ≤ 1.8 с, TBT.
// Ядро сбора метрик без библиотек (в проде проще взять npm-пакет web-vitals).
// LCP: приходит несколько раз, актуален последний до первого взаимодействия
new PerformanceObserver((list) => {
const last = list.getEntries().at(-1);
console.log("LCP", Math.round(last.startTime), last.element);
}).observe({ type: "largest-contentful-paint", buffered: true });
// CLS: сдвиги без недавнего ввода (полную формулу с «сессионными окнами»
// не длиннее 5 с и паузами меньше 1 с реализует web-vitals)
let cls = 0;
new PerformanceObserver((list) => {
for (const e of list.getEntries()) if (!e.hadRecentInput) cls += e.value;
}).observe({ type: "layout-shift", buffered: true });
// Long Animation Frames — лучший источник для отладки плохого INP: показывает
// не только длительность кадра, но и скрипт-виновника
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.blockingDuration < 50) continue;
const worst = frame.scripts.sort((a, b) => b.duration - a.duration)[0];
console.warn("Долгий кадр", Math.round(frame.duration), "мс",
worst?.sourceURL, worst?.sourceFunctionName, worst?.invoker);
}
}).observe({ type: "long-animation-frame", buffered: true });
// Отправка — только при скрытии страницы и через sendBeacon,
// иначе метрику потеряет закрытая вкладка
addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
navigator.sendBeacon("/rum", JSON.stringify({ cls }));
}
}, { once: true });
DevTools, что открывать. Performance — запись профиля: на дорожке Main серые задачи (Task), внутри них фиолетовый Recalculate Style / Layout, зелёный Paint, синий Composite; красный уголок = long task. Rendering — Paint flashing, Layout Shift Regions, Frame Rendering Stats, оверлей Core Web Vitals. Layers — схема слоёв с размером и причиной создания. Coverage — сколько процентов загруженного CSS/JS использовано на первом экране (вход в разговор о code splitting, см. Сборка фронтенда). И обязательный режим тестирования: CPU throttling 4–6× плюс Fast 4G — ваш ноутбук врёт.
Лаборатория против поля. Lighthouse и локальный профайлинг воспроизводимы и удобны для отладки, но реальность отражают плохо; поле — это CrUX и собственный RUM. Классическая ошибка: гнаться за 100 баллов в Lighthouse, когда у 75-го перцентиля пользователей INP равен 600 мс из-за стороннего скрипта, которого в лабораторном прогоне не было. Как встроить это в процесс — в Производительность фронтенда.
Типичные ошибки
- Синхронный
<script>в<head>— блокирует парсер и рендер; лечитсяdefer. - Чтение
getBoundingClientRect()в цикле — layout thrashing; разделяйте фазы. - Анимация
width/top/marginвместоtransform— та же картинка, цена на порядок выше. will-changeв статическом CSS для всего — layer explosion и рост памяти.- Картинки без
width/height— CLS: атрибуты нужны даже приwidth: 100%в CSS. scrollбез{ passive: true }— браузер ждёт возможногоpreventDefault(), скролл лагает.- LCP-картинка фоном в CSS или через JS — preload scanner её не найдёт.
- Оптимизация без измерения (профиль → гипотеза → правка → профиль) и тестирование только на десктопе без throttling — две самые дорогие ошибки в списке.
Мини-итог
- В renderer-процессе главный поток один: он делит время между вашим JS, стилями, layout и paint. Кадр — 16.7 мс при 60 Гц; задача длиннее 50 мс ломает отзывчивость.
- Конвейер: DOM + CSSOM → Style → Layout → Paint → Composite; чем ниже внесено изменение,
тем оно дешевле.
transformиopacityживут на compositor-потоке, остальное дёргает layout или paint. - HTML парсится потоково; синхронные скрипты останавливают парсер, CSS блокирует рендер, а скрипты дополнительно ждут CSSOM.
- LCP отвечает за сеть, CLS — за layout, INP — за главный поток. Лаборатория для отладки, поле для правды.
Источники
- Mariko Kosaka, «Inside look at modern web browser» — https://developer.chrome.com/blog/inside-browser-part1
- RenderingNG: архитектура рендера Chromium — https://developer.chrome.com/docs/chromium/renderingng
- MDN, «How browsers work» — https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/How_browsers_work
- HTML Standard, event loop — https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model
- web.dev: https://web.dev/articles/rendering-performance и https://web.dev/articles/optimize-inp
- Long Animation Frames API — https://developer.chrome.com/docs/web-platform/long-animation-frames
- Ilya Grigorik, «High Performance Browser Networking» — https://hpbn.co
Что дальше
Мы разобрали, что браузер делает с вашим HTML. Теперь — как писать этот HTML так, чтобы браузеру, поисковикам и скринридерам было понятно, что вы имели в виду: Семантический HTML: структура, формы, метаданные.