От кода до пикселей: как работает рендеринг. Часть 1. Браузерные движки.

От кода до пикселей: как работает рендеринг. Часть 1. Браузерные движки.

Anastasia Kotova

Введение

Фронтенд — это не только логика на JavaScript. В конечном итоге всё сводится к тому, что именно и как пользователь видит на экране. Это может быть сложная форма с кучей состояний и валидаций, а может — падающие снежинки в интерфейсе под Новый год.

HTML, CSS, вёрстка: не все фронтенд-разработчики любят глубоко погружаться в эти темы. А мне, как обычно, хочется залезть в самые кишочки происходящего. Поэтому в этом цикле мы будем разбираться, как именно работает рендеринг — что происходит между моментом, когда браузер получил URL страницы и загрузил HTML, и моментом, когда мы увидели готовую страницу. И начнём с архитектуры браузера и того, из каких частей он вообще состоит.

Многопроцессорная архитектура браузера на примере Chromium

В предыдущих статьях мы много говорили про V8 — движок, который выполняет JavaScript и используется не только в Node.js, но и во многих браузерах. Но браузер — это гораздо более сложная система, и V8 в ней всего лишь один из компонентов.

В сегодняшней статье я буду опираться на Chromium — open-source проект, на базе которого работают Chrome, Edge, Opera и другие браузеры.

Одна из ключевых особенностей Chromium — многопроцессная архитектура. Вместо одного процесса браузер разбит на несколько независимых процессов, каждый из которых отвечает за свою задачу. Это сделано в первую очередь ради безопасности и устойчивости: если что-то сломалось в одном процессе, весь браузер не упадёт.

Процесс — это изолированный экземпляр программы. У процесса есть собственное адресное пространство памяти, свои ресурсы (heap, стек, файловые дескрипторы) и ограничения доступа. Процессы не могут напрямую читать или писать память друг друга — для этого нужны специальные механизмы (IPC).

Главный процесс называется browser process. Он отвечает за пользовательский интерфейс браузера, управление вкладками, окнами и за координацию остальных процессов.

Отдельно существуют дочерние renderer processes — процессы, которые занимаются отображением веб-контента. Именно в них происходит разбор HTML, выполнение JavaScript и, в конечном итоге, рендеринг страницы. Каждый такой процесс использует движок Blink для интерпретации и отображения контента.

Внутри процесса рендеринга находятся специальные объекты RenderFrame, которые соответствуют фреймам с документами — основному документу страницы и iframe.

Обычно новая вкладка или окно запускается в новом процессе рендеринга. Browser process создаёт процесс и говорит ему создать один RenderFrame. Внутри страницы при этом могут появляться iframe — иногда в том же процессе, а иногда в других.

И тут возникает важный вопрос: сколько вообще процессов рендеринга создаётся?

С точки зрения безопасности идеальный вариант — отдельный процесс на каждый сайт. Это называется изоляцией сайтов. Сайт здесь — это протокол URL + регистрируемый домен + один уровень поддомена. Например, mail.example.com и chat.example.com считаются одним сайтом, а example.com и example2.com — разными.

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

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

Так как renderer работает отдельно от browser process, Chromium может жёстко ограничивать его права с помощью sandbox. Рендерер не может напрямую ходить в сеть, работать с файловой системой или получать доступ к устройствам ввода — всё это делается через специальные сервисы браузера. Это сильно снижает потенциальный ущерб, если процесс рендеринга будет скомпрометирован.

Помимо browser и renderer процессов, Chromium выносит в отдельные процессы и другие подсистемы — например, GPU, сетевой стек или хранилище данных. При этом вся сетевая коммуникация контролируется browser process, чтобы централизованно управлять cookies, кэшем и количеством соединений.

Blink — это движок рендеринга, используемый в Chromium. Именно он отвечает за всё, что связано с отображением контента во вкладке браузера.

В его обязанности входит:

  • реализация веб-стандартов (HTML, CSS, DOM);
  • встраивание V8 и запуск JavaScript;
  • загрузка ресурсов через сетевой стек;
  • построение DOM-дерева;
  • расчёт стилей и layout;
  • взаимодействие с Chrome Compositor и отрисовка графики.

Процесс рендеринга — это не один поток. В Blink есть основной поток, несколько рабочих потоков и несколько внутренних служебных потоков. При этом почти всё важное происходит именно в основном потоке: выполнение JavaScript, работа с DOM, расчёт стилей и layout.

Поток (thread) — это единица выполнения внутри процесса. Несколько потоков разделяют память и ресурсы одного процесса, но выполняются параллельно. Создавать потоки дешевле, чем процессы, но ошибки в одном потоке могут повлиять на весь процесс.

Blink исторически оптимизирован под такую преимущественно однопоточную модель. Рабочие потоки используются для Web Workers, Service Workers и Worklets. Дополнительные внутренние потоки могут заниматься аудио, базами данных, сборкой мусора и другими задачами. Взаимодействие между потоками происходит через передачу сообщений.

Теперь немного про внутренние сущности Blink.

  • Page почти соответствует вкладке браузера.
  • Frame — это фрейм страницы: основной документ или iframe.
  • DOMWindow — это объект window в JavaScript.
  • Document — это window.document.

Связи между ними выглядят так:

  • один процесс рендеринга может содержать несколько Page;
  • одна Page может содержать несколько Frame;
  • у каждого Frame в конкретный момент времени есть ровно один DOMWindow и один Document.

При навигации Frame может переиспользоваться, но DOMWindow и Document при этом создаются заново. Например, после выполнения кода iframe.contentWindow.location.href = "<https://example.com>" для существующего на странице iframe, создаётся новый Document и новый window, но сам объект Frame может остаться тем же.

Изоляция сайтов добавляет ещё один уровень сложности. Если страница содержит межсайтовый iframe, разные части страницы могут обслуживаться разными процессами рендеринга.

Например код на сайте https://example.com:

<body>
  <iframe src="<https://example2.com>"></iframe>
</body>

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

Взаимодействие с V8

В последней части моего цикла про V8 мы уже разбирали взаимодействие Blink и V8, но здесь тоже важно повторить это.

Чтобы понять этот механизм, важно разобраться в трёх понятиях: изолят, контекст и мир. Они описывают разные уровни изоляции JavaScript-кода и отвечают на разные вопросы.

Изолят (Isolate) — это изолированная среда выполнения JavaScript внутри V8.

Проще всего думать о нём как об отдельной JS-машине со своей памятью и сборщиком мусора. В Chromium изолят жёстко привязан к потоку: у основного потока есть свой изолят, и у каждого worker’а — свой. Это означает, что JavaScript из разных потоков никогда не делит память напрямую.

Контекст (Context) — это конкретный контекст выполнения JavaScript, связанный с глобальным объектом.

Для обычной страницы это объект window. У каждого Frame есть свой window, а значит — свой контекст. При навигации контекст пересоздаётся: появляется новый window, новые глобальные переменные и новый JavaScript-окружение, даже если сам Frame при этом остаётся тем же.

Мир (World) — это логическая «песочница» для JavaScript-кода.

Эта концепция не существует в веб-стандартах и используется браузером для изоляции разных источников JavaScript. Самый наглядный пример — расширения браузера. Скрипты страницы и скрипты расширений должны работать с одним и тем же DOM, но при этом не иметь доступа к JavaScript-объектам друг друга. Для этого браузер создаёт несколько миров: основной мир страницы и отдельные изолированные миры для каждого из расширений. Важно, что DOM-дерево при этом общее — оно реализовано на стороне C++ в Blink. А вот JavaScript-объекты, через которые этот DOM доступен, разные для каждого мира.

Если собрать всё вместе, получается следующая картина:

  • в одном потоке есть один изолят;
  • внутри него могут существовать несколько фреймов;
  • у каждого фрейма может быть несколько миров;
  • и для каждой пары «фрейм + мир» создаётся свой JavaScript-контекст (то есть свой window).
Thread
 └── Isolate
      └── Frame #1
      │    ├── World #1 (страница)
      │    │     └── Context (window)
      │    ├── World #2 (расширение A)
      │    │     └── Context (window)
      │    └── World #3 (расширение B)
      │          └── Context (window)
      └── Frame #2
           ├── World #4 (страница)
           │     └── Context (window)
           └── World #5 (расширение C)
                 └── Context (window)

В worker’ах всё проще: там всегда один поток, один изолят, один мир и один контекст.

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

От момента, когда HTML попадает в Blink, до появления пикселей на экране проходит длинный путь. Этот путь состоит из нескольких этапов — и именно их мы будем разбирать в следующей части.

Report Page