Это третья часть из 4-х, посвященных работе браузеров. Ранее мы рассматривали многопроцессорную архитектуру и навигацию. В этом посте мы рассмотрим, что происходит внутри *рендер-процесса (renderer process).
Часть 1
Часть 2
Часть 3 (текущая)
Часть 4
- в ходе перевода, я старался вычленять из статьи ПОНЯТИЯ, т.е. текстовые единицы которые несут специальный (технический) смысл. В переводе эти понятия выделены по особенному — во первых понятия предваряются символом звёздочки, во-вторых в них вместо пробела используется тире. Например: *браузер-процесс, *сайто-изоляция. При переводе понятий, приоритет отдавался не красоте перевода, а желанию выделить, акцентировать, то что мы имеем дело с ПОНЯТИЕМ, а не с фигурой речи.
- также, некоторые слова переведены неверно с точки зрения русского языка, в жаргонном стиле, например пайплайн, продакшен. У "технарей" такой перевод не вызовет затруднений, у остальных читателей прошу прощения.
*Рендер-процесс затрагивает многие аспекты производительности веб. Так как внутри *рендер-процесса происходит много чего, этот пост является лишь общим обзором. Если вы хотите копнуть глубже, то в разделе "Производительность" Web Fundamentals есть гораздо больше ресурсов.
*Рендер-процессы обрабатывают веб-контент
*Рендер-процесс отвечает за все, что происходит внутри вкладки. В *рендер-процессе главный поток обрабатывает бОльшую часть кода, который вы посылаете пользователю. Иногда части вашего JavaScript обрабатываются рабочими потоками, если вы используете web worker или *сервис-воркер (service worker). *Композ-потоки (compositor threads) и *растр-потоки (raster threads) также запускаются внутри *рендер-процессов для эффективного и гладкого рендеринга страницы.
Основная задача *рендер-процесса — превратить HTML, CSS и JavaScript в веб-страницу, с которой пользователь может взаимодействовать.
Рисунок 1: *Рендер-процесс с главным потоком, worker потоками, *композ-потоком, и *растр-потоком внутри
Парсинг
= Конструкция DOM
Когда процесс рендеринга получает сообщение о необходимости выполнить навигацию и начинает получать HTML-данные, главный поток начинает разбирать текстовую строку (HTML) и превращать ее в Document Object Model (DOM).
DOM — это внутреннее представление страницы браузером, а также структура данных и API, с которым веб-разработчик может взаимодействовать через JavaScript.
Преобразование HTML-документа в DOM определяется стандартом HTML. Вы могли заметить, что приход HTML в браузер никогда не приводит к ошибке. Например, отсутствующий закрывающий тег </p>
является допустимым HTML. Ошибочная разметка, например такая как Hi! <b>I'm <i>Chrome</b>!</i>
(тег b
закрывается раньше тега i
) обрабатывается так, как будто вы написали Hi! <b>Я <i>Хром</i></b><i>!</i>
. Это связано с тем, что спецификация HTML разработана так чтобы обходить такие ошибки. Если вам интересно, как это делается, вы можете прочитать в разделе "Введение в обработку ошибок и странных случаев в парсере" спецификации HTML.
= Загрузка подресурсов
Веб-сайт обычно использует внешние ресурсы, такие как изображения, CSS и JavaScript. Эти файлы необходимо загружать из сети или кэша. Главный поток может запрашивать их по очереди, по мере того, как они встречаются при парсинге для создания DOM, а для ускорения конкурентно запускает "preload scanner ". Если в HTML-документе есть такие вещи, как <img>
или <link>
, preload scanner проглядывает токены, сгенерированные HTML-парсером, и посылает запросы *сетевому-потоку *браузер-процесса.
Рисунок 2: Главный поток парсит HTML и строит DOM дерево
= JavaScript может блокировать парсинг
Когда HTML-парсер встречает тег <script>
, он приостанавливает парсинг HTML документа и должен загрузить, разобрать и выполнить JavaScript код. Почему? Потому что JavaScript может изменить форму документа, используя такие вещи, как document.write()
, которая изменяет всю структуру DOM (обзор модели парсинга в спецификации HTML имеет хорошую схему). Вот почему парсер HTML должен подождать, пока выполнится JavaScript, прежде чем он сможет возобновить парсинг HTML-документа. Если вам интересно, что происходит при выполнении JavaScript, команда разработчиков движка V8 ведет обсуждения и пишет в блоге об этом.
Подсказка браузеру как вы хотите загружать ресурсы
Существует множество способов, с помощью которых веб-разработчики могут подсказывать браузеру, чтобы оптимально загружать ресурсы. Если ваш JavaScript не использует document.write()
, вы можете добавить атрибут async
или defer
к тегу <script>
. В этом случае браузер загружает и запускает JavaScript код асинхронно и не блокирует парсинг. Вы также можете использовать JavaScript модуль, если это подходит. <link rel="preload">
— это способ сообщить браузеру, что ресурс определенно необходим для текущей навигации, и вы хотели бы загрузить его как можно быстрее. Подробнее об этом можно прочитать в разделе Resource Prioritization – Getting the Browser to Help You.
Вычисление стилей
Иметь DOM недостаточно, чтобы знать, как будет выглядеть страница, потому что мы ещё можем стилизовать элементы страницы с помощью CSS. Главный поток разбирает CSS и определяет вычисленный стиль для каждого узла DOM. Это информация о том, какой тип стиля применяется к каждому элементу на базе CSS-селекторов. Вы можете увидеть эту информацию в разделе вычисленного CSS в DevTools.
Рисунок 3: Главный поток парсит CSS и добавляет вычисленный стиль
Даже если вы не предоставляете CSS, каждый узел DOM имеет вычисленный стиль. Тег <h1>
отображается визуально больше, чем тег <h2>
, и для каждого элемента определяются мэрджины. Это происходит потому, что браузер имеет таблицу стилей по умолчанию. Если вы хотите узнать, какой CSS по умолчанию в Chrome, вы можете посмотреть исходный код здесь.
Размещение (Layout)
Теперь *рендер-процесс знает структуру документа и стили для каждого узла, но этого недостаточно для рендеринга страницы. Представьте, что вы пытаетесь описать картину своему другу по телефону. "Есть большой красный круг и маленький синий квадрат" — этого недостаточно, чтобы ваш друг знал, как именно будет выглядеть картина.
Размещение представляет собой процесс поиска геометрии элементов. Главный поток проходит через DOM и вычисляет стили и создает *дерево-размещений (layout tree), содержащее такую информацию, как координаты x y и размеры ограничивающих прямоугольников. *Дерево-размещений похоже на дерево DOM, но оно содержит только информацию, относящуюся к тому, что видно на странице. Если указано display: none
, то такой элемент не является частью *дерева-размещений (однако, элемент с visibility: hidden
будет присутствовать в *дереве-размещений). Аналогично, если применен псевдо-класс с содержимым вида p::before{content: "Hi!"}
, он включается в *дерево-размещений, даже если его нет в DOM-дереве.
Рисунок 4: Человек стоящий возле холста звонит другому человеку
Рисунок 5: Главный поток проходит по DOM дереву с рачитанными стилями и создаёт *дерево-размещений
Определение размещений страницы — непростая задача. Даже самая простая раскладка страницы, как например последовательнсть блоков сверху вниз, должна учитывать, насколько велик шрифт и где разбить его на отдельные строки, так как это влияет на размер и форму абзаца; это затем влияет на то, где должен быть следующий абзац.
CSS может заставить элемент прижиматься в одну сторону, маскировать перехлёст элемента и изменять направление письма. Можете себе представить, насколько мощная задача возникает на этом этапе компоновки. В Chrome над версткой работает целая команда инженеров. Если вы хотите увидеть подробности их работы, то несколько докладов из BlinkOn Conference записаны и достаточно интересны для просмотра.
Рисунок 6: Прямоугольник параграфа сдвигается из-за переноса строки
Отрисовка
Для отображения страницы недостаточно иметь DOM, стиль и размещения. Допустим, вы пытаетесь воспроизвести картину. Вы знаете размер, форму и расположение элементов, но вы все еще должны решить, в каком порядке их рисовать.
Например, для некоторых элементов может быть задан z-index, в этом случае рисование элементов в том порядке как они расположены в HTML, приведет к некорректному рендерингу.
Рисунок 7: Человек у холста держит кисть, и размышляет что он должен нарисовать сначала, квадрат или круг
Рисунок 8: Элементы страницы отрисовываются в порядке их появления в HTML, что приводит к ошибочной отрисовке потому что не был принят во внимание z-index
На этом этапе отрисовки, главный поток проходит по *дереву-размещений для создания записей об отрисовке (*отр-записей, Paint Records). *Отр-запись — это запись хода отрисовки вроде "сначала фон, потом текст, потом прямоугольник". Если вы рисовали на элементе <canvas>
с помощью JavaScript, этот процесс может быть вам знаком.
Рисунок 9: Главный поток проходит по *дереву-размещений и создаёт *отр-записи
= Обновление пайплайна рендеринга это затратно
Самое важное, что нужно понимать в пайплайне рендеринга, это то, что на каждом шаге результат предыдущей операции используется для создания новых данных. Например, если что-то меняется в *дереве-размещений, то порядок отрисовки необходимо регенерировать для затрагиваемых частей документа.
Рисунок 10: DOM+стиль, *дерево-размещений и *дерево-отрисовки в порядке их генерации
Если вы анимируете элементы, браузер должен выполнять эти операции между каждым кадром. Большинство наших дисплеев обновляют экран 60 раз в секунду; анимация будет выглядеть гладко для человеческих глаз, когда вы перемещаете вещи по экрану в каждом кадре. Однако, если анимация пропускает промежуточные кадры, то страница будет выглядеть "дрожащей" ("janky").
Рисунок 11: Кадры анимации на временнОй шкале
Даже если операции рендеринга идут в ногу с обновлением экрана, эти вычисления выполняются в главном потоке, что означает, что они могут быть заблокированы, когда ваше приложение работает над JavaScript.
Рисунок 12: Кадры анимации на временнОй шкале, но один из кадров блокирован из-за JavaScript
С помощью функции requestAnimationFrame()
можно разделить работу JavaScript на небольшие фрагменты (чанки) и запланировать запуск каждого фрагмента. Подробнее об этом можно прочитать в разделе Оптимизация выполнения JavaScript. Вы также можете запустить JavaScript в Web Workers, чтобы избежать блокировки главного потока.
Рисунок 13: Небольшие чанки выполнения JavaScript на временнОй шкале с кадрами анимации
*Композитинг (Compositing)
= Как может отрисовываться страница?
Рисунок 14. Анимация наивного процесса растеризации
Теперь, когда браузер знает структуру документа, стиль каждого элемента, геометрию страницы и порядок отрисовки, как он рисует страницу? Превращение этой информации в пиксели на экране называется растеризацией (rasterizing).
Возможно, наивным способом справиться с этим было бы растеризовать части внутри viewport. Если пользователь прокручивает страницу, то перемещает растровую рамку и заполняет недостающие части растровыми данными. Именно так Chrome обрабатывал растеризацию, когда она была впервые реализована. Однако современный браузер запускает более сложный процесс, называемый *композитингом (compositing).
= Что такое *композитинг
*Композитинг — это техника разделения частей страницы на слои, растеризации их по отдельности и композиции как страницы в отдельном потоке, называемом "поток-композитор" (*композ-поток, compositor thread). Когда происходит прокрутка, так как слои уже растеризованы, все, что нужно сделать — это составить новый кадр. Анимация может быть достигнута тем же способом, путем перемещения слоев и композиции нового кадра.
Вы можете увидеть, как ваш сайт разделен на слои в DevTools с помощью панели Layers.
Рисунок 15. Анимация выполнения *композитинга
= Деление на слои
Чтобы узнать, какие элементы должны быть в каких слоях, главный поток проходит по *дереву-размещений для создания *дерева-слоев (эта часть называется "Update Layer Tree" на панели Performance в DevTools). Если отдельные части страницы, которые должны быть отдельным слоем (например, боковое меню-слайдер), не получают этого, то вы можете дать подсказку браузеру, используя атрибут will-change
в CSS.
Рисунок 16. Главный поток проходит по *дереву-размещений (layout tree) создавая *дерево-слоёв (layer tree)
Может возникнуть соблазн дать слой каждому элементу, но *композитинг на избыточном количестве слоев может привести к более медленной работе, чем растеризация маленьких частей страницы в каждом кадре, поэтому очень важно измерить производительность рендеринга в вашем приложении. Подробнее о том, как это сделать, см. в разделе Stick to Compositor-Only Properties and Manage Layer Count.
= Растеризация и композиция вне главного потока
После создания *дерева-слоев и определения порядка рисования главный поток направляет эту информацию в *композ-поток. Затем *композ-поток растеризует каждый слой. Слой может быть большим, на всю длину страницы, поэтому *композ-поток разделяет его на плитки и отправляет каждую плитку в *растр-потоки (raster threads). *Растр-потоки растеризуют каждую плитку и сохраняют ее в памяти GPU.
Рисунок 17. *Растр-потоки создающие картинки плиток и отправляющие их в GPU
*Композ-поток может расставлять приоритеты для разных *растр-потоков, чтобы объекты во viewport (или рядом с ним) могли быть растеризованы первыми. Слой также имеет несколько плиток для различных разрешений, чтобы справиться с такими вещами, как масштабирование.
Как только плитки растрированы, *композ-поток собирает информацию о плитках, которую называют *отрисовками-квадрантов (draw quads), чтобы создать *кадр-композитор (compositor frame).
*Отрисовки-квадрантов (draw quads)
Содержит такую информацию, как место плитки в памяти и место на странице для отрисовки плитки с учетом композиции страницы.
*Кадр-композитор (compositor frame)
Коллекция *отрисовок-квадрантов, представляющая кадр страницы.
Затем через IPC-запрос в *браузер-процесс передается *кадр-композитор. В этот момент из *UI-потока может быть добавлен другой *кадр-композитор для изменения пользовательского интерфейса браузера или из других *рендер-процессов для расширений. Эти *кадр-композиторы отправляются в GPU для отображения на экране. Если приходит событие прокрутки, *композ-поток создает еще один *кадр-композитор для отправки в GPU.
Рисунок 18. *Композ-поток создающий *кадр-композитор. Кадр отправляется в *браузер-процесс и затем в GPU
Преимущество композиции заключается в том, что она выполняется без вовлечения главного потока. *Композ-поток не должен ждать вычисления стилей или выполнения JavaScript. Поэтому композитирование только анимации считается лучшим решением для плавного исполнения. Если размещения или отрисовка должны быть рассчитаны заново, то должен быть задействован главный поток.
Краткие итоги
В этом посте мы посмотрели на пайплайн рендеринга от парсинга до *композитинга. Будем надеяться, что теперь вы вдохновлены прочитать больше об оптимизации производительности сайта.
В следующем и последнем посте этой серии мы более подробно рассмотрим *композ-поток и увидим, что происходит, когда пользователь вводит данные, например, перемещает мышь и нажимает на ней кнопку.