Примечание: тем, кто стремится досконально разобраться в том, как устроены браузеры, настоятельно рекомендую отличную книгу «Browser Engineering» Павла Панчехи и Криса Харрелсона (доступна здесь). Эта серия статей — лишь общий обзор принципов работы браузеров.

Веб-разработчики нередко воспринимают браузер как «черный ящик», который каким-то чудом превращает HTML, CSS и JS в интерактивные веб-приложения. На самом деле современный браузер — будь то Chrome (на базе Chromium), Firefox (Gecko) или Safari (WebKit) — представляет собой чрезвычайно сложное программное решение. Он управляет сетевыми запросами, разбирает (парсит) и выполняет код, рендерит графику с ускорением на графическом процессоре (GPU) и изолирует контент в отдельных процессах для обеспечения безопасности.

В этой серии статей мы подробно рассмотрим, как устроены современные браузеры, сделав акцент на архитектуре и внутреннем устройстве Chromium, но также отметим ключевые отличия в других браузерах. Мы рассмотрим весь цикл: от сетевого стека и конвейера парсинга до рендеринга с помощью Blink, выполнения JS с помощью движка V8, загрузки модулей, многопроцессной архитектуры, песочниц безопасности и инструментов разработчика. Главная цель — дать понятное и доступное объяснение того, что происходит в браузере «под капотом».

Начнем наше увлекательное путешествие.

Сетевые операции и загрузка ресурсов

Загрузка любой страницы начинается с того, что сетевая подсистема браузера запрашивает необходимые ресурсы из Интернета. Когда мы вводим URL или кликаем по ссылке, пользовательский интерфейс браузера (работающий в так называемом процессе браузера) выполняет запрос на навигацию.

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

Процесс включает в себя следующие шаги:

  • Разбор URL и проверка безопасности: браузер анализирует введенный адрес, чтобы определить протокол (например, HTTP или HTTPS) и целевой домен. Он также решает, ввел ли пользователь поисковый запрос или это все же адрес сайта (как, например, в строке ввода Chrome). На этом этапе могут выполняться проверки безопасности — например, сверка с черными списками, чтобы защитить пользователя от фишинговых сайтов.

  • Поиск DNS: сетевая подсистема определяет IP-адрес домена (если он отсутствует в кэше). Для этого может потребоваться обращение к DNS-серверу. Современные браузеры могут использовать системные DNS-службы или DNS через HTTPS (DoH), если эти механизмы настроены, но в итоге цель одна — получить IP-адрес хоста.

  • Установка соединения: если с сервером нет активного подключения, браузер открывает новое. Для HTTPS-адресов это подразумевает выполнение TLS-рукопожатия — безопасного обмена ключами и проверки сертификатов. Поток сети браузера управляет протоколами (TCP, TLS и др.) самостоятельно, без участия пользователя.

  • Отправка HTTP-запроса: после установки соединения отправляется запрос (чаще всего HTTP GET) для получения ресурса. Современные браузеры используют HTTP/2 или HTTP/3, если сервер их поддерживает. Эти протоколы позволяют передавать несколько ресурсов по одному соединению, что ускоряет загрузку и устраняет ограничение старого HTTP/1.1 (около 6 параллельных запросов на хост). Например, с HTTP/2 HTML, CSS, JS и изображения могут загружаться одновременно по одному TCP/TLS-каналу, а HTTP/3 (работающий поверх QUIC/UDP) еще больше сокращает задержку при установке соединения.

  • Получение ответа: сервер возвращает HTTP-статус, заголовки и тело ответа (разметку HTML, данные JSON и т.д.). Браузер читает поток данных и при необходимости определяет тип содержимого, если заголовок Content-Type отсутствует или указан неверно. Например, если ответ выглядит как HTML, но не помечен как таковой, браузер все равно обработает его как HTML (в соответствии с гибкими веб-стандартами). На этом этапе также действуют механизмы безопасности: проверка Content-Type, блокировка подозрительных MIME-несоответствий и защита от межсайтовых утечек данных (например, CORB — Cross-Origin Read Blocking в Chrome). Кроме того, браузер сверяется с такими сервисами, как Safe Browsing, для блокировки вредоносных данных.

  • Перенаправления (редиректы) и последующие шаги: если ответ — это перенаправление (например, HTTP-статус 301 или 302 с заголовком Location), браузер следует по новому адресу (уведомив при этом UI-поток) и повторяет запрос. Только после получения итогового ответа с реальным содержимым браузер переходит к его обработке.

Все эти этапы выполняются внутри сетевой подсистемы (network stack), которая в Chromium работает в отдельном процессе — Network Service (часть инициативы по разделению сервисов, «сервисификации»). Поток сети в основном процессе браузера координирует низкоуровневую работу с сокетами (sockets) через системные сетевые API. Такая архитектура важна для безопасности: рендерер, который выполняет код страницы, не имеет прямого доступа к сети — он лишь запрашивает нужные ресурсы у основного процесса.

Спекулятивная загрузка и оптимизация ресурсов

Современные браузеры применяют сложные оптимизации производительности на этапе работы с сетью. Chrome, например, может выполнять предварительный DNS-запрос или открывать TCP-соединение, когда пользователь наводит курсор на ссылку или начинает вводить URL (механизмы «Predictor» и «Preconnect»). Это сокращает задержку при фактическом переходе на страницу. Также действует HTTP-кэширование: сетевой стек может обслуживать запросы из кэша браузера, если ресурс уже сохранен и актуален, избегая отправки запросов.

Работа сканера предварительной загрузки: в Chromium реализован продвинутый сканер предзагрузки, который анализирует HTML-разметку до основного парсера. Когда основной HTML-парсер блокируется CSS или синхронным JS, сканер предзагрузки продолжает проверку разметки и выявляет ресурсы (изображения, скрипты, стили), которые можно загрузить параллельно. Этот механизм является ключевым для производительности современных браузеров и работает автоматически. Однако сканер не обнаруживает ресурсы, добавленные через JS, поэтому такие ресурсы загружаются последовательно.

Ранние подсказки (HTTP 103): позволяют серверам отправлять подсказки по ресурсам во время генерации основного ответа с помощью кода состояния HTTP 103. Это дает возможность заранее отправлять подсказки предварительного подключения (preconnect) и предзагрузки (preload) во время основной работы сервера, сокращая время отрисовки наибольшего содержимого страницы (Largest Contentful Paint, LCP) на несколько сотен миллисекунд. Ранние подсказки (Early Hints) доступны только для навигационных запросов и поддерживают директивы preconnect и preload, но не prefetch.

API правил спекулятивной загрузки: современный веб-стандарт, позволяющий задавать правила для динамической предзагрузки и предварительного рендеринга URL на основе поведения пользователя. В отличие от обычного prefetch, этот API может рендерить целые страницы с выполнением JS, обеспечивая почти мгновенную загрузку. Правила определяются в формате JSON внутри <script> или заголовков HTTP. В Chrome установлены ограничения для предотвращение их чрезмерного использования, с разными лимитами в зависимости от уровня важности.

HTTP/2 и HTTP/3: большинство браузеров на базе Chromium и Firefox полностью поддерживают HTTP/2, а HTTP/3 (на основе QUIC) также широко доступен (в Chrome включен по умолчанию для поддерживающих сайтов). Эти протоколы ускоряют загрузку страниц за счет одновременной передачи нескольких ресурсов и сокращения расходов на установку соединений. Для разработчиков это означает, что теперь может отпасть необходимость в использовании спрайтов или разделении доменов — браузер эффективно загружает множество мелких файлов параллельно через одно соединение.

Приоритет ресурсов: браузер распределяет ресурсы по приоритету. Обычно HTML и CSS имеют высокий приоритет (так как блокируют рендеринг), скрипты — средний или высокий приоритет при использовании атрибутов defer/async, а изображения — низкий. Сетевой стек Chromium распределяет приоритеты и может отменять или откладывать запросы, чтобы ускорить рендеринг. Разработчики могут влиять на приоритет с помощью [<link rel=preload>(https://web.dev/articles/preload-critical-assets)] и атрибута Fetch Priority.

К концу сетевого этапа браузер получает начальный HTML страницы (если это HTML-навигация). В этот момент процесс браузера выбирает процесс рендеринга (renderer process) для обработки контента. Chrome часто запускает новый рендерер параллельно с сетевым запросом (спекулятивно), чтобы он был готов к работе, когда данные поступят. Этот процесс изолирован и отвечает за парсинг и рендеринг страницы.

После полной или частичной загрузки ответа процесс браузера завершает навигацию: передает поток байтов рендереру для обработки. В этот момент обновляется адресная строка и индикатор безопасности (замок HTTPS и др.) для нового сайта. Дальше управление переходит к рендереру: парсинг HTML, загрузка подресурсов, выполнение скриптов и отрисовка страницы.

Парсинг HTML, CSS и JavaScript

Когда процесс рендеринга получает HTML, его основной поток начинает разбор разметки в соответствии со спецификацией HTML. Результатом парсинга становится DOM (Document Object Model) — дерево объектов, представляющих структуру страницы. Парсинг выполняется поэтапно и может чередоваться с чтением данных из сети: браузеры анализируют HTML в потоковом режиме, поэтому построение DOM может начаться еще до полной загрузки HTML-файла.

Парсинг HTML и построение DOM: парсинг HTML по стандарту определяется как устойчивый к ошибкам процесс, который всегда создает DOM, даже если разметка некорректна. Это значит, что если пропущен закрывающий тег </p> или нарушена вложенность тегов, парсер автоматически исправит или скорректирует дерево DOM. Например, разметка <p>Hello <div>World</div> в DOM будет интерпретирована так, что тег <p> закрывается перед <div>. Парсер создает элементы DOM и текстовые узлы для каждого тега и текста в HTML, размещая их в дереве в соответствии с вложенностью исходной разметки.

Важный момент: во время парсинга HTML парсер может наткнуться на ресурсы, которые нужно загрузить. Например, при встрече с <link rel="stylesheet" href="..."> браузер отправит запрос за CSS-файлом (в сетевом потоке), а при встрече с <img src="..."> — за изображением. Эти загрузки происходят параллельно с парсингом. Парсер продолжает работу, пока ресурсы загружаются, за одним важным исключением — скриптами.

Обработка тегов <script>: если HTML-парсер встречает тег <script>, по умолчанию он приостанавливает парсинг и выполняет скрипт. Это необходимо, поскольку скрипты могут использовать document.write() или другие методы изменения DOM, которые могут влиять на структуру или содержимое страницы, которое еще загружается. Выполняя скрипт сразу, браузер сохраняет правильный порядок действий относительно HTML. Парсер передает скрипт движку JS для выполнения, и только после завершения скрипта (и применения всех изменений в DOM) парсинг HTML возобновляется. Именно из-за этого включение больших скриптов в <head> может замедлять рендеринг страницы — парсер не сможет продолжить работу, пока скрипт не будет полностью загружен и выполнен.

Разработчики могут изменить это поведение с помощью атрибутов defer или async для тега <script> (или использовать современные ES6-модули).

async: скрипт загружается параллельно и выполняется сразу после загрузки, не останавливая HTML-парсинг. Парсер продолжает работу, а порядок выполнения скриптов относительно других асинхронных скриптов не гарантирован.
defer: скрипт загружается параллельно, но выполняется только после разбора всего HTML, при этом сохраняется порядок скриптов в документе.

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

ES6-модули (<script type="module">) автоматически ведут себя как отложенные (defer) и могут использовать import для загрузки других модулей.

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

Парсинг CSS и построение CSSOM: помимо HTML, стили CSS также необходимо разобрать в структуру, с которой браузер может работать — обычно ее называют CSSOM (CSS Object Model). CSSOM представляет собой набор всех стилей (правил, селекторов, свойств), применимых к документу.

CSS-парсер браузера считывает CSS-файлы или блоки <style> и превращает их в список правил CSS (с использованием различных оптимизаций, например, фильтров Блума, для ускорения применения стилей). Затем, по мере построения DOM (или когда DOM и CSSOM готовы), браузер вычисляет стили для каждого узла DOM. Этот этап обычно называют вычислением стилей (style resolution / style calculation).

Браузер объединяет DOM и CSSOM, чтобы определить, какие CSS-правила применяются к каждому элементу и каковы итоговые вычисленные стили (с учетом каскада, наследования и стилей по умолчанию). Результат обычно представляют как связывание каждого узла DOM с его вычисленными стилями — окончательными CSS-свойствами элемента (цвет, шрифт, размер и т.д.).

Следует отметить, что даже без CSS, заданного автором сайта, у каждого элемента есть стили по умолчанию браузера (user-agent stylesheet). Например, у <h1> есть стандартный размер шрифта и отступы почти во всех браузерах. Встроенные стили браузера применяются с наименьшим приоритетом, обеспечивая разумное отображение по умолчанию. Разработчики могут посмотреть вычисленные стили в DevTools (инструментах разработчика), чтобы увидеть, какие CSS-свойства в итоге применяются к элементу. Этап вычисления стилей учитывает все доступные правила — встроенные браузером, пользовательские и авторские — для окончательного оформления каждого элемента.

Блокирующее рендеринг поведение: хоть парсинг HTML и может продолжаться без полной загрузки CSS, существует блокирующая рендеринг зависимость: браузеры обычно ждут загрузки CSS (особенно в <head>), перед выполнением первого рендеринга. Это делается во избежание отображения неоформленного содержимого (flash of unstyled content). На практике, если <script> без атрибутов async или defer встречается перед <link> с CSS в HTML, его выполнение также будет ждать загрузки CSS, так как скрипты могут запрашивать информацию о стилях через DOM API.

Как правило, ссылки на таблицы стилей размещают в <head> (они блокируют рендеринг, но нужны на раннем этапе), а некритичные или крупные скрипты лучше помещать с defer/async или внизу страницы, чтобы они не задерживали парсинг DOM.

На этом этапе браузер имеет:

  1. DOM, построенный из HTML.

  2. CSSOM, полученный из разобранных CSS-правил.

  3. Вычисленные стили для каждого узла DOM.

Эти компоненты формируют основу для следующего этапа — формирования макета страницы (layout).

Перед этим стоит подробнее рассмотреть, как движок JS (например, V8 в Chrome) выполняет код. Мы уже упоминали блокировку скриптов, но что происходит при их выполнении? Скрипты могут изменять DOM или CSSOM (например, через document.createElement() или установку стилей элементов). Браузеру может потребоваться повторно вычислять стили или заново формировать макет при таких изменениях, что при частом выполнении может негативно сказываться на производительности.

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

Стилизация и макетирование

На этом этапе процесс рендеринга браузера уже знает структуру DOM и вычисленные стили каждого элемента. Следующий вопрос: где на экране разместить все эти элементы и какого они размера?

Этим занимается layout (также называемый reflow или формирование (вычисление) макета (разметки)). На этом этапе браузер рассчитывает геометрию каждого элемента — его размеры и позицию — с учетом CSS-правил (поток документа, модель коробки (box model), flexbox, grid и др.) и иерархии DOM.

Построение дерева разметки (layout tree): браузер проходит по дереву DOM и создает дерево разметки (также называемое render tree или frame tree). Оно похоже на DOM по структуре, но включает только визуальные элементы: например, теги <script> или <meta> не создают визуальных блоков. При этом, один HTML-элемент может быть представлен несколькими блоками, если он визуально разделен на части — например, текст, переносящийся на несколько строк. Каждый узел дерева разметки содержит вычисленные стили элемента, а также информацию о его содержимом (тексте, изображении) и свойствах, влияющих на размещение — ширина, высота, отступы и т.д.

На этом этапе браузер вычисляет точные координаты (x, y) и размеры (ширину и высоту) каждого блока. Для этого используются алгоритмы, определенные в спецификациях CSS, например: в обычном потоке документа блочные элементы располагаются сверху вниз и занимают всю доступную ширину, а строчные элементы выстраиваются в одну строку и переносятся на новую при нехватке места. Современные режимы компоновки — flexbox и grid применяют собственные алгоритмы.

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

Особенности дерева разметки:

  • Элементы с display: none полностью исключаются из дерева разметки (они не создают никаких блоков). В отличие от них, элементы с visibility: hidden все же имеют блок в дереве (занимают место), но не отображаются при отрисовке.

  • Псевдоэлементы, такие как ::before или ::after, имеющие содержимое, включаются в дерево разметки, так как создают визуальные блоки.

  • Узлы дерева разметки содержат информацию о своей геометрии. Например, узел компоновки для элемента <p> знает свои координаты относительно области просмотра и размеры, а также содержит дочерние узлы для каждой строки или встроенного блока внутри него.

Вычисление разметки: формирование макета обычно является рекурсивным процессом. Начиная с корневого элемента <html>, браузер вычисляет размеры области просмотра для <html> и <body>, затем размещает внутри нее дочерние элементы и т.д. Размер многих элементов определяется размерами их родительских или дочерних элементов — например, контейнер может увеличиваться, чтобы вместить свои дочерние элементы, или дочерний элемент может занимать 50% ширины родителя. Алгоритм разметки иногда выполняет несколько проходов для обработки плавающих элементов или сложных взаимодействий, но в основном процесс идет сверху вниз с возможными откатами при необходимости.

К концу этого этапа известны позиции и размеры каждого элемента на странице. Концептуально страницу можно представить как набор блоков с текстом или изображениями внутри. Однако пока ничего не нарисовано на экране — это следующий этап, отрисовка (painting).

Важно отметить: разметка может потреблять значительные ресурсы и снижать производительность, особенно при повторных вычислениях. Если JS изменяет размер элемента или добавляет новый контент, это может привести к повторному вычислению разметки (relayout) для части или всей страницы. Разработчикам рекомендуется избегать многократного пересчета разметки (layout thrashing) — например, не получать данные о размерах элементов сразу после изменений DOM, чтобы не провоцировать синхронный пересчет разметки.

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

Итоги по стилям и разметке.

Итак, из HTML и CSS браузер строит:

  • DOM-дерево — структура и содержимое страницы

  • CSSOM — разобранные CSS-правила

  • вычисленные стили — результат применения правил CSS к каждому узлу DOM

  • дерево разметки — DOM-дерево, содержащее только визуальные элементы, с рассчитанными размерами и положением каждого узла

Каждый этап строится на основе предыдущего. Если какой-либо этап меняется (например, скрипт изменяет DOM или свойство CSS), последующие этапы могут потребовать обновления. Например, если изменить CSS-класс у элемента, браузер может пересчитать стили для него (и для дочерних элементов, если меняется наследование), затем при необходимости пересчитать разметку, если изменения стиля влияют на геометрию (например, отображение или размеры), и после этого выполнить перерисовку.

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

После формирования макета начинается следующий основной этап: отрисовка.

Отрисовка, компоновка и рендеринг

Отрисовка — это этап, на котором структурированные данные о разметке превращаются в видимое изображение на экране. В традиционном подходе браузер обходил дерево разметки и выполнял команды отрисовки для каждого узла («нарисовать фон, текст, изображение в этих координатах»).

Современные браузеры по сути делают то же самое, но часто разбивают работу на несколько этапов и используют GPU для повышения эффективности.

Отрисовка/растеризация: в основном потоке рендерера, после этапа макетирования, Chrome создает записи для отрисовки (paint records) или список команд отображения (display list), обходя дерево компоновки. По сути, это список операций рисования с координатами, аналогичный плану художника:

  • нарисовать прямоугольник в точке (x, y) с шириной W, высотой H и заливкой синим цветом

  • нарисовать текст «Hello» в точке (x2, y2) со шрифтом XYZ

  • нарисовать изображение… и т.д.

Список формируется в правильном порядке по z-index (чтобы элементы, перекрывающие друг друга, отображались корректно). Например, отрисовка элемента с более высоким z-index выполняется позже, поэтому он накладывается на контент с меньшим z-index. Браузер также учитывает контексты наложения (stacking contexts), прозрачность и другие особенности, чтобы получить нужный результат.

Раньше браузеры могли сразу рисовать каждый элемент на экране по мере обхода дерева разметки, но при изменениях на странице это было неэффективно — приходилось перерисовывать все целиком. Современные браузеры обычно сначала сохраняют команды рисования, а затем с помощью компоновки (compositing) собирают финальное изображение, особенно эффективно используя GPU.

Слои и компоновка: компоновка — это оптимизация, при которой страница разбивается на несколько слоев, которые можно обрабатывать независимо. Например, позиционированный элемент с CSS-трансформацией или анимацией может получить собственный слой. Слои можно представить как отдельные «рабочие холсты» — браузер может нарисовать каждый слой отдельно, а затем композитор (compositor thread) объединяет их на экране, часто с использованием GPU.

В конвейере (pipeline) Chromium, после генерации записей для отрисовки, создается дерево слоев, которое определяет, какие элементы на каком слое находятся. Некоторые слои создаются автоматически (например, <video>, <canvas> или элементы с определенными CSS-свойствами), а разработчики могут давать браузеру подсказки через will-change или свойств, вроде transform. Слои полезны тем, что изменения позиции или прозрачности слоя можно «компоновать» — т.е. обновлять только этот слой без перерисовки всей страницы. Однако, слишком большое количество слоев увеличивает расход памяти и создает дополнительную нагрузку, поэтому браузеры используют их экономно.

После определения слоев основной поток Chrome передает работу композитору. Этот поток выполняется в процессе рендерера, но отдельно от основного потока (что позволяет ему работать даже при загрузке главного JS-потока — это обеспечивает плавность прокрутки и анимации). Задача композитора — взять слои, растеризовать их (превратить в пиксельные битовые карты (bitmaps)) и объединить в кадры для отображения (frames).

Растеризация с помощью GPU: работа по растеризации может распределяться между потоками. В Chrome поток композитора разбивает слои на маленькие плитки (например, 256×256 или 512×512 пикселей, иногда больше при использовании GPU), а затем отправляет их нескольким рабочим потокам для параллельной растеризации. Каждый поток получает плитку — по сути, список команд рисования для этой области слоя — и создает растровое изображение (bitmap). Важно: библиотека Skia (графическая библиотека Chrome) может использовать центральный процессор (CPU) или GPU для растеризации. Обычно потоки используют CPU для отрисовки пикселей и загружают результат в память GPU. В Firefox WebRender делает это немного иначе.

После растеризации плитки хранятся в памяти GPU в виде текстур. Как только все необходимые плитки отрисованы, поток композитора фактически получает готовый набор текстурированных слоев. Композитор собирает композитный кадр — сообщение для браузерного процесса, включающее все плитки слоев и их позиции. Этот кадр отправляется обратно в процесс браузера через IPC (Inter-Process Communication - интерфейс межпроцессного взаимодействия), где отдельный GPU-процесс (в Chrome это отдельный процесс для работы с графикой) получает его и отображает на экране. Интерфейс самого браузера (например, панель вкладок) также рисуется с помощью композитных кадров, и все элементы объединяются на финальном этапе. GPU-процесс обрабатывает эти кадры с помощью GPU (через OpenGL, DirectX, Metal и т.д.), размещая каждую текстуру в нужной позиции на экране и применяя трансформации. В результате получается окончательное изображение, которое пользователь видит на экране.

Преимущество такой схемы особенно заметно при прокрутке или анимации. Например, при прокрутке страницы в основном изменяется лишь та часть большой текстуры страницы, которая видна в области просмотра. Поток композитора просто смещает позиции слоев и поручает GPU отрисовать новую видимую часть, без необходимости полной перерисовки в основном потоке.

Если анимация ограничивается только трансформацией (например, перемещением элемента, который находится на отдельном слое), поток композитора может обновлять позицию элемента для каждого кадра и формировать новые кадры без участия основного потока и без повторного расчета стилей и разметки. Поэтому для хорошей производительности рекомендуются анимации, затрагивающие только компоновку (изменение transform или opacity, которые не вызывают пересчет разметки) — они могут плавно работать с частотой 60 кадров в секунду (FPS) даже при загруженном основном потоке. В тоже время анимация таких свойств, как height или background-color, может потребовать повторного расчета разметки или перерисовки каждого кадра, что заметно замедляет процесс, если основной поток загружен.

Если описывать кратко, конвейер рендеринга в Chrome выглядит так: DOM → стиль → макет → отрисовка (создание списка отображения) → создание слоев → растеризация (tiles - плитки) → компоновка (GPU)

В Firefox процесс до этапа формирования списка отображения аналогичен, но WebRender передает этот список напрямую в GPU, где почти вся отрисовка выполняется с помощью шейдеров. Safari (WebKit) использует многопоточный композитор и GPU-рендеринг через CALayers на macOS. Все современные движки активно используют GPU для рендеринга и компоновки, что повышает частоту кадров и разгружает CPU.

Прежде чем двигаться дальше, разберем роль GPU подробнее. В Chromium процесс GPU выделен отдельно и отвечает за взаимодействие с графическим оборудованием. Он получает команды рисования (в основном высокого уровня, вроде «нарисуй эти текстуры в таких координатах») от всех потоков-композиторов и интерфейса браузера, а затем переводит их в реальные вызовы API графики. Такое разделение повышает надежность: если драйвер GPU даст сбой, это не «уронит» весь браузер — перезапускается только определенный процесс GPU. Кроме того, изоляция создает дополнительный уровень безопасности, ведь GPU обрабатывает потенциально небезопасный контент, вроде <canvas> или WebGL, где иногда возникают ошибки в драйверах — запуск в отдельном процессе снижает риски.

Результат компоновки в итоге отправляется на экран — в окно или контекст операционной системы, где запущен браузер. Для каждой анимации композитор старается формировать кадры с частотой около 60 FPS (примерно 16,7 мс на кадр) для плавного отображения. Если главный поток занят (например, выполнение JS заняло слишком много времени), композитор может пропустить кадры или не успеть их обновить, что приводит к подтормаживанию анимации. Инструменты разработчика позволяют увидеть такие пропущенные кадры на временной шкале производительности. Методы, вроде requestAnimationFrame(), помогают синхронизировать обновления JS с кадрами анимации, обеспечивая более плавное воспроизведение.

Подводя итог, можно сказать, что движок рендеринга браузера последовательно преобразует содержимое страницы и ее стили в геометрию (разметку) и набор инструкций для рисования. После этого, с помощью слоев и GPU-компоновки, он эффективно превращает все это в пиксели, которые мы видим на экране. Благодаря этой сложной, но отлаженной цепочке процессов современные браузеры способны отображать насыщенную графику и плавные анимации с высокой частотой кадров.

Далее мы заглянем внутрь движка JS, чтобы понять, как браузер выполняет скрипты — ту часть, которая до этого была для нас «черным ящиком».

Внутри движка JavaScript (V8)

JavaScript отвечает за интерактивное поведение веб-страниц. В браузерах на базе Chromium его выполнение обеспечивает движок V8, который также обрабатывает WebAssembly. Понимание принципов работы V8 помогает разработчикам писать более эффективный код.

Полное изучение движка потребовало бы отдельной книги, поэтому здесь мы рассмотрим только основные этапы его работы: парсинг и компиляцию кода, его выполнение и управление памятью (включая сборку мусора). Также мы затронем современные механизмы V8 — многоуровневую JIT-компиляцию и поддержку модулей ES.

Современный конвейер парсинга и компиляции в V8

Фоновая компиляция: начиная с Chrome 66, V8 выполняет компиляцию исходного кода JS в фоновом потоке, что сокращает время, затрачиваемое на компиляцию в основном потоке, примерно на 5–20% на большинстве сайтов. С версии Chrome 41 движок поддерживает фоновый парсинг JS-файлов через API V8 StreamedSource. Это означает, что V8 может начинать парсинг кода сразу после загрузки первых байтов из сети и продолжать его параллельно с получением оставшейся части файла. Практически вся компиляция скриптов выполняется в фоновых потоках — в основном потоке остаются только короткие этапы: внутренняя обработка абстрактного синтаксического дерева (AST) и финализация (finalizing) байт-кода (bytecode) непосредственно перед выполнением скрипта. На данный момент верхнеуровневый код (top-level script) и немедленно вызываемые функции (IIFE) компилируются в фоновых потоках, а вложенные функции по-прежнему компилируются лениво (lazy compilation) — т.е. только при первом выполнении, уже в основном потоке.

Парсинг и байт-код: когда браузер встречает тег <script> (во время парсинга HTML или при последующей загрузке), движок V8 сначала разбирает исходный код JS. Результатом этого этапа является формирование AST — структурированного представления кода.

Предварительный парсер (preparser) — это облегченная версия парсера, выполняющая минимально необходимую работу по «валидации» функций. Он проверяет, что функции синтаксически корректны, и подготавливает данные, необходимые для правильной компиляции внешних функций. Когда предварительно разобранная функция вызывается впервые, она полностью парсится и компилируется по требованию.

Вместо того, чтобы интерпретировать код напрямую из AST, V8 использует интерпретатор байт-кода Ignition (введенный в 2016 году). Ignition компилирует JS в компактный байт-код — последовательность инструкций для виртуальной машины. Такая начальная компиляция выполняется очень быстро, а байт-код довольно низкоуровневый (Ignition — регистровая виртуальная машина). Главная цель — начать выполнение кода как можно быстрее, с минимальными затратами времени на подготовку, что особенно важно для скорости загрузки страниц.

Процесс интернализации AST: интернализация (internalization) AST включает выделение в куче V8 литералов — строк, чисел и шаблонов объектных литералов — для последующего использования сгенерированным байт-кодом. Чтобы реализовать фоновую компиляцию, этот процесс был перенесен на более поздний этап — после компиляции в байт-код. Это потребовало изменений, позволяющих обращаться к «сырым» значениям литералов, встроенным в AST, вместо уже интернализированных объектов в куче.

Явные подсказки компиляции: недавно V8 ввел новую возможность под названием Explicit Compile Hints, которая позволяет разработчикам явно указывать, что код нужно разобрать и скомпилировать сразу при загрузке — с помощью ранней компиляции. Файлы с такой подсказкой компилируются в фоновом потоке, тогда как отложенная компиляция выполняется в основном потоке. Эксперименты с популярными веб-страницами показали улучшение производительности в 17 из 20 случаев, при среднем сокращении времени парсинга и компиляции на 630 мс. Разработчики могут добавлять явные подсказки в специальные комментарии в JS-файлы, чтобы включить раннюю компиляцию критически важных участков кода в фоновом режиме.

Оптимизации сканера и парсера: сканер V8 был значительно улучшен, что дало прирост производительности во всех направлениях: сканирование одного токена ускорилось примерно в 1,4 раза, сканирование строк — в 1,3 раза, сканирование многострочных комментариев — в 2,1 раза, а сканирование идентификаторов — в 1,2–1,5 раза в зависимости от длины идентификатора.

Когда скрипт выполняется, Ignition интерпретирует байт-код и запускает программу. Интерпретация обычно медленнее выполнения оптимизированного машинного кода, но она позволяет движку сразу начать выполнение и одновременно собирать профилирующую информацию о поведении кода. По мере выполнения V8 собирает данные о том, как используется код: типы переменных, какие функции вызываются чаще всего и т.д. Эта информация затем используется для ускорения выполнения кода на последующих этапах.

Уровни JIT-компиляции

V8 не ограничивается интерпретацией. Он использует несколько уровней компиляторов Just-In-Time (JIT - компиляция кода во время выполнения), чтобы ускорять часто выполняемый код. Идея в том, чтобы тратить больше ресурсов на оптимизацию часто выполняемого кода, делая его быстрее, и при этом не тратить время на код, который выполняется редко.

  1. Ignition — интерпретирует байт-код.

  2. Sparkplug: базовый JIT-компилятор V8, запущенный примерно в 2021 году. Sparkplug берет байт-код и быстро компилирует его в машинный код без глубоких оптимизаций. Полученный нативный код работает быстрее интерпретации, но Sparkplug не проводит сложного анализа — он почти так же быстр при запуске, как интерпретатор, но дает код, который работает немного быстрее.

  3. Maglev: в 2023 году V8 представил Maglev — компилятор среднего уровня с оптимизациями. Maglev генерирует код примерно в 20 раз медленнее Sparkplug, но в 10–100 раз быстрее TurboFan, эффективно заполняя промежуток для функций, которые вызываются часто, но недостаточно часто для TurboFan. Maglev применяется к функциям с умеренной активностью — достаточно часто вызываемым, чтобы требовать оптимизации, но не настолько, чтобы задействовать TurboFan, или в случаях, когда компиляция через TurboFan была бы слишком затратной. Начиная с Chrome M117, Maglev способен обрабатывать многие такие случаи, ускоряя запуск веб-приложений для участков кода со средней активностью, заполняя промежуток между базовым JIT и высокоэффективным компилятором.

  4. TurboFan: когда функции или циклы выполняются много раз, V8 включает свой самый мощный оптимизирующий компилятор. TurboFan использует собранную информацию о типах для генерации высоко оптимизированного машинного кода, применяя продвинутые оптимизации (встраивание функций, устранение проверок границ и т.д.). Такой код может работать значительно быстрее, если выполнены предполагаемые условия.

Итак, у V8 теперь фактически четыре уровня исполнения: интерпретатор Ignition, базовый JIT Sparkplug, оптимизирующий JIT Maglev и оптимизирующий JIT TurboFan. Это похоже на многоуровневую JIT-компиляцию в Java HotSpot VM (C1 и C2). Движок динамически решает, какие функции и когда оптимизировать, исходя из профиля выполнения. Если функция внезапно вызывается миллион раз, она, скорее всего, будет оптимизирована TurboFan для максимальной скорости.

Intel также разработала Profile-Guided Tiering, что повышает эффективность V8, обеспечивая примерно 5% прироста в бенчмарках Speedometer 3. В последних версиях V8 реализована оптимизация статических корней, позволяющая на этапе компиляции точно определять адреса памяти для часто используемых объектов, что заметно ускоряет к ним доступ.

Одна из проблем JIT-оптимизации заключается в том, что JS — язык с динамической типизацией. V8 может оптимизировать код, исходя из определенных предположений (например, что переменная всегда хранит целое число). Если позднее эти предположения нарушаются (например, переменная становится строкой), оптимизированный код становится недействительным. Тогда V8 выполняет деоптимизацию: возвращается к менее оптимизированной версии или генерирует код заново с учетом новых предположений. Этот механизм опирается на встроенные кэши (inline caches) и обратную связь по типам, чтобы быстро адаптироваться. Из-за деоптимизации пиковая производительность может снижаться, если типы данных непредсказуемы, однако V8 обычно эффективно работает с типичными случаями, например, когда функции получают объекты одного и того же типа последовательно.

Сброс байт-кода и управление памятью

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

Управление памятью (сборка мусора): V8 автоматически управляет памятью JS-объектов с помощью сборщика мусора. Со временем он эволюционировал в Orinoco GC — современный, поколенческий и инкрементальный сборщик мусора, который выполняется одновременно с основным потоком. Основные моменты:

  • Поколенческая сборка: V8 разделяет объекты по их «возрасту». Новые объекты помещаются в молодое поколение (или nursery), где они часто очищаются с помощью быстрого алгоритма scavenging — «живые» объекты копируются в новое пространство, а остальная память освобождается. Объекты, которые пережили несколько таких циклов, перемещаются в старшее поколение.

  • Mark-and-sweep с уплотнением: для старшего поколения V8 применяет сборщик Mark-and-sweep с уплотнением. Это значит, что периодически выполнение JS останавливается (stop-the-world), собираются все доступные объекты (начиная от корней, например, глобального объекта), а память, занимаемая неиспользуемыми объектами, освобождается. Дополнительно может выполняться уплотнение памяти (перемещение объектов для уменьшения фрагментации памяти). Однако в Orinoco большая часть этапа маркировки (mark) выполняется конкурентно — в фоновых потоках, пока JS продолжает работать, что позволяет минимизировать паузы.

  • Инкрементальная сборка: V8 выполняет сборку мусора по частям, а не одной большой паузой. Такой подход распределяет работу и помогает избежать «подвисаний». Например, часть маркировки может выполняться между выполнениями скриптов, используя периоды простоя (idle time).

  • Параллельная сборка: на многопроцессорных машинах V8 может выполнять части сборки (маркировку или очистку (sweep)) параллельно в нескольких потоках.

В итоге команда V8 за годы работы существенно сократила паузы сборки мусора, сделав ее практически незаметной даже в больших приложениях. Небольшие сборки (для новых объектов, «Young space») обычно проходят очень быстро. Основные сборки (для старого поколения, «Old space») встречаются реже и теперь выполняются преимущественно параллельно с работой скриптов. В диспетчере задач Chrome или в панели «Память» DevTools можно увидеть кучу V8, разделенную на «Young space» и «Old space», что отражает поколенческую организацию памяти.

Для разработчиков это означает, что управление памятью вручную не требуется, но все же стоит учитывать некоторые моменты: например, не создавать слишком много короткоживущих объектов в горячих циклах (хотя V8 эффективно с ними справляется) и помнить, что удержание больших структур данных будет сохранять их в памяти. Инструменты, вроде DevTools, позволяют принудительно запустить сборку мусора или записать профили памяти, чтобы понять, что занимает больше всего места.

V8 и веб-API: важно понимать, что V8 отвечает только за сам язык JS и его среду выполнения (интерпретацию кода, стандартные объекты JS и т.д.). Многие «браузерные API» — например, методы DOM, alert(), сетевые запросы через XHR или fetch — не входят в состав V8. Их предоставляет браузер, и они становятся доступными в JS через специальные привязки (bindings). Например, при вызове document.querySelector() движок через привязку обращается к реализации DOM на C++. V8 обрабатывает этот вызов и возвращает результат, а граница между JS и C++ оптимизирована для скорости работы (в Chrome используется специальный язык описания интерфейсов (IDL) для генерации эффективных привязок).

Мы рассмотрели, как браузер загружает ресурсы, парсит HTML и CSS, вычисляет разметку, рендерит ее с помощью GPU и выполняет JS. Теперь у нас есть целостное представление о процессе загрузки и отображения страницы. Но есть и другие аспекты для изучения: как работают ES-модули (у них свой механизм загрузки), как устроена многопроцессная архитектура браузера и как реализованы функции безопасности, такие как песочница и изоляция сайтов. Обо всем этом мы поговорим в следующей части.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Комментарии (0)