Skip to main content

Що таке критичний шлях рендерингу (crp) у браузері

Critical rendering path (CRP) - це послідовність кроків, які браузер виконує, щоб перетворити HTML, CSS і JavaScript на видимі пікселі на екрані, від першого байта HTML до першого намальованого кадру.

Теорія

TL;DR

  • CRP схожий на конвеєр: HTML будує каркас (DOM), CSS додає стилі (CSSOM), але блокуючий <script> зупиняє весь конвеєр, поки не виконається
  • Головна мета: мінімізувати час до First Contentful Paint (FCP); ціль Chrome - менше 1.8 секунди
  • Кроки по порядку: DOM → CSSOM → Render Tree → Layout → Paint → Composite
  • Блокуючі ресурси (CSS-файли, синхронний JS) затримують кожен наступний крок
  • Правило вибору: FCP більше 1.8s на мобільному? Починай з async/defer для скриптів, потім preload критичного CSS

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

html
<!DOCTYPE html> <html> <head> <!-- Блокує парсинг, поки файл не завантажиться і не обробиться --> <link rel="stylesheet" href="styles.css"> <!-- Блокує парсинг, поки файл не завантажиться і не виконається --> <script src="script.js"></script> </head> <body> <!-- Не намалюється, поки CRP не завершиться --> <h1>Видимий контент</h1> </body> </html>

Браузер зупиняється на <script>, завантажує і виконує script.js, потім будує DOM і CSSOM, об'єднує їх у render tree, рахує layout і лише тоді малює. Кожен блокуючий ресурс додає 200-500ms до FCP. Це видно у вкладці Network в Chrome DevTools.

Як браузер будує CRP

Шість кроків, завжди в одному порядку.

Побудова DOM. Браузер парсить HTML байт за байтом у дерево вузлів. Рушій Blink у Chrome використовує попередній парсер, який спекулятивно завантажує CSS і JS, поки основний парсер обробляє токени. Теги <html>, <head>, <body> перетворюються на вузли Document, Element і Text.

Побудова CSSOM. CSS блокує рендеринг за замовчуванням. Браузер не може нічого показати, не знаючи які стилі застосовуються до яких вузлів. Кожен <link rel="stylesheet"> запускає завантаження файлу, і поки він не завантажиться і не обробиться, малювання стоїть. @import всередині таблиці стилів робить ситуацію гіршою: він запускає послідовне (серіальне) завантаження, тобто файли вантажаться один за одним, а не паралельно.

Render Tree. DOM і CSSOM об'єднуються в render tree, який містить тільки видимі вузли з обчисленими стилями. Елементи з display: none виключаються повністю. Елементи з visibility: hidden залишаються, але рендеряться як порожній простір.

Layout (Reflow). Браузер рахує точний розмір і позицію кожного вузла render tree. Цей крок дорогий, якщо його запускати багато разів. Будь-який JS, який читає властивості layout (наприклад offsetWidth) після зміни DOM, примусово запускає синхронний reflow.

Paint. Браузер перетворює інформацію про layout у реальні пікселі, пошарово. Текст, рамки, тіні, кольори - все растеризується тут.

Composite. Окремі шари (особливо ті, що використовують CSS transform або opacity) збираються потоком композитора і відправляються на GPU. Саме тому transform: translateZ(0) переносить елемент на власний шар і пропускає крок paint під час анімації.

Головна різниця: CRP і повне завантаження сторінки

Повне завантаження включає всі ресурси: зображення нижче згину, lazy-loaded скрипти, сторонні віджети. CRP фокусується тільки на тому, що потрібно для першого видимого кадру. Chrome може намалювати контент above the fold приблизно за 100ms, якщо ніщо не блокує. Очікування повного завантаження може зайняти 2+ секунди. Цей розрив і є метою оптимізації CRP.

Коли оптимізувати CRP

  • FCP більше 1.8s на мобільному: preload критичного CSS через <link rel="preload">, inline якщо менше 10KB
  • JS-важкий SPA: додай async або defer до тегів скриптів
  • Hero-зображення above the fold: preload з fetchpriority="high"
  • CSS з @import: заміни на зконкатеновані файли через MiniCssExtractPlugin у webpack
  • Застарілий сайт: мінімізуй HTML і CSS, прибери блокуючі ресурси

Типові помилки

1. Синхронний <script> у <head>

html
<!-- Блокує парсинг DOM на весь час завантаження + виконання --> <script src="app.js"></script>

Браузер зупиняє парсинг HTML щойно зустрічає цей тег. Бандл у 1MB на повільному 3G може додати цілу секунду до FCP. Використовуй defer для скриптів, яким потрібен DOM, і async для незалежних скриптів, як аналітика.

2. @import у критичній таблиці стилів

css
/* reset.css завантажується послідовно - подвоює час завантаження CSS */ @import url('reset.css');

Браузер не може знайти reset.css, поки не завантажить і не обробить батьківський файл. Lighthouse це позначає. Об'єднуй import-и на етапі збірки.

3. Примусовий синхронний reflow у циклі

javascript
// Погано: зустрічається в реальному коді дашбордів for (let i = 0; i < 1000; i++) { divs[i].style.height = '20px'; // Зміна console.log(divs[i].getBoundingClientRect().height); // Reflow на кожній ітерації } // Результат: 60fps падає до 5fps, FCP зростає на ~2s

У продакшені цей патерн непомітно вбиває продуктивність частіше за будь-яку іншу CRP-проблему, особливо в React portals і дашбордах, що вимірюють розміри overlays. Рішення: спочатку всі зміни, потім всі читання.

4. Великий inline <style> вище згину

html
<style>/* 50KB стилів */</style>

Парсер чекає повного завершення парсингу перш ніж рухатись далі. Витягни тільки критичний CSS (менше 14KB) і завантажуй решту асинхронно.

5. document.write() після початкового завантаження

javascript
document.write('<div>Динамічний контент</div>'); // Після DOMContentLoaded

Це запускає повний перерахунок CRP і на мить гасить екран. Використовуй element.appendChild().

Де зустрічається в реальних проектах

  • Next.js: автоматично генерує <link rel="preload"> для _app.js і критичних чанків, ціль - FCP менше 1 секунди
  • Gatsby: gatsby-plugin-critical розбиває CSS і вбудовує стилі above the fold; використовується на 100k+ сайтах з приростом FCP приблизно на 50%
  • Create React App: шаблон за замовчуванням використовує defer для основного бандлу, але main.css залишається блокуючим; рішення - плагін critters для витягування критичного CSS
  • Webpack: HtmlWebpackPlugin + Critters автоматично вбудовують критичний CSS у SPA-збірках
  • React portals: вимірювання розмірів overlays через getBoundingClientRect() всередині render-циклів - відома CRP-проблема у великих дашбордах

Питання на співбесіді

Q: Яка різниця між CRP і LCP?
A: CRP - це весь процес від першого байта до першого намальованого кадру. LCP (Largest Contentful Paint) - це Core Web Vital, який вимірює скільки часу потрібно, щоб намалювати найбільший видимий блок тексту або зображення. LCP більше 2.5s вважається поганим показником. Оптимізація CRP, як правило, покращує LCP як побічний ефект.

Q: Чим відрізняються async і defer у контексті CRP?
A: async завантажує скрипт паралельно, але виконує його щойно завантаження завершується, що може перервати парсинг HTML. defer також завантажує паралельно, але відкладає виконання до завершення парсингу DOM. Для більшості скриптів додатку правильний вибір - defer. async підходить для скриптів без залежності від DOM, наприклад аналітика.

Q: Як виміряти кроки CRP у Chrome DevTools?
A: Відкрий вкладку Performance, запиши завантаження сторінки, потім фільтруй по "Recalculate Style", "Layout" і "Paint". Кожна з цих подій відповідає одному кроку CRP. Ціль - менше 100ms на кадр. Жовті піки у flame chart зазвичай вказують на примусові reflow з JavaScript.

Q: Як Service Worker впливає на CRP у PWA?
A: Service Worker перехоплює fetch-запити і може відповідати з кешу миттєво, що значно прискорює CRP при повторних відвідуваннях. Стратегія stale-while-revalidate повертає кешований HTML одразу, паралельно завантажуючи оновлену версію. Компроміс: перший render може показати застарілий контент.

Q (рівень senior): У тебе новинний сайт з 50+ hero-зображеннями і персоналізованим CSS. Як оптимізувати CRP?
A: Preload топ-3 зображення з fetchpriority="high", щоб вони конкурували за пропускну здатність першими. Генеруй критичний CSS для кожного сегменту користувачів на сервері з PurgeCSS для видалення невикористаних правил. Lazy-load все нижче згину через loading="lazy". Вимірюй через бібліотеку web-vitals за реальними RUM-даними, не тільки Lighthouse. Такий підхід зменшив FCP на 40% у продакшені на великих новинних сайтах.

Приклади

Блокуючий vs неблокуючий скрипт

html
<!-- Варіант 1: блокує рендеринг --> <head> <script src="analytics.js"></script> </head> <!-- Варіант 2: async для скриптів без залежності від DOM --> <head> <script src="analytics.js" async></script> </head> <!-- Варіант 3: defer для скриптів, яким потрібен DOM --> <head> <script src="app.js" defer></script> </head>

async дозволяє браузеру продовжувати парсинг HTML під час завантаження скрипта, але виконання відбувається одразу після завантаження - це може перервати парсинг. defer гарантує виконання після повної побудови DOM. Для аналітики і сторонніх скриптів без залежності від DOM - async. Для основного бандлу - defer.

Витягування критичного CSS у React-додатку

html
<!-- public/index.html - неоптимізований шаблон Create React App --> <head> <!-- Блокує рендеринг: затримує FCP на ~300ms --> <link rel="stylesheet" href="/static/css/main.abc123.css"> </head> <body> <div id="root"></div> <!-- Правильно: defer не блокує парсинг HTML --> <script src="/static/js/main.abc123.js" defer></script> </body>

Без витягування критичного CSS Lighthouse фіксує FCP близько 2.5s для типового CRA-додатку. Додавши плагін critters у конфіг webpack, ти вбудуєш стилі above the fold прямо в <head> і завантажиш решту асинхронно. FCP падає до менше 1s на більшості з'єднань.

Пастка примусового reflow у циклі

Читання властивостей layout одразу після зміни DOM змушує браузер зупинитись і перерахувати layout синхронно. Обидва варіанти нижче дають однаковий результат у консолі, тому баг непомітний без профайлера.

javascript
// Погано: примусовий reflow на кожній ітерації const divs = document.querySelectorAll('.item'); for (let i = 0; i < divs.length; i++) { divs[i].style.height = '20px'; // Зміна const height = divs[i].getBoundingClientRect().height; // Примусовий reflow console.log(height); } // Добре: спочатку всі зміни, потім всі читання for (let i = 0; i < divs.length; i++) { divs[i].style.height = '20px'; // Всі зміни спочатку } for (let i = 0; i < divs.length; i++) { const height = divs[i].getBoundingClientRect().height; // Всі читання після console.log(height); }

У трасуванні Chrome Performance погана версія показує жовті піки "Layout" після кожної зміни стилю, і frame rate падає з 60fps до 5fps. Правильна версія запускає layout один раз. Цей патерн зустрічається в React portals і дашбордах, що вимірюють розміри overlays під час рендеру.

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

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

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

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