Мышление через отклонение: практическая модель того, как браузеры отображают страницы
Научитесь анализировать процесс отрисовки в браузере, задаваясь вопросом о том, какие изменения нарушают его работу, — от проблем с макетированием и затрат на отрисовку до процессов композиции, гидратации и стриминга.
Источники: The Web Page Rendering Process In Browsers: A Deep Dive
Почти каждый разработчик фронтенда может перечислить этапы процесса отрисовки: HTML превращается в DOM, CSS — в CSSOM, затем следуют этапы формирования макета, отрисовки и сборки изображения. Такая последовательность является хорошим введением, но она практически бесполезна, когда реальная страница работает с трудностями, поскольку описывает лишь один проход, тогда как браузеры постоянно выполняют частичные, повторяющиеся проходы. В этом руководстве вместо заученного списка приводится рабочая модель, основанная на одном вопросе, что позволяет объяснить проблемы с формированием макета, медленную отрисовку, неэффективные анимации и задержки при загрузке данных с использованием тех же принципов и проверить свои предположения в DevTools.
Отрисовка — это непрерывный процесс, а не однократное действие
Страница никогда не отрисовывается один раз и не остается без изменений. Скрипты мутируют DOM, изменения классов влияют на стили, изображения и веб-шрифты загружаются с задержкой, пользователи перемещают курсор и прокручивают страницу, анимации обновляются на каждом кадре, а фреймворки перерисовывают компоненты после изменения состояния. Каждое из этих событий заставляет движок отрисовки отвечать на один и тот же вопрос:
Что стало недействительным, и сколько работы необходимо выполнить заново?
Держать этот вопрос в уме ценнее, чем знать названия этапов. Именно поэтому два приложения, выглядящие одинаково, могут работать совершенно по-разному на одном и том же телефоне: одно постоянно запускает дорогостоящие пересчёты, а другое в основном использует результаты, которые браузер может повторно применить.
Первый шаг: браузер создаёт внутренние модели
Когда приходит HTML, парсер преобразует его в DOM. DOM — это не изображение страницы; это дерево элементов, атрибутов и текстовых узлов, а также отношения родительства и братьев-сестер между ними. Возьмем небольшой фрагмент маркировки:
<main>
<h1>Browser Rendering</h1>
<p>Understanding what happens after HTML arrives.</p>
</main>
Парсер преобразует его в дерево, с которым движок может работать, выполняя запросы и внося изменения:
Document
└── main
├── h1
│ └── "Browser Rendering"
└── p
└── "Understanding what happens after HTML arrives."
Шаблоны стилей проходят свою собственную обработку. Браузер парсит их, применяет правила каскадирования и наследования, определяет, какие правила соответствуют тем или иным элементам, и вычисляет окончательное значение каждого свойства для каждого элемента. На этом этапе ничего ещё не отрисовано. Движок накапливает информацию о состоянии страницы, и ему необходимы как структура, так и вычисленные стили, прежде чем можно будет определить геометрию отрисовки. Схема, ориентированная на зависимости, выглядит так:
HTML ──→ DOM ───────────┐
├──→ Style + Layout ──→ Paint ──→ Composite
CSS ──→ Styles ────────┘
Рассматривайте этот рисунок как карту зависимостей, а не описание имплементации. Настоящие движки содержат гораздо больше внутренних этапов, кэшей, структур отслеживания недействительности данных, нескольких потоков и упрощений, специфичных для определенной платформы. В диаграмме правильно показано, какие выходные данные зависят от каких входных. Если вы хотите узнать подробнее о том, как определяются значения стилей перед формированием макета, наша статья о том, как CSS вписывается в работу браузера дает более глубокое объяснение.
Формирование макета — это расчет зависимостей
При наличии DOM и вычисленных стилей браузер определяет геометрию элементов. При формировании макета необходимо решить такие вопросы, как:
- Каковы ширина и высота каждого элемента?
- Где расположен каждый элемент?
- Где происходит перенос текста внутри контейнера?
Последний вопрос объясняет, почему расчет макета является ресурсоемким процессом. Предположим, абзац становится выше из-за увеличения размера шрифта. Все элементы, следующие за ним в потоке, могут потребоваться сместиться вниз, а высота контейнера может измениться, что повлияет на его собственные дочерние элементы. Изменение, кажущееся локальным, может оказать эффект на большую область документа. Именно поэтому полезно рассматривать формирование макета как решение сети взаимозависимостей, а не как простое размещение элементов на рабочем поле.
Разработчики стараются ограничить пересчет только затронутой поддеревушкой, но объем выполняемых операций зависит от типа изменения и от того, как элементы связаны между собой. Например, содержимое в контейнере фиксированного размера обычно вызывает меньше последствий, чем содержимое в контейнере с автоматическим размером.
Аннулирование изменений важнее, чем весь процесс обработки
Представьте, что скрипт выполняет строку card.style.width = "500px". Теперь браузер понимает, что его предыдущие данные о геометрии этой карточки, а возможно, и элементов вокруг неё, могут быть устаревшими. Перед следующей сменой кадра ему необходимо определить, какие этапы процесса нужно перезапустить. В упрощённой форме:
Something changes
│
▼
What does it invalidate?
│
├── Style
├── Layout
├── Paint
└── Composite
Ключевой момент заключается в том, что не все изменения проходят через весь процесс обработки:
- Изменение ширины влияет на геометрию, поэтому пересчитывается макет, а затем обычно перерисовывается затронутая область.
- Изменение цвета фона влияет на внешний вид без перемещения элементов, поэтому требуется перерисовка, но макет обычно не меняется.
- Новое значение параметра
transformчасто можно применить с гораздо меньшими усилиями, но только в том случае, если решения браузера относительно слоёв и окружающие условия позволяют обработать обновление на этапе композиции.
Именно поэтому общие рекомендации вроде «избегайте перерисовки» или «используйте только GPU» на практике не сработывают. Полезный диагностический вопрос всегда звучит так: какие действия были сделаны бесполезно из-за этого конкретного изменения? В нашей статье о том, сколько стоит для браузера каждое изменение CSS перечислены распространённые свойства в зависимости от этапов, при которых они обычно вызывают действия.
Как JavaScript заставляет выполнять работу по отрисовке раньше времени
Распространённым способом случайного создания ресурсоемкой отрисовки является смешивание операций записи и чтения информации о макете. В приведённом ниже цикле задаётся ширина каждой карточки, а затем сразу запрашивается её фактическая ширина:
for (const card of cards) {
card.style.width = "500px";
const width = card.getBoundingClientRect().width;
console.log(width);
}
Сама функция getBoundingClientRect() не имеет никаких проблем. Это обычный и полезный API. Проблема возникает из-за порядка выполнения операций. Установка стилей оставляет задачи по формированию макета нерешенными, а сразу следующая строка запрашивает значение, которое может быть корректным только после выполнения этих нерешенных задач. У браузера нет другого выбора, кроме как выполнять операции с стилями и формированием макета синхронно, прямо здесь, перед возвратом числа. В цикле возникает явление, известное как layout thrashing: запись, чтение, запись, чтение, при этом на каждой итерации принудительно выполняется обновление макета.
Перестройка кода таким образом, чтобы все операции чтения выполнялись первыми, а все операции записи — позже, устраняет необходимость принудительной синхронизации:
const widths = cards.map(card =>
card.getBoundingClientRect().width
);
for (const card of cards) {
card.style.width = "500px";
}
Теперь измерения производятся на основе единственного уже действительного макета, а операции записи оставляются на обработку браузером при следующей отрисовке. Обратите внимание, что эти две версии не являются строго эквивалентными: во второй версии ширины записываются до изменений, поэтому используйте такую перестановку только тогда, когда это действительно необходимо. Если операция чтения действительно зависит от предыдущей операции записи, обычные методы заключаются в группировке операций записи и запланировании операций чтения на следующую кадру, например с помощью requestAnimationFrame.
Более общий урок заключается не в том, чтобы «никогда не измерять макет». JavaScript и процесс отрисовки используют одну и ту же потоковую оболочку и одни и те же данные, поэтому ваш код может создавать зависимости, заставляющие браузер немедленно выполнять действия, которые в противном случае были бы запланированы и выполнены эффективно пакетно. У проблемы с макетом есть и другие тонкие факторы, вызывающие её, которые мы рассматриваем в статье live DOM collections and layout thrashing.
Этап отрисовки — это отдельная стадия, отличная от формирования макета
Как только геометрия элементов известна, браузеру всё равно необходимо определить, как будет выглядеть каждая область: фоны, границы, символы текста, изображения, тени, градиенты, эффекты обрезки и другие элементы. Существует компактный способ разделить эти две стадии:
- На этапе формирования макета определяется, где находятся элементы и каковы их размеры.
- На этапе отрисовки определяется, как они будут выглядеть.
Изменение значения background-color обычно не требует изменения геометрии всего документа, в то время как изменение размера или положения может это потребовать. Однако не стоит считать, что такая обработка бесплатна. Рисование больших участков, использование сложных эффектов, таких как крупные размытые тени, частая замена контента и чрезмерно широкий диапазон обновлений могут значительно увеличить время рендеринга и замедлить отрисовку кадра.
Растеризация и композитинг создают окончательный кадр
Функция Paint генерирует инструкции для рисования, но не обязательно финальные пиксели. Эти инструкции затем преобразуются в пиксельные данные, которые обычно делятся на блоки и обрабатываются с помощью графической системы платформы. После этого происходит композитинг, при котором полученные поверхности или слои объединяются в кадр, отображаемый на экране.
Именно на этом этапе свою репутацию завоевывают свойства transform и opacity. При подходящих условиях анимация этих свойств позволяет элементу двигаться, масштабироваться или тускнеть без необходимости браузером пересчитывать макет документа или перерисовывать его содержимое; он просто объединяет существующие слои.
Однако популярное утверждение «преобразования выполняются на GPU, поэтому всегда быстры» скрывает реальные затраты:
- Каждый дополнительный слой потребляет память, что имеет значение на мобильных устройствах с ограниченными ресурсами.
- Содержимое всё равно должно быть растеризовано в какой-то момент, а процесс растеризации может быть затратным.
- То, будет ли у элемента собственный слой, зависит от правил работы браузера и контекста отрисовки, поэтому один и тот же CSS может вести себя по-разному в разных ситуациях.
Это простое правило проще любого списка свойств: чем меньше изменений приводит к недействительности данных, тем меньше работы остается для пересчёта у движка.
Бюджет процессорных ресурсов делится
Дисплей с частотой обновления 60 Гц обновляется примерно каждые 16,67 миллисекунды, поскольку 1000 / 60 примерно равно 16,67. При частоте 120 Гц этот интервал сокращается до примерно 8,33 миллисекунды.
Этот интервал не является отведённым временем процессора исключительно для вашего кода. В течение этого времени браузер должен обработать выполнение скриптов, расчёт стилей, формирование макета, отрисовку, растеризацию, композицию изображений, обработку ввода, сетевые вызовы и свои собственные задачи по поддержанию работы. Поэтому одна длительная задача на JavaScript может замедлить отрисовку, даже если сам процесс отрисовки был бы простым.
Именно по этой причине такие показатели, как FCP (First Contentful Paint) и INP (Interaction to Next Paint), существуют отдельно. Быстрое отображение контента и быстрая реакция на ввод — это связанные цели, но они сбоют по разным причинам, поэтому их необходимо измерять отдельно.
Место создания интерфейса влияет на затраты на отрисовку
Прежде чем браузер что-либо отрисует, архитектурный выбор уже влияет на объем работы, которая предстоит выполнить: где создается начальный интерфейс?
Отрисовка на стороне клиента
При CSR сервер отправляет минимальную структуру HTML и код приложения, а устройство выполняет большую часть работы:
Server
│
├── HTML shell
└── JavaScript + data
│
▼
Browser
│
├── Parse JS
├── Execute JS
├── Fetch data
├── Build UI
└── Render
Отрисовка на стороне сервера
При SSR загрузка данных, обработка логики приложения и генерация HTML происходят на сервере перед отправкой ответа:
Request
│
▼
Server
│
├── Fetch data
├── Execute application logic
└── Generate HTML
│
▼
Browser
│
▼
Parse + Render
SSR позволяет более быстро доставлять значимый маркап в браузер, тогда как CSR переносит вычисления и отрисовку на аппаратное обеспечение пользователя. В целом ни один из подходов не является лучшим. Правильный выбор зависит от сложности приложения, необходимых данных, возможностей кэширования ответов, типов устройств пользователей и количества JavaScript-кода, который в конечном итоге должен выполняться в браузере.
Гидратация: кажущаяся готовность — это не настоящая готовность
Многие современные фреймворки сочетают эти два подхода с помощью гидратации. Сервер отправляет HTML, который можно сразу отобразить, а клиентский JavaScript запускает приложение и добавляет обработчики событий, необходимые для взаимодействия:
Server HTML
│
▼
Browser renders content
│
▼
JavaScript loads
│
▼
Application initializes
│
▼
Hydration
│
▼
Interactive UI
Последствия этого легко упустить из виду: страница может казаться готовой задолго до того, как сможет отреагировать на нажатие. Процесс гидратации включает загрузку, парсинг, компиляцию и выполнение большого количества JavaScript-кода, инициализацию компонентов и настройку обработчиков событий, причем всё это происходит на основном потоке. Поэтому крупные приложения могут заставить контент появляться быстрее, но сразу после этого возникает всплеск работы основного потока, именно тогда, когда пользователи начинают нажимать.
Это противоречие объясняет текущее направление развития стратегий отрисовки: отправка меньшего количества JavaScript-кода на сторону клиента, разделение пакетов и активация только тех частей страницы, которые действительно требуют интерактивности, вместо того чтобы активировать всю структуру сразу.
Изменения при потоковой передаче по мере начала работы
В классическом SSR полный ответ генерируется заранее перед его отправкой. При потоковой передаче HTML отправляется порциями по мере того, как он становится доступен:
Server
│
├── HTML shell ──────────→ Browser
├── Content ─────────────→ Browser
├── More content ────────→ Browser
└── Remaining response ──→ Browser
Браузер может парсить и отображать первые части контента, пока сервер всё ещё генерирует остальное. Стриминг в целом не снижает затраты на отображение. Он меняет момент, когда можно начать полезную работу, что может сократить время от навигации до отображения контента. Прогрессивное загрузчик применяет ту же идею на стороне клиента, постепенно выполняя инициализацию вместо мгновенного запуска всего приложения.
Наблюдайте за процессом, а не запоминайте его
Самый быстрый способ развить интуицию — наблюдать за браузером напрямую. Откройте панель Performance в Chrome DevTools, зафиксируйте интеракцию и просмотрите, что выполняется при изменении интерфейса. Затем создайте искусственную ситуацию с изменением макета, сразу установив значение ширины и прочитав геометрические данные:
element.style.width = "600px";
element.getBoundingClientRect();
Зафиксируйте процесс снова, внеся изменения только в параметры преобразования:
element.style.transform = "translateX(100px)";
Цель упражнения не в том, чтобы назвать одно свойство хорошим, а другое плохим. Для каждой записи решайте вопросы по порядку:
- Был ли стиль пересчитан?
- Происходила ли настройка макета, и была ли она вызвана скриптом?
- Перерисовывалась ли какая-либо область?
- Происходила ли растеризация?
- Может ли обновление полностью обрабатываться композитором?
Как только вы начнёте анализировать логи в таком ключе, панель Производительности перестаёт быть инструментом отладки в крайнем случае и становится способом увидеть, как движок реагирует на каждую строку вашего кода.
Что за пределами шестиступенчатой схемы
Текстовая схема обработки данных всё ещё стоит знать:
HTML → DOM → CSSOM → Layout → Paint → Composite
Настоящая система гораздо сложнее. Движок постоянно синхронизирует несколько взаимосвязанных представлений — от дерева элементов и вычисленных стилей до геометрии прямоугольников и инструкций по отрисовке, а также до растровых плиток и составленных слоев. Изменения редко приводят к необходимости полной пересборки всех этих компонентов. Система определяет, что всё ещё действительно актуально, отмечает то, что устарело, и повторно использует всё возможное.
Основные выводы
- Вместо вопроса «Какую оптимизацию мне применить?» следует задаваться вопросами: «Что изменилось, что стало недействительным и почему браузеру пришлось повторять эту работу?»
- Разметка представляет собой проблему зависимостей; кажущиеся локальными изменения могут повлиять на весь документ.
- Одновременные операции чтения и записи принуждают систему к синхронной обработке разметки; их следует разделять или откладывать чтение на следующую кадровую процедуру.
transform и opacity становятся дешевыми только тогда, когда слои и условия сотрудничают друг с другом.