Мислення через недійсність: практична модель того, як браузери відображають сторінки
Навчіться міркувати щодо відображення в браузері, ставлячи запитання про те, що скасовується кожною зміною — від проблем із макетуванням та витрат на зображення до процесів композиції, гідратації та стрімінгу.
Джерела: The Web Page Rendering Process In Browsers: A Deep Dive
Майже кожен розробник фронтенду може назвати етапи процесу відображення: HTML перетворюється на DOM, CSS — на CSSOM, а потім йдуть етапи формування макету, нанесення кольорів та об’єднання елементів. Ця послідовність є гарним вступом, але майже безкорисною, коли справжня сторінка працює з перешкодами, адже вона описує лише один прохід даних, тоді як браузери постійно виконують часткові, повторювані проходи. Цей посібник замінює запам’ятований перелік на функціональну модель, засновану на одному запитанні, щоб ви могли пояснити проблеми з формуванням макету, повільне нанесення кольорів, неефективні анімації та затримки під час ініціалізації сторінки, використовуючи ту саму логіку, та підтвердити свої припущення за допомогою DevTools.
Процес відображення є безперервним, а не одноразовою подією
Сторінка ніколи не відображається один раз та не залишається недоторканою. Скрипти змінюють DOM, зміни класів впливають на стилі, зображення та веб-шрифти завантажуються з запізненням, користувачі перетягують курсор та прокручують сторінку, анімації відтворюються на кожному кадрі, а фреймворки перевідображають компоненти після оновлення стану. Кожна з цих подій змушує двигун відображення відповісти на одне й те саме запитання:
Що більше не є дійсним, і скільки роботи потрібно зробити заново?
Запам’ятати це запитання важливіше, ніж знати назви етапів. Саме тому два за зовнішнім виглядом ідентичні додатки можуть працювати зовсім по-різному на одному телефоні: один постійно ініціює дорогі перерахунки, а інший переважно використовує те, що браузер може повторно використати.
Перший крок: браузер створює внутрішні моделі
Коли надходить HTML, парсер перетворює його на DOM. DOM — це не зображення сторінки. Це дерево елементів, атрибутів та текстових вузлів, а також зв’язки батьківства та братерства між ними. Розгляньмо невеликий фрагмент маркапу:
<main>
<h1>Browser Rendering</h1>
<p>Understanding what happens after HTML arrives.</p>
</main>
Парсер перетворює його на дерево, яке двигун може запитувати та оновлювати:
Document
└── main
├── h1
│ └── "Browser Rendering"
└── p
└── "Understanding what happens after HTML arrives."
Шаблони стилів проходять власну обробку. Браузер парсує їх, застосовує принципи каскадування та успадкування, визначає, які правила стосуються яких елементів, та обчислює кінцеве значення кожної властивості для кожного елемента. На цьому етапі ще нічого не намальовано. Двигун накопичує інформацію про стан, і йому потрібна як структура, так і обчислені стилі, перш ніж можна буде визначити геометрію. Схема, орієнтована на залежності, виглядає так:
HTML ──→ DOM ───────────┐
├──→ Style + Layout ──→ Paint ──→ Composite
CSS ──→ Styles ────────┘
Розглядайте цю картинку як карту залежностей, а не опис реалізації. Справжні двигуни містять набагато більше внутрішніх етапів, кешів, структур для відстеження недійсності даних, кілька потоків та специфічні для платформи скорочення. Те, що діаграма правильно показує, — це те, які вихідні дані залежать від яких вхідних. Якщо ви хочете детальніше дізнатися про те, як значення стилів обробляються перед формуванням макету, наша стаття про місце CSS у процесі роботи браузера надає більш глибокий огляд.
Макетування — це обчислення залежностей
За наявності DOM та обчислених стилів браузер обчислює геометрію. Під час макетування необхідно вирішити такі питання:
- Якими є ширина та висота кожної клітинки?
- Де розташована кожна клітинка?
- Де текст обривається всередині контейнера?
Останнє запитання пояснює, чому формування макету є ресурсоемним процесом. Припустімо, абзац стає вищим через збільшення розміру шрифту. Усе, що знаходиться після нього в потоці, може потребувати переміщення вниз, а висота контейнера може змінитися, що вплине на його власних сусідів. Зміна, яка здається локальною, може поширитися на велику частину документа. Саме тому корисно розглядати формування макету як вирішення мережі залежностей, а не як розміщення елементів на полотні.
Механізми намагаються обмежити перерозрахунок лише ураженою піддеревом, але обсяг виконуваних операцій залежить від типу зміни та взаємозв’язків між елементами. Наприклад, контент у контейнері фіксованого розміру зазвичай менше піддається змінам, ніж контент у контейнері автоматичного розміру.
Анулювання має більше значення, ніж весь процес обробки
card.style.width = „500px“. Тепер браузер розуміє, що його попередні дані про геометрію цього елемента, а можливо й елементів навколо нього, можуть бути застарілими. Перед наступним кадром він мусить визначити, які етапи потрібно перевиконати. У спрощеній формі:
Something changes
│
▼
What does it invalidate?
│
├── Style
├── Layout
├── Paint
└── Composite
- Нова ширина змінює геометрію, тому потрібно перевиконати розташування елементів, а після цього зазвичай потрібно перемалювати вплинутий ділянку.
- Новий колір фону змінює вигляд елемента без переміщення його частин, тому потрібно перемалювати елемент, але зазвичай не потрібно перевиконувати розташування.
- Нове значення
transformчасто можна застосувати з значно меншими зусиллями, але лише тоді, коли рішення браузера щодо шарів та навколишні умови дозволяють обробити оновлення на етапі композиції.
Ось чому загальні поради на кшталт „уникайте переробки“ чи „використовуйте лише GPU“ на практиці не спрацьовують. Корисне діагностичне запитання завжди полягає у тому: яку роботу саме ця зміна зробила недійсною? Наша стаття про що коштує браузеру від кожної зміни CSS класифікує поширені властивості за етапами, які вони зазвичай запускають.
Як JavaScript примушує виконувати роботу з відображення раніше
Поширеним способом випадкового створення складної роботи з відображення є поєднання запису та читання параметрів макету. У наведеному нижче циклі встановлюється ширина кожної картки, а відразу ж запитується її фактична ширина:
for (const card of cards) {
card.style.width = "500px";
const width = card.getBoundingClientRect().width;
console.log(width);
}
У самій функції getBoundingClientRect() немає жодних проблем. Це звичайний та корисний API. Проблема полягає у порядку виконання операцій. Присвоєння стилів залишає роботу з форматуванням у плані, а вже наступний рядок запитує значення, яке може бути правильним лише після виконання цієї роботи. Браузер змушений виконувати операції зі стилями та форматуванням синхронно, прямо там, перш ніж повернути число. У циклі виникає явище, відоме як layout thrashing: запис, читання, запис, читання, з примусовим оновленням форматування щоразу під час ітерації.
Якщо переструктурувати код так, щоб усі операції читання виконувалися спочатку, а всі операції запису — пізніше, це усуне примусову синхронізацію:
const widths = cards.map(card =>
card.getBoundingClientRect().width
);
for (const card of cards) {
card.style.width = "500px";
}
Тепер вимірювання здійснюються за основою єдиного, вже дійсного макету, а операції запису залишаються на обробку браузером під час наступного відображення. Зауважте, що ці дві версії не є абсолютно еквівалентними: у другій версії ширини записуються до змін, тому використовуйте це переупорядкування лише тоді, коли це справді необхідно. Якщо операція читання дійсно залежить від попередньої операції запису, зазвичай використовують методи групування записів та запланування операцій читання на наступну кадру, наприклад за допомогою requestAnimationFrame.
Ширший урок полягає не в тому, щоб «ніколи не вимірювати макет». JavaScript та процес відображення використовують одну й ту саму потоковість та одні й ті самі дані, тож ваш код може створювати залежності, які змушують браузер негайно виконувати те, що інакше було б заплановано та виконано ефективно у пакетах. У проблемі змін макету є й інші тонкі фактори, які ми розглядаємо у живих колекціях DOM та проблемі змін макету.
Процес нанесення зображень — це окрема витрата порівняно з макетуванням
Як тільки геометрія визначена, браузер все одно мусить визначити, як виглядає кожна область: фони, рамки, символи тексту, зображення, тіні, градієнти, ефекти обрізки та інші ефекти. Компактний спосіб розділити ці два етапи:
- Макетування визначає, де знаходяться елементи та якими вони є за розміром.
- Процес нанесення визначає, як виглядають ці елементи.
Зміна значення background-color зазвичай не вимагає створення нової геометрії для всього документа, тоді як зміна розміру чи положення може це потребувати. Однак не варто робити висновок, що процес малювання є безкоштовним. Малювання великих ділянок, складних ефектів, таких як великі розмиті тіні, часта зміна контенту та надмірно широке оновлення можуть значно подовжити час малювання, що призведе до затримки відображення кадру.
Растеризація та композиція створюють кінцевий кадр
Процес малювання генерує інструкції для малювання, а не обов’язково кінцеві пікселі. Ці інструкції потім растеризуються у піксельні дані, які зазвичай діляться на блоки та обробляються за допомогою графічного стеку платформи. Після цього через процес композиції отримані поверхні чи шари об’єднуються у кадр, який відображається на екрані.
Саме на цьому етапі transform та opacity здобувають свою популярність. Коли умови сприятливі, анімація цих властивостей дозволяє елементу рухатися, масштабуватися чи зникати поступово, без необхідності браузером перерахуновувати макет документа чи перемальовувати його вміст; він просто знову поєднує наявні шари.
Популярне спрощення „transform виконується на GPU, тому завжди швидко“ приховує справжні витрати, проте:
- Кожен додатковий шар споживає пам’ять, що має значення на пристроях з низькими характеристиками.
- Вміст все одно доводиться растеризувати в певний момент, а процес растеризації може бути трудомістким.
- Те, чи отримає елемент власний шар, залежить від правил браузера та контексту відображення, тому один і той самий CSS може поводитися по-різному в різних ситуаціях.
Це просте правило є простішим за будь-який список властивостей: зміна, яка скасовує менше елементів, змушує движок обчислювати менше даних.
Бюджет ресурсів фрейму є спільним
Дисплей з частотою оновлення 60 Гц оновлюється приблизно кожні 16,67 мілісекунди, адже 1000 / 60 дорівнює приблизно 16,67. При частоті 120 Гц цей інтервал скорочується до приблизно 8,33 мілісекунд.
Цей інтервал — це не частина часу процесора, виділена вашому коду. Протягом нього браузер має виконати виконання скриптів, обчислення стилів, формування макету, нанесення зображень, растеризацію, композицію, обробку вхідних даних, мережеві виклики та свої внутрішні завдання. Тому одне довге завдання на JavaScript може уповільнити відображення, навіть якби сам процес відображення був дуже простим.
Саме тому такі показники, як FCP (First Contentful Paint) та INP (Interaction to Next Paint), існують окремо. Швидке відображення контенту та швидка реакція на вхідні дані — це пов’язані цілі, але вони досягаються з різних причин, тому їх потрібно вимірювати окремо.
Місце створення інтерфейсу впливає на витрати на відображення
Перш ніж браузер щось відобразить, архітектурний вибір вже впливає на обсяг роботи, яка йому доведеться виконати: де створюється початковий інтерфейс?
Відображення з боку клієнта
При CSR сервер надсилає мінімальну структуру HTML та код додатку, а пристрій виконує більшу частину роботи:
Server
│
├── HTML shell
└── JavaScript + data
│
▼
Browser
│
├── Parse JS
├── Execute JS
├── Fetch data
├── Build UI
└── Render
Відображення з боку сервера
При SSR отримання даних, обробка логіки додатку та генерація HTML відбуваються на сервері ще до надсилання відповіді:
Request
│
▼
Server
│
├── Fetch data
├── Execute application logic
└── Generate HTML
│
▼
Browser
│
▼
Parse + Render
SSR дозволяє надсилати значущий маркап до браузера швидше, тоді як CSR переносить обчислення та відображення на апаратне забезпечення користувача. Жоден з підходів загалом не є кращим. Правильний вибір залежить від складності додатку, необхідних даних, можливостей кешування відповідей, типів пристроїв у користувачів та кількості JavaScript-коду, який у будь-якому разі має виконуватися у браузері.
Гідратація: виглядати готовим — не означає бути готовим
Багато сучасних фреймворків поєднують ці два підходи за допомогою гідратації. Сервер надсилає HTML, який можна відразу відобразити, а JavaScript на стороні клієнта запускає додаток та підключає обробники подій, необхідні для взаємодії:
Server HTML
│
▼
Browser renders content
│
▼
JavaScript loads
│
▼
Application initializes
│
▼
Hydration
│
▼
Interactive UI
Наслідки цього легко проігнорувати: сторінка може здаватися готовою задовго до того, як зможе відреагувати на клік. Процес ініціалізації може включати завантаження, парсинг, компіляцію та виконання великої кількості коду JavaScript, ініціалізацію компонентів та налаштування слухачів, причому все це відбувається на основній смузі виконання. Тому великі додатки можуть швидко відображати контент, але одночасно створювати великий обсяг роботи для основної смуги виконання, саме тоді, коли користувачі починають натискати.
Це протиріччя пояснює поточний напрямок стратегій відображення: надсилання меншої кількості коду JavaScript на клієнт, розділення пакетів та активація лише тих частин сторінки, які дійсно потребують інтерактивності, замість того щоб активувати всю структуру одночасно.
Зміни під час стрімингу при початку виконання роботи
У класичному SSR повна відповідь готується перед її надсиланням. У стрімингу HTML надсилається порціями по мірі її готовності:
Server
│
├── HTML shell ──────────→ Browser
├── Content ─────────────→ Browser
├── More content ────────→ Browser
└── Remaining response ──→ Browser
Браузер може парсувати та відображати перші частини контенту, поки сервер ще генерує решту. Стрімінг не робить процес відображення загалом дешевшим. Він змінює момент, коли може розпочатися корисна робота, що може скоротити час від навігації до видимого контенту. Прогресивне заряджання застосовує ту саму ідею на клієнті, поступово ініціалізуючи програму замість того, щоб активувати всю її одразу.
Спостерігайте за процесом замість того, щоб запам’ятовувати його
Найшвидший спосіб сформувати інтуїцію — це безпосередньо спостерігати за браузером. Відкрийте панель Performance у Chrome DevTools, записайте взаємодію та перевірьте, що відбувається під час змін інтерфейсу. Потім встановіть навмисний примусовий макет, вказавши ширину та одразу отримавши інформацію про геометрію:
element.style.width = "600px";
element.getBoundingClientRect();
Знову записайте, але цього разу з оновленням, яке змінює лише параметр трансформації:
element.style.transform = "translateX(100px)";
Мета вправи не полягає у тому, щоб позначити одну властивість як хорошу, а іншу — як погану. Для кожної запису перегляньте запитання у порядку:
- Чи був стиль перерахований заново?
- Чи виконувалося форматування, і чи було воно здійснене за допомогою скрипту?
- Чи перемальовувалася якась область?
- Чи відбувалася растеризація?
- Чи можна було б обробку оновлень повністю передати композитору?
Як тільки ви будете читати записи таким чином, панель Performance перестає бути інструментом для вирішення проблем у крайньому разі та стає способом побачити, як двигун реагує на кожну лінію вашого коду.
Поза межами шестикрокової схеми
Класична послідовність операцій все ще варта знання:
HTML → DOM → CSSOM → Layout → Paint → Composite
Справжня система є більш складною. Движок постійно підтримує синхронність кількох взаємопов’язаних представлень — від дерева елементів та обчислених стилів через геометрію елементів та інструкції до зображення до растерних плиток та скомпонованих шарів. Зміна рідко змушує перебудовувати всі ці елементи одночасно. Система визначає, що все ще є дійсним, позначає те, що стало недійсним, та повторно використовує все можливе.
Ключові висновки
- Замініть питання „Яку оптимізацію мені застосувати?“ на „Що змінилося, що це унеможливило, і чому браузеру довелося повторити цю роботу?“
- Макетування — це проблема залежностей; зміни, які здаються локальними, можуть вплинути на весь документ.
- Послідовні операції читання та запису змушують систему макетувати синхронно; розділіть їх або відкладіть операції читання на наступну кадру.
transform та opacity є дешевими лише тоді, коли шари та умови співпрацюють.