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

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

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

Кратко

Секция статьи "Кратко"

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

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

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

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

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

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

Секция статьи "Получение ресурсов, 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

Секция статьи "Парсинг, Parsing"

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

DOM

Секция статьи "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>

        
        
          
        
      

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

                  html
        ____________|________
        |                    |
      head                  body
    ____|____              ___|___
    |       |             |       |
    meta   title          p      img
            |             |
          "Hello"    "Hello world"

Пока браузер парсит документ и строит 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

Секция статьи "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 {
  color: red;
}

img {
  max-width: 100%;
}

        
        
          
        
      

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

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

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

Render Tree

Секция статьи "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

Секция статьи "Вычисление позиции и размеров, Layout"

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

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

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

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

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

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

Секция статьи "Глобальный и инкрементальный Layout"

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

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

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

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

Секция статьи "«Грязные» элементы"

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

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

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

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

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

Секция статьи "Непосредственно отрисовка, Paint"

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

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

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

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

Секция статьи "Порядок отрисовки"

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

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

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

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

Секция статьи "CPU и композитинг"

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

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

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

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

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

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

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

Секция статьи "Перерисовка, 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 не учитывает, на какой стадии отрисовки находится страница, и в итоге кадры отрисовки могут быть рваными или дёрганными:

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

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

  ....|....|..||...|....|.........||||..|....|...|...|....|...

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

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

  ....|....|....|...|....|...|....|....|...|.....|....|....

На практике

Секция статьи "На практике"

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

Секция статьи "Саша Беспоясов"

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

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

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

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