Frontend-разработка Как работает браузер: парсинг, DOM, CSSOM, рендер, композитинг
0%

Как работает браузер: парсинг, DOM, CSSOM, рендер, композитинг

Как работает браузер: парсинг, DOM, CSSOM, рендер, композитинг

Фронтендер, который не знает, как браузер превращает текст в пиксели, обречён чинить производительность методом тыка: «добавил will-change, вроде полегчало», «перенёс скрипт вниз — не трогайте». Это работает до первого серьёзного проекта. Дальше начинается магия: список из 500 строк дёргается при скролле, вёрстка прыгает при загрузке шрифта, кнопка отвечает на клик через 400 мс, а Lighthouse ставит 40 баллов и не объясняет почему.

Магия исчезает, как только появляется модель конвейера. Браузер — классический компилятор-и-рантайм: токенизирует входной текст, строит два дерева, соединяет их, вычисляет геометрию, записывает список команд рисования, растрирует его на нескольких потоках и отдаёт видеокарте прямоугольники с текстурами. Каждая стадия имеет цену, и почти вся оптимизация фронтенда сводится к тому, чтобы не запускать дорогие стадии там, где хватает дешёвых.

Браузер — это не одна программа

Современный Chromium (по той же схеме — Firefox и WebKit) разложен на несколько процессов ОС, которые общаются через IPC.

Многопроцессная архитектура браузера и потоки внутри renderer-процесса

Зачем усложнение? Надёжность — падение рендерера убивает вкладку, а не браузер. Безопасность — 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 RenderingPaint 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».

Бюджет кадра 16.7 мс и эффект длинной задачи

Порядок шагов внутри «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 мс из-за стороннего скрипта, которого в лабораторном прогоне не было. Как встроить это в процесс — в Производительность фронтенда.

Типичные ошибки

  1. Синхронный <script> в <head> — блокирует парсер и рендер; лечится defer.
  2. Чтение getBoundingClientRect() в цикле — layout thrashing; разделяйте фазы.
  3. Анимация width/top/margin вместо transform — та же картинка, цена на порядок выше.
  4. will-change в статическом CSS для всего — layer explosion и рост памяти.
  5. Картинки без width/height — CLS: атрибуты нужны даже при width: 100% в CSS.
  6. scroll без { passive: true } — браузер ждёт возможного preventDefault(), скролл лагает.
  7. LCP-картинка фоном в CSS или через JS — preload scanner её не найдёт.
  8. Оптимизация без измерения (профиль → гипотеза → правка → профиль) и тестирование только на десктопе без 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 — за главный поток. Лаборатория для отладки, поле для правды.

Источники

Что дальше

Мы разобрали, что браузер делает с вашим HTML. Теперь — как писать этот HTML так, чтобы браузеру, поисковикам и скринридерам было понятно, что вы имели в виду: Семантический HTML: структура, формы, метаданные.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов