Картина в рамке, разложенная по слоям: фон, контуры, заливка, рамка
Иллюстрация: Кира Кустова

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

Как обрабатывается HTML, CSS и JS код перед тем, как станет веб-страницей.

Время чтения: 9 мин

Кратко

Скопировано

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

  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>
          <!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>
  <head>
    <meta charset="utf-8">
    <title>Hello</title>
  </head>
  <body>
    <p class="text">Hello world</p>
    <img src="/hello.jpg" alt="Привет!">
  </body>
</html>

        
        
          
        
      

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

DOM дерево

Пока браузер парсит документ и строит DOM, он натыкается на элементы типа <img>, <link>, <script>, которые содержат ссылки на другие ресурсы.

Если ресурс неблокирующий (например, изображение), браузер запрашивает его параллельно с парсингом оставшейся части документа. Блокирующие ресурсы (например, скрипты) приостанавливают обработку до своей полной загрузки.

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

        
          
          // script.jsconst image = document.getElementById('image')
          // script.js
const image = document.getElementById('image')

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

        
        
          
        
      

В этом случае image === undefined, потому что браузер успел распарсить только часть документа до этого тега <script>.

А в этом всё в порядке, изображение найдётся:

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

        
        
          
        
      

И в этом тоже порядок, атрибут defer скажет браузеру продолжать парсить страницу и выполнить скрипт потом:

        
          
          <body>  <script src="script.js" defer></script>  <img src="/hello.jpg" alt="Hello world" id="image"></body>
          <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: 1.5rem;}.text {  color: red;}img {  max-width: 100%;}
          body {
  font-size: 1.5rem;
}

.text {
  color: red;
}

img {
  max-width: 100%;
}

        
        
          
        
      

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

CSSOM дерево

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

Render Tree

Скопировано

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

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

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

Render tree

Обратите внимание, что в 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 = () => {  // Код анимации}
          const animate = () => {
  // Код анимации
}

        
        
          
        
      

Эта функция запускает новый кадр анимации: обновляет какое-то свойство или перерисовывает canvas.

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

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

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

        
        
          
        
      

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

        
          
          window.requestAnimationFrame(animate)
          window.requestAnimationFrame(animate)

        
        
          
        
      

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

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

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

Анимация с setInterval

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

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

Анимация с requestAnimationFrame

На практике

Скопировано

Саша Беспоясов советует

Скопировано

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

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

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

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

На собеседовании

Скопировано
Задать вопрос в рубрику
🤚 Я знаю ответ

Марина Дорошук  отвечает

Скопировано

Чтобы понять что такое progressive rendering, нужно понимать отличие client-side rendering от server-side rendering.

При client-side rendering (CSR) контент отрисовывается на стороне клиента (в браузере). Такой подход используется в React, когда браузеру отсылается практически пустой HTML-документ, а потом запускается скрипт, который генерирует HTML в указанном скрипту теге. Как правило это <div id="root">. Пользователь будет видеть пустую страницу, пока JS-файл полностью не загрузится.

При server-side rendering (SSR) HTML-разметка генерируется на сервере, отсылается браузеру и после этого отрисовывается на клиенте. Пользователь увидит контент сразу же, но не сможет взаимодействовать со страницей, пока не загрузится JS-файл.

При использовании прогрессивного рендеринга, кусочки HTML генерируется на сервере и отсылаются браузеру в порядке их приоритетности. То есть, элементы с самым высоким приоритетом (например <header>, фон, главная интерактивная часть страницы) генерируются на сервере, отсылаются браузеру и отрисовываются в первую очередь. Это позволяет пользователю увидеть самый важный контент как можно скорее, не дожидаясь полной загрузки всего контента. То есть, progressive rendering что-то среднее между client-side rendering и server-side rendering.

Техники реализации прогрессивного рендеринга:

  1. Ленивая загрузка (Lazy Loading). Загрузка контента по мере необходимости. Например, если страница достаточно большая, не нужно загружать изображения вне вьюпорта. Загрузка изображения стартует за некоторое время до того как она появится во вьюпорте. Эту же технику можно использовать для загрузки контента изначально скрытых элементов. Например, можно загрузить контент закрытого меню когда пользователь наводит курсор на кнопку открытия.
  2. Приоритизация контента. Например, не загружать изначально все CSS-стили. Добавлять в <head> загрузку только тех стилей, которые нужны для текущей видимой области HTML-документа. Остальные стили можно добавить в <body>.