Skip to main content

Парсинг пайплайн - від байтів до 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 виконується одразу як файл завантажився

Швидкий приклад

javascript
// Браузер отримує 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">.

html
<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 до того, як він існує

javascript
// НЕПРАВИЛЬНО: скрипт виконується до парсингу <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

html
<!-- НЕПРАВИЛЬНО --> <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()

javascript
// НЕПРАВИЛЬНО: CSSOM read-only const style = getComputedStyle(element); style.color = 'red'; // нічого не відбудеться // ПРАВИЛЬНО element.style.color = 'red'; // inline стиль element.classList.add('highlight'); // застосувати правило з CSSOM

Помилка 4: async для скриптів, що залежать один від одного

html
<!-- НЕПРАВИЛЬНО: порядок виконання не гарантований --> <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
<!-- НЕПРАВИЛЬНО: мета-тег надто пізно --> <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.

Приклади

Базовий приклад: порядок визначення кодування

html
<!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

html
<!-- Блокує парсинг - парсер чекає завантаження і виконання --> <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

javascript
// Сервер надсилає цей 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 критичних стилів прибирає блокуючий мережевий запит для контенту вище згину.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?