Як працює браузер під час введення запиту та етапів рендерингу
Пайплайн рендерингу браузера - послідовність кроків від введення URL до пікселів на екрані, поділена на дві фази: мережева та рендеринг.
Теорія
TL;DR
- Мережева фаза: DNS lookup, TCP handshake, TLS (тільки HTTPS), HTTP-запит, відповідь сервера
- Фаза рендерингу: парсинг HTML (DOM), парсинг CSS (CSSOM), виконання JS, render tree, layout, paint, composite
- JS без
asyncабоdeferповністю блокує парсинг HTML - Анімація через
transformминає layout і paint - compositor обробляє її на GPU - Читання layout-властивості одразу після запису в стиль примушує синхронний layout - у циклах це вбиває продуктивність
Огляд пайплайну
// Від натискання Enter до пікселів - кожен крок по порядку:
// 1. DNS: example.com → 93.184.216.34
// 2. TCP: SYN → SYN-ACK → ACK
// 3. TLS: сертифікат + узгодження шифру (тільки HTTPS)
// 4. HTTP: GET /index.html HTTP/1.1
// 5. Відповідь: сервер надсилає байти HTML (chunked або повністю)
// 6. DOM: парсер будує дерево вузлів з HTML інкрементально
// 7. CSSOM: парсер будує дерево стилів з CSS (потрібне повністю)
// 8. JS: виконується, може змінювати DOM або CSSOM
// 9. Render: DOM + CSSOM = render tree (тільки видимі вузли)
// 10. Layout: обчислює точну позицію та розмір кожного вузла
// 11. Paint: растеризує пікселі по шарах (від нижнього до верхнього)
// 12. Composite: GPU об'єднує шари → кадр на екраніКожен крок виробляє результат, потрібний наступному. Якщо один зупинився - все після нього чекає.
DNS resolution
Перед тим як відкрити з'єднання, браузеру потрібна IP-адреса. Він перевіряє чотири місця по черзі:
- Власний DNS-кеш браузера (TTL зазвичай короткий)
- Файл
hostsопераційної системи (статичні записи, зручні для локальної розробки) - Кеш резолвера ОС
- Рекурсивний DNS-сервер - провайдерський або публічний, наприклад 8.8.8.8
Рекурсивний сервер, якщо не має кешу, проходить ієрархію DNS: кореневі сервери → TLD-сервер (.com, .io) → авторитетний сервер домену. На прогрітому кеші цей крок займає менше 1мс. На холодному запиті додай 20-120мс залежно від географії.
TCP та TLS handshake
TCP потребує три повідомлення перед тим, як передавати дані:
- SYN: клієнт відкриває з'єднання
- SYN-ACK: сервер підтверджує
- ACK: клієнт підтверджує, з'єднання готове
Для HTTPS одразу після цього йде TLS handshake (рукостискання): сервер надсилає сертифікат, обидві сторони домовляються про шифр і виводять сесійні ключі. HTTP/2 мультиплексує всі запити через одне TCP-з'єднання, прибираючи витрати на handshake для кожного ресурсу. HTTP/3 використовує QUIC і поєднує TCP та TLS в один round trip.
HTTP-запит і відповідь сервера
Браузер надсилає GET-запит з заголовками: Host, User-Agent, Accept-Encoding і cookies для цього домену. Сервер відповідає кодом статусу і тілом. Відповідь 200 з HTML запускає рендеринг. 301 або 302 перезапускає весь процес з новим URL.
Chunked transfer encoding дозволяє браузеру починати парсинг HTML ще до того, як прийшла повна відповідь. Тому сторінки іноді рендеряться поступово, а не з'являються одразу цілком.
Побудова DOM і CSSOM
Парсинг HTML - інкрементальний. Браузер токенізує потік байтів, створює вузли і додає їх до дерева по ходу читання. Повний документ не потрібен.
CSS інакший. CSSOM (об'єктна модель стилів) має бути побудований повністю перед використанням - пізніші правила можуть перекривати ранні, тому браузеру потрібна повна картина. Великий неоптимізований CSS-файл блокує формування render tree. Саме тому розбивка CSS за media query або відкладення некритичних стилів має значення.
JavaScript і блокування рендерингу
Тег <script> без атрибутів зупиняє парсинг HTML повністю. Браузер завантажує скрипт, виконує його і тільки потім продовжує. Це зроблено тому, що JS може викликати document.write() або змінити DOM під час парсингу.
async завантажує скрипт паралельно, але все одно призупиняє парсинг під час виконання. defer завантажує паралельно і виконує тільки після повного парсингу HTML, у порядку документа. Для більшості скриптів defer - правильний вибір.
Я бачив, як команди витрачали по дві години на дебаг проблем з порядком завантаження через async на скриптах, що залежали один від одного. Порядок виконання з async не гарантований.
Render tree
Render tree - це не DOM. Він поєднує кожен видимий DOM-вузол з його обчисленими стилями з CSSOM. <head>, <script> і будь-який елемент з display: none виключаються. Елемент з visibility: hidden залишається в дереві і займає місце - він впливає на layout.
Layout (reflow)
Layout обчислює точний бокс для кожного вузла render tree: позицію, ширину, висоту, відступи відносно viewport. Саме тут реально застосовується CSS-блокова модель.
Layout ресурсоємний. Зміна ширини контейнера може викликати перерахунок для всіх його дочірніх елементів. Зчитування layout-властивості (offsetHeight, getBoundingClientRect) після запису в стиль змушує браузер синхронно виконати layout для повернення актуального значення. Це layout thrashing, і він вбиває частоту кадрів.
Paint і composite
Paint перетворює кожен шар на пікселі: кольори, текст, рамки, тіні. Шари малюються від нижнього до верхнього (алгоритм художника). Кожен шар растеризується незалежно.
Composite відбувається на GPU. Елементи з transform, opacity, will-change або position: fixed часто отримують власний compositor-шар. GPU об'єднує їх і відправляє кадр на екран. Саме тому анімації через transform плавні: вони ніколи не запускають layout або paint, тільки composite.
Типові помилки
Блокуючі скрипти в <head>
<!-- блокує парсинг під час завантаження і виконання -->
<head>
<script src="app.js"></script>
</head>
<!-- завантажує паралельно, виконує після парсингу - без блокування -->
<head>
<script src="app.js" defer></script>
</head>Layout thrashing у циклі
// Погано: читання скидає черговий layout, запис одразу забруднює знову
elements.forEach(el => {
const h = el.offsetHeight; // читання: синхронний layout
el.style.height = (h + 10) + 'px'; // запис: layout знову dirty
});
// Добре: спочатку всі читання, потім всі записи
const heights = elements.map(el => el.offsetHeight); // один layout
elements.forEach((el, i) => {
el.style.height = (heights[i] + 10) + 'px';
});Анімація геометричних властивостей
/* Повний пайплайн на кожен кадр: layout + paint + composite */
.slow-animation {
transition: width 0.3s, left 0.3s;
}
/* Тільки composite - без layout, без paint */
.fast-animation {
transition: transform 0.3s, opacity 0.3s;
}Відсутність defer на сторонніх скриптах
Додавання аналітики чи чат-віджетів без defer може додати сотні мілісекунд до Time to Interactive на повільних з'єднаннях. Ці скрипти майже ніколи не потребують виконання до завершення парсингу HTML.
Де зустрічається в реальних проектах
- Chrome DevTools, вкладка Performance: жовтий - JS, фіолетовий - layout, зелений - paint, тонкі зелені смужки - composite
- Lighthouse, прапорець "Eliminate render-blocking resources" - ловить стилі та скрипти, що затримують First Contentful Paint
- React і Vue мінімізують кількість DOM-мутацій, що запускають layout і paint - вони не обходять жоден з цих кроків, просто зменшують їх кількість
will-change: transformпереміщує елемент на власний compositor-шар до початку анімації, уникаючи накладних витрат на промоцію шару
Питання для поглиблення
Q: Що таке critical rendering path?
A: Послідовність DOM, CSSOM, render tree, layout, paint. Оптимізація - це скорочення кількості ресурсів, що блокують цей шлях, і зменшення обсягу байтів на кожному кроці.
Q: Яка різниця між reflow і repaint?
A: Reflow перераховує геометрію - викликається зміною розмірів, позицій, шрифтів. Repaint перемальовує пікселі без зміни геометрії - колір, фон, видимість. Reflow завжди викликає repaint. Repaint не викликає reflow.
Q: Чому visibility: hidden відрізняється від display: none з точки зору layout?
A: display: none видаляє елемент з render tree повністю - він не займає місця. visibility: hidden залишає його в дереві з повним впливом на layout, тобто елемент і далі займає простір і зміщує сусідів.
Q: Як HTTP/2 впливає на мережеву фазу?
A: HTTP/2 мультиплексує всі запити через одне TCP-з'єднання, прибираючи витрати на handshake для кожного ресурсу. Також стискає заголовки через HPACK і підтримує server push.
Q: Сценарій рівня senior: ти читаєш el.offsetWidth, а потім встановлюєш el.style.width всередині requestAnimationFrame-колбека на кожному кадрі. Що відбувається і чому?
A: Читання offsetWidth змушує браузер синхронно виконати layout для повернення актуального значення. Після запису в style.width layout позначається dirty знову. На наступному кадрі - ще один примусовий layout. Це layout thrashing всередині rAF. Він відбувається навіть там, якщо читання і запис чергуються, і є однією з найчастіших причин dropped frames.
Приклади
Трейсинг завантаження сторінки в Chrome DevTools
Відкрий DevTools, перейди на вкладку Network, клікни на HTML-запит і відкрий панель Timing:
- "DNS Lookup": час на DNS-резолвінг
- "Initial Connection": TCP handshake
- "SSL": TLS-переговори (тільки HTTPS)
- "Time to First Byte": час обробки на сервері плюс початок відповіді
- "Content Download": отримання байтів HTML
На вкладці Performance запиши перезавантаження. Flame chart на Main thread відображає кожен етап рендерингу: фіолетовий - layout, зелений - paint, жовтий - виконання JS. Composite - тонкі зелені смужки на Compositor thread.
Стратегії завантаження скриптів
<!-- За замовчуванням: парсер блокується під час завантаження і виконання -->
<script src="heavy-lib.js"></script>
<!-- async: завантажує паралельно, виконує одразу як готовий -->
<!-- порядок не гарантований, тільки для незалежних скриптів -->
<script src="analytics.js" async></script>
<!-- defer: завантажує паралельно, виконує після парсингу HTML, по порядку -->
<!-- підходить для основного коду, всього що читає або змінює DOM -->
<script src="app.js" defer></script>
<script src="components.js" defer></script>
<!-- components.js завжди виконується після app.js -->Правило: defer для всього, що торкається DOM. async тільки для дійсно незалежних скриптів, де порядок виконання не має значення - трекери помилок, пікселі аналітики.
Compositor layers та продуктивність анімацій
const box = document.querySelector('.box');
// Повільно: запускає layout + paint + composite на кожному кадрі
function animateSlow() {
let x = 0;
function tick() {
x += 2;
box.style.left = x + 'px'; // layout dirty → reflow → repaint
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
// Швидко: працює на compositor thread, без layout і paint
function animateFast() {
let x = 0;
function tick() {
x += 2;
box.style.transform = `translateX(${x}px)`; // тільки composite
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}Додай will-change: transform в CSS елемента, щоб перемістити його на власний compositor-шар до початку анімації. Це уникне накладних витрат на промоцію шару на першому кадрі.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.