Парсинг пайплайн - від байтів до DOM та CSSOM
Parsing pipeline - це процес, яким браузер перетворює сирі байти HTML та CSS на дерева DOM і CSSOM, з якими можуть працювати JavaScript і рушій рендерингу.
Теорія
TL;DR
- Пайплайн має 4 етапи: Байти → Символи → Токени → Дерево
- HTML і CSS проходять ті самі етапи, але дають різні результати: живий і змінний DOM проти read-only CSSOM
- Парсинг інкрементальний - браузер починає рендеринг до того, як завантажиться весь документ
- Тег
<script>зупиняє HTML-парсер; CSS в<head>блокує рендеринг до готовності CSSOM deferзавантажує паралельно і виконується після парсингу;asyncвиконується одразу як файл завантажився
Швидкий приклад
// Браузер отримує HTML як сирі байти
// 3C 68 31 3E 48 65 6C 6C 6F 3C 2F 68 31 3E
// Етап 1: Байти → Символи (UTF-8 декодування)
// "<h1>Hello</h1>"
// Етап 2: Символи → Токени
// StartTag(h1), Character("Hello"), EndTag(h1)
// Етап 3: Токени → DOM вузол
document.querySelector('h1').textContent; // "Hello"
// Вузол вже існує до того, як завантажиться решта документаПарсинг не чекає на весь файл. Браузер обробляє шматки по ~14KB і одразу будує дерево.
Чотири етапи
Етап 1: Байти в символи. Перш ніж зчитати хоч один символ, браузеру потрібно знати кодування. Він перевіряє по черзі: BOM (Byte Order Mark) у файлі, HTTP заголовок Content-Type, тег <meta charset>, потім авто-визначення. Перший збіг виграє. Тому <meta charset="utf-8"> має бути в перших 1024 байтах. Якщо браузер вже почав парсити з неправильним кодуванням - він перезапускається з початку.
Етап 2: Токенізація (tokenization). Токенізатор - це скінченний автомат із HTML-специфікації. Він читає потік символів і видає токени: StartTag, EndTag, Character, Comment. Цікавий момент: <tr> без <tbody> не викликає помилку. Токенізатор просто видає токени, а будівник дерева виправляє структуру автоматично.
Етап 3: Побудова дерева. Будівник дерева споживає токени і створює DOM-вузли. Він спокійно обробляє некоректний HTML: автоматично закриває відкриті теги, вставляє пропущені елементи на кшталт <tbody>, переміщує вузли відповідно до правил HTML5. DOM живий протягом усього процесу. JavaScript, що виконується під час парсингу, вже бачить вузли, які були створені.
Етап 4: CSS іде паралельним шляхом. Поки HTML-парсинг будує DOM, CSS-файли мають власний токенізатор і будівник дерева. CSS-токенізатор видає токени для селекторів, властивостей і значень. Будівник дерева будує CSSOM. На відміну від HTML, невалідний CSS просто ігнорується, не зупиняючи парсер. Але рендеринг заблокований до готовності і DOM, і CSSOM. Саме тому CSS має бути в <head>.
Preload scanner
Більшість розробників про це не думають. Головний HTML-парсер зупиняється на тезі <script>. Але браузер не просто чекає. Паралельний потік, який називають preload scanner, продовжує читати сирий HTML вперед і ставить у чергу завантаження ресурсів: <link rel="stylesheet">, <script src>, <img src>, <link rel="preload">.
<script src="slow-script.js"></script>
<!-- Парсер заблокований тут -->
<img src="hero.jpg">
<!-- Preload scanner вже почав завантажувати hero.jpg -->Це одна з найвпливовіших оптимізацій браузера. document.write() ламає її повністю, бо може вставити нові символи в потік після того, як сканер вже пройшов далі.
Головна різниця: DOM vs CSSOM
DOM можна змінювати. JavaScript читає і пише його постійно. CSSOM з точки зору JavaScript - read-only. Читати обчислені стилі можна через getComputedStyle(), але писати в CSSOM напряму не можна. Виклик element.style.color = 'red' не торкається CSSOM. Він створює inline-стиль на DOM-вузлі, який перемагає правила CSSOM через найвищу специфічність.
Коли це важливо
- CSS в
<body>замість<head>затримує перший paint, бо CSSOM не готовий коли DOM вже є <script>безasyncабоdeferблокує і парсинг, і рендеринг<meta charset>після перших 1024 байтів може змусити браузер перезапустити парсинг- Великі блоки inline-стилів роздувають DOM і сповільнюють побудову дерева
document.write()вимикає preload scanner і вбиває паралельне завантаження
Типові помилки
Помилка 1: Звернення до DOM до того, як він існує
// НЕПРАВИЛЬНО: скрипт виконується до парсингу <h1>
<script>
console.log(document.querySelector('h1')); // null
</script>
<h1>Hello</h1>
// ПРАВИЛЬНО: чекаємо DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
console.log(document.querySelector('h1')); // "Hello"
});Парсер зупиняється на <script>, виконує його, потім продовжує. Якщо <h1> нижче за тег скрипту, вузол ще не існує під час виконання.
Помилка 2: CSS в body
<!-- НЕПРАВИЛЬНО -->
<body>
<h1>Заголовок</h1>
<link rel="stylesheet" href="styles.css"> <!-- CSSOM не готовий -->
<p>Контент</p>
</body>
<!-- ПРАВИЛЬНО -->
<head>
<link rel="stylesheet" href="styles.css">
</head>Браузер нічого не малює, поки не готові і DOM, і CSSOM. CSS в body означає, що CSSOM може не бути готовим, коли парсер дійшов до видимого контенту вище нього.
Помилка 3: Спроба записати в getComputedStyle()
// НЕПРАВИЛЬНО: CSSOM read-only
const style = getComputedStyle(element);
style.color = 'red'; // нічого не відбудеться
// ПРАВИЛЬНО
element.style.color = 'red'; // inline стиль
element.classList.add('highlight'); // застосувати правило з CSSOMПомилка 4: async для скриптів, що залежать один від одного
<!-- НЕПРАВИЛЬНО: порядок виконання не гарантований -->
<script src="jquery.js" async></script>
<script src="app.js" async></script>
<!-- app.js може виконатися до jquery.js -->
<!-- ПРАВИЛЬНО: defer зберігає порядок -->
<script src="jquery.js" defer></script>
<script src="app.js" defer></script>async запускає скрипт одразу як він завантажився, ігноруючи порядок у документі. defer чекає завершення парсингу, потім виконує скрипти по черзі. Для коду, що використовує DOM, defer - майже завжди правильний вибір.
Помилка 5: Розрахунок на правильне авто-визначення кодування
<!-- НЕПРАВИЛЬНО: мета-тег надто пізно -->
<html>
<head>
<title>Сторінка</title>
<!-- ~800 байт іншого вмісту -->
<meta charset="utf-8"> <!-- браузер міг уже помилитися з кодуванням -->
</head>Декларація charset має бути в перших 1024 байтах. Якщо ні - браузер угадує. Якщо угадав неправильно - символи розсипаються і парсер перезапускається з початку.
Де зустрічається в реальному коді
- React SSR:
renderToString()надсилає HTML-байти з Node.js. Браузер парсить їх за тим самим пайплайном. Hydration відбувається після побудови DOM, томуuseEffectзапускається після paint - Next.js: Стримить HTML через
renderToNodeStream(), що дозволяє браузеру починати парсинг до завершення відповіді. Це інкрементальний рендеринг у дії - Chrome DevTools: Метрика "Parse HTML" у вкладці Performance вимірює токенізацію і побудову дерева. Великі значення зазвичай означають або дуже великий документ, або некоректний HTML, який парсер виправляє
- Webpack/бандлери: Управляють завантаженням скриптів через атрибути
async,deferабоtype="module"на тегах<script> - Googlebot: Парсить HTML і виконує JavaScript для побудови DOM, після чого індексує результат. Рендеринг тільки на клієнті може приховати контент від краулера
Follow-up питання
Q: Чому браузер парсить HTML інкрементально, а не чекає на весь файл?
A: Продуктивність. Якщо чекати завантаження 5MB HTML перед початком парсингу, користувачі бачать порожній екран секундами. Інкрементальний парсинг дає змогу рендерити контент вище згину, поки решта документа ще в дорозі.
Q: Що буде, якщо <meta charset> суперечить HTTP заголовку Content-Type?
A: Перемагає HTTP заголовок. Браузер обробляє charset з Content-Type: text/html; charset=utf-8 до того, як прочитає хоч один байт HTML. Тег <meta> має значення тільки коли заголовок відсутній.
Q: Чому JavaScript не може напряму змінити CSSOM?
A: CSSOM індексований по селектору для швидкого пошуку, а не для мутацій. Прямий запис також непередбачувано ламав би каскад стилів. Натомість змінюй DOM (inline стилі або класи), і браузер перераховує обчислені стилі з урахуванням CSSOM.
Q: Чим defer відрізняється від async з точки зору парсингу?
A: Обидва завантажують скрипт паралельно, не блокуючи парсер. Різниця в моменті виконання. async запускає скрипт одразу як він завантажився, потенційно перериваючи парсинг. defer чекає завершення парсингу і запускає скрипти в порядку їх появи у документі.
Q: (Senior) Є 50MB HTML з тисячами inline-стилів. Як зменшити час парсингу?
A: Для початку - не надсилати 50MB HTML. Розбий сторінку і завантажуй секції лінивим завантаженням. Якщо все ж необхідно: перенеси inline-стилі в CSS-класи, бо вони роздувають і DOM, і запускають перерахунок CSSOM при кожній зміні стилів. Використай Transfer-Encoding: chunked або renderToNodeStream() у React, щоб браузер починав парсинг до завершення відповіді. Потім профілюй у Chrome DevTools - найчастіше справжнє вузьке місце не парсинг, а layout і paint.
Приклади
Базовий приклад: порядок визначення кодування
<!DOCTYPE html>
<!-- Браузер перевіряє кодування в такому порядку: -->
<!-- 1. BOM у файлі (найвищий пріоритет) -->
<!-- 2. HTTP Content-Type: text/html; charset=utf-8 -->
<!-- 3. Цей мета-тег (має бути в перших 1024 байтах) -->
<meta charset="utf-8">
<title>Сторінка</title>
<!-- 4. Авто-визначення браузером (запасний варіант) -->Визначення charset відбувається до токенізації першого символу. Якщо перші три сигнали суперечать один одному - виграє раніший, і парсер може перезапуститися.
Середній рівень: блокування, defer та async
<!-- Блокує парсинг - парсер чекає завантаження і виконання -->
<script src="app.js"></script>
<!-- Завантажує паралельно, виконується ПІСЛЯ парсингу, по порядку -->
<script src="jquery.js" defer></script>
<script src="app.js" defer></script>
<!-- Завантажує паралельно, виконується одразу як готовий, порядок НЕ гарантований -->
<script src="analytics.js" async></script>
<!-- Вимикає preload scanner для всієї сторінки -->
<script>document.write('<script src="bad.js"><\/script>');</script>Для скриптів, що торкаються DOM або залежать один від одного, defer - правильний вибір. async підходить для ізольованих скриптів, як аналітика, яка не залежить від стану сторінки.
Просунутий рівень: FOUC через порядок завантаження CSS
// Сервер надсилає цей HTML (Node.js / React SSR)
const html = `
<!DOCTYPE html>
<html>
<head>
<!-- Якщо styles.css завантажується повільно,
браузер покаже порожній екран поки CSSOM не готовий -->
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h1>Контент</h1>
</body>
</html>`;
// Браузер будує DOM інкрементально.
// Рендеринг чекає на CSSOM.
// Повільний styles.css = порожній екран, не нестилізований контент.
// FOUC виникає коли CSS в <body> або завантажується через JS після побудови DOM.
// Рішення: inline критичний CSS в <head> для контенту вище згину
const criticalCss = `h1 { font-size: 2rem; }`;
const optimized = `
<head>
<style>${criticalCss}</style>
<link rel="stylesheet" href="styles.css" media="print" onload="this.media='all'">
</head>`;FOUC (Flash of Unstyled Content, миготіння нестилізованого контенту) виникає коли DOM готовий, а CSSOM ще ні. Inline критичних стилів прибирає блокуючий мережевий запит для контенту вище згину.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.