skip to main content

Как браузер рисует страницы

js

Кратко 🔗

Чтобы нарисовать на экране результат работы нашего кода, браузеру нужно выполнить несколько этапов:

  1. Сперва ему нужно скачать исходники.
  2. Затем их нужно прочитать и распарсить.
  3. После этого браузер приступает к рендерингу — отрисовке.

Каждый из процессов очень сложен, и мы не будем рассматривать их до мельчайших подробностей.

Мы лишь обратим внимание на те детали, которые необходимо знать фронтенд-разработчикам, чтобы лучше понимать, почему разные решения по-разному влияют на производительность и скорость отрисовки.

Начнём по порядку.

Получение ресурсов, Fetching 🔗

Ресурсы браузер получает с помощью запросов к серверу. В ответ он может получить как данные в виде json, так и картинки, видео, файлы стилей и скриптов.

Самый первый запрос к серверу — обычно запрос на получение html-страницы (чаще всего index.html).

В коде неё содержатся ссылки на другие ресурсы, которые браузер тоже запросит у сервера:

<!DOCTYPE html>
<html lang="en">
<head>
<link href="/style.css" rel="stylesheet">
<title>Document</title>
</head>
<body>
<img src="/hello.jpg" alt="Привет!">
<script src="/index.js"></script>
</body>
</html>

В примере выше браузер запросит также:

  • стилевой файл style.css;
  • изображение hello.jpg;
  • и скрипт index.js.

Парсинг, Parsing 🔗

По мере того, как скачивается html-страница, браузер пытается её «прочитать» — распарсить.

DOM 🔗

Браузер работает не с текстом разметки, а с абстракциями над ним. Одна из таких абстракций, результат парсинга html-кода, называется DOM.

DOM (Document Object Model) — абстрактное представление html-документа, с помощью которого браузер может получать доступ к его элементам, изменять его структуру и оформление.

DOM — это дерево. Корень этого дерева — это элемент html, все остальные элементы — это дочерние узлы.

<!-- Для такого документа: -->
<html>
<head>
<meta charset="utf-8">
<title>Hello</title>
</head>
<body>
<p class="text">Hello world</p>
<img src="/hello.jpg" alt="Привет!">
</body>
</html>

<!-- ...получится такое дерево:

html
______|_______
| |
body head
___|____ ___|___
| | | |
p img meta title
| |
"Hello world" "Hello"

-->

Пока браузер парсит документ и строит DOM, он натыкается на элементы типа img, link, script, которые содержат ссылки на другие ресурсы. Он читает, откуда надо запросить ресурс и запрашивает его параллельно с парсингом оставшейся части документа.

Мы можем указывать браузеру, как именно ему следует запрашивать некоторые ресурсы, например, скрипты. Это может быть полезно, когда в скрипте мы собираемся работать с элементами, которые находятся в разметке после тега script:

// script.js
const image = document.getElementById("image")
<!--  1:
image === undefined, потому что браузер
успел распарсить только часть документа
до этого тега script.
-->

<body>
<script src="script.js"></script>
<img src="/hello.jpg" alt="Hello world" id="image">
</body>

<!-- 2:
Всё в порядке, изображение найдётся.
-->

<body>
<img src="/hello.jpg" alt="Hello world" id="image">
<script src="script.js"></script>
</body>

<!-- 3:
Тоже порядок, атрибут defer
скажет браузеру продолжать парсить страницу
и выполнить скрипт после.
-->

<body>
<script src="script.js" defer></script>
<img src="/hello.jpg" alt="Hello world" id="image">
</body>

CSSOM 🔗

Когда же браузер находит элемент link, который указывает на стилевой файл, браузер скачивает и парсит и его. Результат парсинга css-кода — CSSOM.

CSSOM (CSS Object Model) — по аналогии с DOM, представление стилевых правил в виде дерева.

/* Для документа выше с такими стилями: */

body {
font-size: 14px;
}

.text {
color: red;
}

img {
max-width: 100%;
}

/* ...получим такое дерево:

body
(font-size: 14px)
________|_________
| |
.text img
(color: red) (max-width: 100%)

*/

Чтение стилей приостанавливает чтение кода страницы. Поэтому рекомендуется в самом начале отдавать только критичные стили — которые есть на всех страницах и конкретно на этой. Так мы уменьшаем время, которое пользователь ждёт, пока «страница загрузится».

Render Tree 🔗

После того, как браузер составил DOM и CSSOM, он объединяет их в общее дерево рендеринг — Render Tree.

Render Tree — это термин, который используется движком webkit, в других движках он может отличаться. Например, gecko использует термин Frame Tree.

В итоге для нашего документа выше, мы получим такое дерево:

/*

html
|
body
(font-size: 14px)
________|________
| |
p.text img
(color: red) (max-width: 100%)
|
"Hello world"

*/

Обратите внимание, что в Render tree попадают только видимые элементы. Если бы у нас был элемент, спрятанный через display: none, он бы в это дерево не попал. Об ёэтом подробнее мы ещё поговорим дальше.

Общая схема парсинга выглядит вот так:

Общая схема парсинга HTML и CSS

На первых шагах мы разбираемся с HTML и CSS, а затем объединяем их в Render Tree.

Вычисление позиции и размеров, Layout 🔗

После того, как у браузера появилось дерево рендеринга (Render Tree), он начинает «расставлять» элементы на странице. Этот процесс называется Layout.

Чтобы понимать, где какой элемент должен находиться и как он влияет на расположение других элементов, браузер рассчитывает размеры и положение каждого рекурсивно.

Расчёт начинается от корневого элемента дерева рендеринга, его размеры равны размеру вьюпорта. Далее браузере переходит поочерёдно к каждому из дочерних элементов.

Важно помнить, что Layout построен на поточной модели компоновки. Это значит, что если элементы не влияют на расположение и размеры других элементов, то их положение и размеры можно просчитать за один подход.

Именно поэтому при вёрстке макетов рекомендуется «находиться в потоке» — чтобы браузеру не приходилось несколько раз пересчитывать один и тот же элемент, так страница отрисовывается быстрее.

Глобальный и инкрементальный Layout 🔗

Глобальный Layout — это процесс просчёта всего дерева полностью, то есть каждого элемента. Инкрементальный — просчитывает только часть.

Глобальный Layout запускается, например, при ресайзе окна, потому что браузеру требуется подогнать всю страницу под новый размер экрана. Это очень дорогой процесс.

Инкрементальный Layout запускает пересчёт только «грязных» элементов.

«Грязные» элементы 🔗

...Это те элементы, которые были изменены их их дети.

Если мы как-то поменяли блок, то браузер перерисует его и его детей, потому что их положение и размеры могут зависеть от родителя.

Дерево "грязных" и перерисованных элементов

Дальше браузер приступает к, собственно, отрисовке.

Непосредственно отрисовка, Paint 🔗

Во время отрисовки (Paint) браузер наполняет пиксели на экране нужными цветами в зависимости оттого, что в конкретном месте должно быть нарисовано: текст, изображение, цвет фона, тени, рамки и т. д.

Отрисовка тоже бывает глобальной и инкрементальной. Чтобы понять, какую часть вьюпорта надо перерисовать, браузер делит весь вьюпорт на прямоугольные участки. Логика тут та же, как и в Layout — если изменения ограничены одним участком, то пометится грязным и перерисуется лишь он.

Отрисовка — это самый дорогой процесс из всех, что мы уже перечислили.

Порядок отрисовки 🔗

Порядок отрисовки связан со стековым контекстом.

В общих чертах, отрисовки начинается с заднего плана и постепенно переходит к переднему:

  • background-color;
  • background-image;
  • border;
  • children;
  • outline.

CPU и композитинг 🔗

И Layout, и Paint работают засчёт CPU (central process unit), поэтому относительно медленные. Плавные анимации при таком раскладе невероятно дорогие.

Для плавных анимаций в браузерах предусмотрен композитинг (Compositing).

Композитинг — это разделение содержимого страницы на «слои», которые браузер будет перерисовывать. Эти слои друг от друга не зависят, из-за чего изменение элемента в одном слое не затрагивает элементы из других слоёв, и перерисовывать их становится не нужно.

Именно из-за разнесения элементов по разным композиционным слоям свойство transform не так сильно нагружает браузер. Поэтому чтобы анимации не тормозили, их рекомендуется делать с применением transform и opacity.

Схема композитинга

Применение таких свойств, как например, transform «выносит» элемент на отдельный композитный слой, где положение элемента не зависит от других и не влияет на них.

Перерисовка, Reflow (relayout) и Repaint 🔗

Процесс отрисовки — циклический. Браузер перерисовывает экран каждый раз, когда на странице происходят какие-то изменения.

Если, например, в DOM-дереве добавился новый узел, или изменился текст, то браузер построит новое дерево отрисовки и запустит вычисление позиции и отрисовку заново.

Один цикл обновления — это animation frame.

Зная «расписание отрисовки» браузера, мы можем «предупредить» его, что хотим запустить какую-то анимацию на каждый новый фрейм. Это можно сделать с помощью requestAnimationFrame.

const animate = () => {
/* Эта запускает новый кадр анимации:
обновляет какое-то свойство или
перерисовывает canvas. */

}

/* Если мы хотим добиться плавной анимации
используя функцию выше, мы должны обеспечить
в среднем 60 обновлений экрана за секунду
(60 fps — frames per second).

Это можно сделать топорно, через интервал: */


// 60 раз в 1000 миллисекунд, приблизительно 16 мс.
const intervalMS = 1000 / 60
setInterval(animate, intervalMS)

/* Либо использовать window.requestAnimationFrame: */
window.requestAnimationFrame(animate)

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

/*
С интервалом анимация может быть рваной,
потому что перерисовка может быть запущена
в неподходящее время.

А если вкладка была неактивна, то интервал может
«попытаться догнать время»
и несколько кадров запустится разом
....|....|..||...|....|.........||||..|....|...|...|....|...
*/


/*
С requestAnimationFrame анимация плавнее,
потому что браузер знает, что в следующем фрейме
надо запустить новый кадр анимации.

Она не гарантирует, что анимация будет запущена
строго раз в 16 мс, но значение будет достаточно близким.
....|....|....|...|....|...|....|....|...|.....|....|....
*/

В работе 🔗

Для динамики всегда используйте transform и opacity, избегайте изменения остальных свойств (типа left, top, margin, background и т. д.).

Таким образом вы дадите браузеру возможность оптимизировать отрисовку, отчего страница станет отзывчивее.

Для анимаций, которые необходимо перерисовывать на каждый фрейм, используйте requestAnimationFrame и его полифил.

Это сделает тяжёлую анимацию менее рваной.

Автор: Саша,