Архітектура браузера й рендеринг
Зміст
Коли автотест падає з формулюванням «елемент не знайдено», хоча ти на власні очі бачив його на скріншоті, справжня причина майже завжди прихована в тому, як браузер перетворює текстовий HTML на пікселі. Браузер — це не одна програма, а набір підсистем, кожна зі своїм темпом роботи. Елемент може вже існувати в структурі документа, але ще не мати геометрії; може мати геометрію, але бути перекритим іншим елементом; може бути видимим, але недосяжним для кліку. Щоб не боротися з флаком (flakiness) навмання, AQA-інженеру потрібна модель того, що відбувається всередині. Цей розділ будує таку модель — від компонентів браузера до окремих піксельних шарів на екрані.
Основні компоненти браузера
Класична модель браузера виділяє кілька підсистем, які спілкуються між собою. Розуміти їх варто тому, що різні класи багів і різні прийоми тестування живуть у різних підсистемах: мережеві затримки — в одній, «стрибки» верстки — в іншій, зависання JavaScript — у третій.
| Компонент | Роль |
|---|---|
| Інтерфейс користувача (user interface) | Адресний рядок, кнопки навігації, вкладки, закладки — усе, крім самої сторінки. |
| Рушій браузера (browser engine) | Диспетчер: передає команди між UI та рушієм рендерингу. |
| Рушій рендерингу (rendering engine) | Перетворює HTML і CSS на видиме зображення сторінки. |
| Мережевий шар (networking) | HTTP-запити, кеш, з’єднання, TLS. Саме сюди «підключається» перехоплення мережі в тестах. |
| JS-рушій (JavaScript engine) | Компілює та виконує JavaScript. |
| Бекенд UI (UI backend) | Малює базові примітиви — поля вводу, вікна — засобами операційної системи. |
| Сховище даних (data persistence) | Cookies, localStorage, sessionStorage, IndexedDB, кеш. Ключове місце для ізоляції стану між тестами. |
Для тестувальника тут два практичні висновки. По-перше, мережевий шар і сховище даних — це те, чим ми маніпулюємо ззовні: підміняємо відповіді сервера, чистимо cookies між сценаріями. По-друге, рушій рендерингу і JS-рушій працюють у тісній зв’язці, але це два різні механізми, і плутати їх — типова помилка.
Рушій рендерингу проти JS-рушія
Це найважливіше архітектурне розмежування розділу. Рушій рендерингу відповідає за структуру й вигляд: парсить HTML і CSS, будує внутрішні дерева, рахує геометрію, малює. JS-рушій відповідає за поведінку: виконує код, який ці дерева змінює.
| Браузер | Рушій рендерингу | JS-рушій |
|---|---|---|
| Chrome, Edge, Opera | Blink | V8 |
| Firefox | Gecko | SpiderMonkey |
| Safari | WebKit | JavaScriptCore (Nitro) |
Blink — рушій рендерингу Chromium, виокремлений із WebKit у 2013 році. V8 — окремий JS-рушій, який компілює JavaScript у машинний код; той самий V8 працює всередині Node.js, тому серверний і браузерний JavaScript виконуються однаковим двигуном.
Чому це важливо на практиці. DOM (об’єктна модель документа) фізично живе на боці рушія рендерингу, а не JS-рушія. Коли твій код викликає document.querySelector, V8 звертається до DOM через прошарок прив’язок (bindings). Кожне таке звернення — перехід межі між двома підсистемами, і саме тому масові операції з DOM у циклі коштують дорого. Друга причина: V8 виконується в тому самому головному потоці (main thread), що й побудова дерев та розрахунок геометрії. Довгий синхронний JavaScript блокує рендеринг — сторінка «замерзає». Для тестів це означає, що кнопка може бути намальована, але клік по ній нічого не дасть, бо обробник (event handler) ще не навісився, поки головний потік зайнятий іншим скриптом.
Нюанс. Конкретна архітектура потоків (кількість raster-потоків, розподіл між процесами) залежить від версії Chromium і платформи — тут наведено спрощену узагальнену модель, якої достатньо для співбесіди й діагностики.
Від HTML до пікселів: конвеєр рендерингу
Шлях від отриманого з мережі HTML до кольорових пікселів на екрані проходить фіксовану послідовність етапів. Її корисно тримати в голові як конвеєр:
HTML → parsing → DOM ┐
├→ Render tree → Layout → Paint → Composite → пікселі
CSS → parsing → CSSOM┘
Розберемо кожен етап окремо, бо кожен породжує свій тип проблем у тестах.
Парсинг і DOM
Отримавши HTML, браузер запускає парсер (parser): розбиває потік символів на токени, а з токенів будує дерево DOM (Document Object Model) — ієрархію вузлів, що відповідає вкладеності тегів. Парсинг інкрементальний: браузер не чекає на весь файл, а будує DOM у міру надходження байтів.
Ключовий факт для тестування: наявність вузла в DOM — це найраніша, найслабша гарантія. Вузол уже є в дереві, але це ще нічого не каже про його видимість чи геометрію. Локатор, що перевіряє лише присутність у DOM (toBeAttached), спрацює набагато раніше, ніж елемент реально стане клікабельним.
CSSOM
Паралельно браузер парсить CSS і будує CSSOM (CSS Object Model) — дерево правил стилів із урахуванням каскаду й успадкування. На відміну від DOM, CSSOM не можна будувати «частинами й показувати»: браузер повинен зібрати всі стилі, перш ніж рахувати підсумковий вигляд елемента, інакше сторінка блимне неправильними стилями. Саме тому CSS — ресурс, що блокує рендеринг (про це нижче).
Render tree (дерево рендерингу)
Далі відбувається обчислення стилів (style recalculation): браузер зіставляє правила CSSOM із кожним вузлом DOM і рахує підсумкові (computed) значення. Результат зливає DOM і CSSOM у render tree — дерево вузлів, які реально будуть намальовані, з уже прикріпленими обчисленими стилями. У Blink його називають layout tree, а окремий крок обчислення стилів видно в панелі Performance як «Recalculate Style».
Важлива деталь, від якої залежить половина «зниклих» елементів у тестах: до render tree потрапляють лише видимі вузли. Елемент з display: none у дереві рендерингу відсутній — він є в DOM, але не має геометрії й не малюється. А от елемент з visibility: hidden у render tree присутній: він займає місце, просто прозорий. Ця різниця напряму пояснює поведінку isVisible() у тестах: обидва варіанти повертають «не видно», але з різних причин.
Layout (reflow)
На етапі layout браузер обчислює геометрію: для кожного вузла render tree визначає точні координати й розміри в пікселях. Цей самий етап часто називають reflow — термін закріпився насамперед у Gecko (Firefox), тоді як Blink і WebKit у документації вживають слово layout; це синоніми. До цього моменту в дереві були лише стилі на кшталт «ширина 50%», а після layout — конкретне «ширина 640px, лівий край у точці 120px». Браузер намагається перераховувати не все дерево, а лише «забруднені» (dirty) піддерева, проте зміна поблизу кореня каскадом посуває решту.
Саме тут виникає геометрія, за якою «полюють» тести. Поки елемент не отримав геометрії, getBoundingClientRect() дає нульовий прямокутник, а offsetHeight — 0, і фреймворк вважає елемент невидимим.
Paint
На етапі paint браузер визначає, чим заповнювати пікселі: текст, кольори, фони, рамки, тіні, зображення. Точніше, він формує список команд малювання (display list) для кожного шару. Це відповідь на питання «яким кольором що фарбувати», тоді як layout відповідав на питання «де і якого розміру».
Composite
Сторінка малюється не одним суцільним полотном, а кількома шарами (layers). Спершу команди малювання перетворюються на бітмапи — це растеризація (rasterization), яку часто виконують окремі raster-потоки й нерідко з прискоренням графічного процесора (GPU). Потім готові шари склеюються в підсумкове зображення — це власне composite. Композитинг дає змогу дешево анімувати певні властивості, не перезапускаючи layout і paint. До композитних шарів повернемося окремо.
Критичний шлях рендерингу
Уся описана послідовність від отримання HTML до першого показу пікселів називається критичним шляхом рендерингу (critical rendering path, CRP). Оптимізувати CRP — означає якнайшвидше довести браузер до першого змістовного кадру. Для розробника це швидкість завантаження; для AQA — розуміння, чому «сторінка відкрилась» і «сторінкою можна користуватись» — це два різні моменти в часі, розділені іноді секундами.
Критичний шлях має два природні гальма: браузеру потрібен готовий DOM і готовий CSSOM, перш ніж він зможе побудувати render tree й показати першу картинку. Тому ресурси, які затримують DOM або CSSOM, безпосередньо відсувають перший показ. Це підводить нас до блокуючих ресурсів.
Блокуючі ресурси: CSS і скрипти
CSS блокує рендеринг
CSS не заважає будувати DOM — парсер спокійно читає HTML далі. Але CSS блокує рендеринг: браузер не намалює контент, поки не збере CSSOM, щоб не показати сторінку без стилів (ефект «спалаху нестилізованого вмісту», FOUC — flash of unstyled content). Отже, великий або повільний CSS відкладає перший кадр.
Є й непряма пастка: CSS блокує виконання скриптів, які йдуть після нього. Причина логічна — скрипт може запитати обчислений стиль елемента (getComputedStyle), тож браузер зобов’язаний спершу дорахувати CSSOM. Через це повільний CSS може загальмувати навіть JavaScript, який від нього формально не залежить.
Скрипт блокує парсинг
Звичайний <script> без додаткових атрибутів — парсер-блокуючий (parser-blocking). Дійшовши до нього, браузер зупиняє побудову DOM, завантажує файл скрипта, виконує його й лише потім продовжує парсинг. Причина знову логічна: скрипт може викликати document.write і змінити сам потік HTML, тому парсер не має права бігти вперед.
<!-- Парсер зупиниться тут, доки analytics.js не завантажиться й не виконається -->
<script src="/analytics.js"></script>
На практиці браузери пом’якшують це «спекулятивним» попереднім скануванням (preload scanner): поки головний парсер стоїть, окремий сканер забігає вперед по HTML і починає завантажувати знайдені ресурси. Але сам DOM далі за блокуючий скрипт усе одно не будується, поки той не відпрацює.
async проти defer
Щоб не блокувати парсинг зовнішніми скриптами, є два атрибути. Обидва працюють лише для зовнішніх скриптів із src.
<script src="app.js"></script> <!-- блокує парсинг -->
<script src="analytics.js" async></script> <!-- вантажиться паралельно, виконається щойно готовий -->
<script src="framework.js" defer></script> <!-- вантажиться паралельно, виконається після парсингу -->
| Атрибут | Завантаження | Коли виконується | Порядок кількох скриптів | Блокує парсинг |
|---|---|---|---|---|
| (немає) | Під час парсингу, із зупинкою | Одразу після завантаження | За порядком у документі | Так |
async | Паралельно з парсингом | Щойно файл готовий (може перервати парсинг) | Не гарантований — хто перший готовий | Лише на час виконання |
defer | Паралельно з парсингом | Після завершення парсингу, перед DOMContentLoaded | За порядком у документі | Ні |
Практичне правило: async пасує незалежним скриптам, яким байдужий порядок і готовність DOM (аналітика, лічильники); defer — скриптам, що працюють з DOM і залежать від порядку (більшість застосункового коду). Для тестувальника важливий наслідок: скрипт із async може виконатися в непередбачуваний момент, тому поведінка сторінки, зав’язана на такий скрипт, за визначенням гоночна — і це законне джерело флаку, яке варто ловити, а не «залатувати» затримкою.
Про моменти готовності. Подія DOMContentLoaded спрацьовує, коли HTML повністю розібрано й defer-скрипти виконано; вона не чекає на завантаження зображень, підфреймів та async-скриптів. Подія load настає пізніше — коли догрузилися всі ресурси, включно з картинками. Прив’язувати очікування (wait) в тестах до load часто занадто грубо: сторінка буває інтерактивною задовго до load, а буває, що й після load застосунок ще домальовує вміст через JavaScript.
Reflow, repaint і продуктивність
Конвеєр рендерингу проходиться не лише під час першого завантаження, а щоразу, коли щось змінюється. Тут з’являються два ключові терміни.
Reflow (перерахунок геометрії) запускається, коли зміна впливає на розміри чи положення: додали елемент, змінили ширину, шрифт, розмір вікна. Reflow дорогий, бо каскадний — зсув одного елемента може посунути сотні інших, а нерідко тягне за собою і повторний paint.
Repaint (перемальовування) відбувається, коли змінюється лише зовнішній вигляд без геометрії: колір, фон, visibility. Repaint дешевший за reflow, бо геометрію рахувати не треба.
Класична проблема продуктивності — примусовий синхронний layout (forced synchronous layout), він же layout thrashing. Виникає, коли код у циклі чергує запис у DOM і читання геометрії. Кожне читання геометрії змушує браузер негайно дорахувати layout, який щойно «забруднив» попередній запис:
// Погано: кожна ітерація примусово запускає reflow
for (const el of boxes) {
el.style.width = el.offsetWidth + 10 + 'px'; // читання геометрії одразу після запису → негайний layout
}
// Краще: спочатку всі читання, потім усі записи
const widths = boxes.map(el => el.offsetWidth); // читаємо
boxes.forEach((el, i) => { // пишемо
el.style.width = widths[i] + 10 + 'px';
});
Для AQA це не абстракція. По-перше, повільний reflow — це реальний дефект продуктивності, який можна зафіксувати. По-друге, важкий синхронний перерахунок займає головний потік, а поки він зайнятий, елементи намальовані, але не реагують на кліки — і тест «мигає». По-третє, деякі властивості можна анімувати в обхід reflow і paint: зміни transform і opacity браузер здатен обробити на етапі composite силами GPU, не чіпаючи layout. Тому плавні анімації роблять саме через transform, а не через зміну top/left (останні щоразу запускають reflow). Анімований елемент — окрема причина флаку: фреймворк може вимагати, щоб елемент був «стабільним» (не рухався) перед кліком.
Композитні шари і GPU
Щоб не перемальовувати всю сторінку через дрібну зміну, браузер може винести окремі елементи на власні композитні шари (compositor layers). Кожен шар растеризується окремо, а підсумкове зображення збирається з шарів на GPU. Якщо анімувати transform/opacity елемента, що лежить на власному шарі, браузер лише зсуває чи змішує готові шари, не запускаючи ні layout, ні paint у головному потоці — звідси плавність.
Просунути елемент на окремий шар можна підказкою:
.overlay {
will-change: transform; /* натяк браузеру підготувати окремий шар */
}
Плата за це — пам’ять: кожен шар зберігається в GPU, і сотні шарів здатні з’їсти ресурси й зашкодити продуктивності. Тож will-change застосовують точково.
Нюанс. Критерії, за якими браузер виносить елемент на окремий шар (3D-трансформації,
video,canvas, деякіposition: fixed), — внутрішні евристики: вони відрізняються між браузерами і змінюються з версіями, тож не покладайся на конкретний перелік як на стабільний контракт.
Viewport
Viewport (область перегляду) — це видима частина сторінки, «вікно», крізь яке користувач дивиться на документ. Її розмір задає, який контент видно без прокрутки, які спрацьовують адаптивні брейкпоінти CSS і чи взагалі елемент потрапляє в кадр.
На мобільних пристроях розрізняють layout viewport (за яким рахується верстка) і visual viewport (те, що реально видно з урахуванням масштабу). Керує цим метатег:
<meta name="viewport" content="width=device-width, initial-scale=1">
Ще одна тонкість — співвідношення CSS-пікселів і фізичних пікселів екрана (devicePixelRatio): на «ретина»-дисплеях один CSS-піксель відповідає кільком фізичним, що впливає на скріншотні порівняння.
Для тестів viewport — це параметр запуску, який змінює результат. Однакові локатори на «десктопному» й «мобільному» viewport можуть вести до різних елементів, бо адаптивна верстка ховає одні блоки й показує інші. Фреймворки на кшталт Playwright перед кліком автоматично прокручують елемент у видиму область, але якщо елемент за межами viewport перекритий «липкою» шапкою чи банером cookie, клік прилетить не туди. Тому фіксований, свідомо обраний розмір viewport — базова умова відтворюваності тесту.
«Елемент є, але його не видно / на нього не клікнути»
Присутність елемента в DOM і його доступність для взаємодії — різні речі, розділені кількома незалежними умовами.
| Стан | У render tree | Займає місце | Видно | Клікабельний |
|---|---|---|---|---|
display: none | Ні | Ні | Ні | Ні |
visibility: hidden | Так | Так | Ні | Ні |
opacity: 0 | Так | Так | Ні (прозорий) | Так |
pointer-events: none | Так | Так | Так | Ні |
| Розмір 0×0 | Так | Ні (фактично) | Ні | Ні |
| Перекритий іншим елементом | Так | Так | Частково/ні | Ні (клік ловить верхній) |
disabled (для полів/кнопок) | Так | Так | Так | Ні |
Звідси зрозуміло, чому серйозні фреймворки не обмежуються перевіркою «елемент у DOM». Playwright перед дією проганяє елемент через набір перевірок доступності (actionability): він має бути видимим, стабільним (не в процесі анімації), приймати події (hit-test: саме він, а не накладка, під курсором), увімкненим, а для введення тексту — ще й редагованим. Кожна з цих умов відповідає конкретному місцю в конвеєрі: видимість — це render tree, стабільність — це відсутність поточного reflow/анімації, hit-test — це геометрія шарів і z-порядок.
Тут ховається важлива невідповідність: «видимість» очима людини й «видимість» очима фреймворка — не одне й те саме. Playwright, наприклад, вважає елемент видимим, якщо той має ненульовий прямокутник і не має visibility: hidden, але opacity не враховує. Тому напівпрозорий або зовсім прозорий (opacity: 0) елемент фреймворк вважатиме видимим і клікабельним — окрема пастка, коли тест «клікає в порожнечу», а очі бачать порожнє місце.
Найкаверзніший рядок у таблиці — «перекритий іншим елементом». Елемент повністю видимий і клікабельний сам по собі, але поверх нього лежить прозорий оверлей, спінер завантаження чи банер згоди на cookies. Клік дістанеться верхньому елементу. У DOM усе гаразд, локатор знаходить ціль, а дія «не працює» — класичний флак, який діагностується лише розумінням композиції шарів.
«Показано, але сторінка ще не готова»: корінь флаку
Флак найчастіше народжується з розриву між двома моментами: браузер уже щось намалював (пройшов перший paint) — і застосунок уже готовий до взаємодії. Між ними встигає статися багато:
- HTML розібрано, але
defer-скрипти ще не навісили обробники подій — кнопка намальована, але «мертва»; - дані вже підвантажилися запитом, але JavaScript ще не перемалював список — старий вміст видно, новий іще ні;
- елемент з’явився, але триває анімація появи — його геометрія щомиті змінюється;
- поверх контенту ще висить спінер або модалка, які от-от зникнуть.
Спокуса «полагодити» це фіксованою паузою (sleep) — головна антипрактика. Стала затримка або занадто коротка (тест усе одно мигає на повільному оточенні), або занадто довга (набір тестів повзе). Правильна відповідь випливає з конвеєра: чекати треба на конкретний стан, який відповідає етапу, що тебе цікавить.
// Погано: сподіваємось, що 3 секунд вистачить
await page.waitForTimeout(3000);
await page.click('#submit');
// Добре: чекаємо на реальний стан, а не на годинник
await page.getByRole('button', { name: 'Submit' }).click();
// клік сам дочекається видимості, стабільності, увімкненості й доступності для подій
Мережевий бік цієї ж проблеми — коли готовність сторінки залежить від відповіді сервера. Тут архітектура дає ще один важіль: перехоплення мережі (network interception). Замість того щоб ловити випадковий момент приходу даних, тест підмінює відповідь детермінованою заглушкою — і момент «дані готові» стає передбачуваним:
await page.route('**/api/users', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Test User' }]),
})
);
Останній важіль — ізоляція стану. Cookies і сховище (localStorage, IndexedDB) живуть у підсистемі персистентності й переживають перезавантаження сторінки. Якщо не чистити їх між сценаріями, попередній тест лишає застряглий стан — авторизацію, закритий банер, кеш, — і наступний тест поводиться інакше залежно від порядку запуску. Тому кожен незалежний сценарій розумно ганяти в чистому контексті, де сховище й cookies порожні від старту.
Більшість «нестабільних» тестів насправді стабільні — вони просто перевіряють стан на стадії конвеєра, яка ще не настала. Хто тримає в голові шлях від HTML до пікселів, той ставить очікування на правильну стадію й перетворює флак на детермінований результат.
З яких основних компонентів складається браузер?
Браузер — це не одна програма, а набір підсистем: інтерфейс користувача (адресний рядок, вкладки), рушій браузера (диспетчер між UI та рендерингом), рушій рендерингу, мережевий шар, JS-рушій, бекенд UI (малює примітиви засобами ОС) і сховище даних (cookies, localStorage, sessionStorage, IndexedDB, кеш). Розділення важливе, бо різні класи багів живуть у різних підсистемах: мережеві затримки — в одній, стрибки верстки — в іншій, зависання JavaScript — у третій. Для AQA дві підсистеми — робочі важелі: мережевим шаром ми маніпулюємо через перехоплення запитів і підміну відповідей, а сховищем даних — через чищення cookies і storage для ізоляції стану між тестами.
Чим рушій рендерингу відрізняється від JS-рушія? Наведи приклади для популярних браузерів.
Рушій рендерингу відповідає за структуру й вигляд: парсить HTML і CSS, будує дерева, рахує геометрію, малює; JS-рушій відповідає за поведінку — виконує код, який ці дерева змінює. У Chrome, Edge та Opera це Blink і V8, у Firefox — Gecko і SpiderMonkey, у Safari — WebKit і JavaScriptCore. Blink виокремили з WebKit у 2013 році, а V8 — той самий рушій, що працює всередині Node.js, тому серверний і браузерний JavaScript виконує один двигун. Ключовий практичний момент: DOM фізично живе на боці рушія рендерингу, і кожен виклик на кшталт document.querySelector — це перехід межі між підсистемами через прошарок прив'язок (bindings), тому масові операції з DOM у циклі коштують дорого.
Опиши конвеєр рендерингу: що відбувається між отриманням HTML і пікселями на екрані?
HTML парситься в DOM, паралельно CSS парситься в CSSOM; їх злиття дає render tree — дерево лише видимих вузлів з обчисленими стилями. Далі layout визначає точні координати й розміри кожного вузла в пікселях, paint формує список команд малювання (чим заповнювати пікселі: текст, кольори, тіні), а composite растеризує окремі шари й склеює їх у підсумкове зображення, часто силами GPU. Послідовність фіксована: DOM → render tree → layout → paint → composite. Для тестувальника цінність цієї моделі в тому, що кожен етап породжує свій тип проблем: елемент може бути в DOM, але без геометрії; мати геометрію, але бути перекритим іншим шаром — і очікування в тесті треба ставити на ту стадію, яка реально потрібна дії.
Чому «елемент є в DOM» — найслабша гарантія для автотесту?
Парсинг HTML інкрементальний: браузер будує DOM у міру надходження байтів, не чекаючи всього файлу, тому вузол з'являється в дереві дуже рано. Але присутність у DOM нічого не каже ані про видимість, ані про геометрію: елемент може бути з display: none, мати нульовий розмір або ще не пройти layout. Перевірка типу toBeAttached у Playwright спрацює набагато раніше, ніж елемент стане клікабельним. Тому асерти на присутність у DOM годяться хіба для перевірки самої структури, а для взаємодії потрібні сильніші гарантії — видимість, стабільність, доступність для подій.
Чим display: none відрізняється від visibility: hidden?
Елемент з display: none відсутній у render tree: він є в DOM, але не має геометрії, не займає місця й не малюється. Елемент з visibility: hidden у render tree присутній: він займає своє місце у верстці, просто прозорий. Різниця напряму видна в тестах: isVisible() поверне «не видно» в обох випадках, але з різних причин — у першому немає самого вузла рендерингу, у другому є геометрія без відображення. Це також пояснює, чому перемикання display запускає reflow (геометрія сусідів змінюється), а перемикання visibility — лише repaint.
Що таке render tree і чим воно відрізняється від DOM?
Render tree — результат злиття DOM і CSSOM: браузер зіставляє правила стилів із кожним вузлом, рахує підсумкові (computed) значення й будує дерево вузлів, які реально будуть намальовані. Головна відмінність від DOM — до render tree потрапляють лише видимі вузли: display: none викидає елемент із дерева рендерингу, хоча в DOM він залишається. У Blink це дерево називають layout tree, а сам крок обчислення стилів видно в панелі Performance як «Recalculate Style». Половина «зниклих» елементів у тестах пояснюється саме цим розривом: локатор бачить вузол у DOM, а рендеринг про нього «не знає».
Що таке reflow і repaint? Що з них дорожче й чому?
Reflow — перерахунок геометрії: запускається, коли зміна впливає на розміри чи положення (додали елемент, змінили ширину, шрифт, розмір вікна). Repaint — перемальовування без зміни геометрії: колір, фон, visibility. Reflow дорожчий, бо каскадний — зсув одного елемента може посунути сотні інших, і зазвичай він тягне за собою ще й repaint; браузер намагається перераховувати лише «забруднені» (dirty) піддерева, але зміна поблизу кореня посуває решту каскадом. Терміни reflow і layout — синоніми: перший закріпився в Gecko, другий вживають Blink і WebKit. Для AQA повільний reflow — це вимірюваний дефект продуктивності, а ще він займає головний потік: елементи намальовані, але не реагують на кліки, і тест мигає.
Чому кажуть, що CSS блокує рендеринг, а скрипт — парсинг?
CSS не заважає будувати DOM, але браузер не намалює контент, поки не збере повний CSSOM — інакше сторінка блимне нестилізованим вмістом (FOUC, flash of unstyled content); CSSOM не можна будувати й показувати частинами через каскад. Звичайний <script> без атрибутів — парсер-блокуючий: дійшовши до нього, браузер зупиняє побудову DOM, завантажує і виконує скрипт, бо той може викликати document.write і змінити сам потік HTML. Є й непряма пастка: CSS блокує виконання скриптів, що йдуть після нього, бо скрипт може запитати getComputedStyle, а для цього CSSOM має бути готовий. Браузери пом'якшують блокування preload-сканером, який забігає вперед і починає завантажувати знайдені ресурси, але DOM за блокуючим скриптом усе одно не будується, поки той не відпрацює.
Що таке критичний шлях рендерингу (critical rendering path)?
Це послідовність від отримання HTML до першого показу пікселів: браузеру потрібні готовий DOM і готовий CSSOM, перш ніж він побудує render tree й покаже перший кадр. Ресурси, що затримують DOM або CSSOM (повільний CSS, блокуючі скрипти), безпосередньо відсувають перший показ — тому оптимізація CRP означає якнайшвидше довести браузер до першого змістовного кадру. Для AQA головний висновок: «сторінка відкрилась» і «сторінкою можна користуватись» — два різні моменти в часі, іноді розділені секундами, і очікування в тестах треба прив'язувати до другого, а не до першого.
Чим async відрізняється від defer?
Обидва атрибути працюють лише для зовнішніх скриптів із src і дозволяють завантажувати файл паралельно з парсингом, не зупиняючи побудову DOM. Різниця в моменті виконання: async-скрипт виконується щойно файл готовий (може перервати парсинг), і порядок кількох таких скриптів не гарантований — хто перший завантажився; defer-скрипт виконується після завершення парсингу, перед DOMContentLoaded, зі збереженням порядку в документі. Практичне правило: async — для незалежних скриптів, яким байдужі порядок і DOM (аналітика, лічильники), defer — для застосункового коду, що працює з DOM. Для тестувальника важливий наслідок: поведінка, зав'язана на async-скрипт, за визначенням гоночна, бо момент його виконання непередбачуваний — це законне джерело флаку, яке треба ловити, а не залатувати затримкою.
Чим DOMContentLoaded відрізняється від load і чому чекати на load у тестах — часто погана ідея?
DOMContentLoaded спрацьовує, коли HTML повністю розібрано й defer-скрипти виконано; ця подія не чекає на зображення, підфрейми та async-скрипти. load настає пізніше — коли догрузилися всі ресурси, включно з картинками. Прив'язувати очікування до load зазвичай занадто грубо в обидва боки: сторінка буває інтерактивною задовго до load (тест дарма чекає), а буває, що й після load застосунок ще домальовує вміст через JavaScript (тест починає дію зарано). Правильніше чекати на конкретний стан конкретного елемента — саме так працюють автоочікування в сучасних фреймворках.
Що таке forced synchronous layout (layout thrashing)?
Це ситуація, коли код у циклі чергує запис у DOM і читання геометрії: кожне читання (offsetWidth, getBoundingClientRect) змушує браузер негайно дорахувати layout, який щойно «забруднив» попередній запис. Замість одного відкладеного перерахунку виходить перерахунок на кожній ітерації. Лікується розділенням фаз: спочатку всі читання, потім усі записи. Для AQA це не абстракція: важкий синхронний перерахунок займає головний потік, і поки він зайнятий, елементи намальовані, але не реагують на кліки — тест отримує таймаут на цілком «видимому» елементі; водночас сам thrashing — реальний дефект продуктивності, який можна зафіксувати як баг.
Чому плавні анімації роблять через transform і opacity, а не через top/left?
Зміни top/left — це зміни геометрії, тобто кожен кадр анімації запускає reflow, а за ним paint. Зміни transform і opacity браузер здатен обробити на етапі composite: якщо елемент лежить на власному композитному шарі, браузер лише зсуває чи змішує вже растеризовані шари силами GPU, не чіпаючи ні layout, ні paint у головному потоці — звідси плавність. Просунути елемент на окремий шар можна підказкою will-change: transform, але плата — пам'ять GPU на кожен шар, тож застосовують її точково; критерії автоматичного виносу на шар — внутрішні евристики браузерів, на конкретний перелік покладатися не варто. Для тестів анімований елемент — окрема причина флаку: його геометрія щомиті змінюється, і фреймворк вимагає стабільності (елемент не рухається) перед кліком.
Що таке viewport і як він впливає на автотести?
Viewport — видима частина сторінки, «вікно», крізь яке користувач дивиться на документ; його розмір визначає, який контент видно без прокрутки і які спрацьовують адаптивні брейкпоінти CSS. На мобільних розрізняють layout viewport (за ним рахується верстка) і visual viewport (те, що реально видно з урахуванням масштабу), керує цим метатег viewport; окремо є devicePixelRatio — на «ретина»-дисплеях один CSS-піксель відповідає кільком фізичним, що ламає наївні скріншотні порівняння між машинами. Для тестів viewport — параметр запуску, який змінює результат: однакові локатори на десктопному й мобільному розмірі можуть вести до різних елементів, бо адаптивна верстка ховає одні блоки й показує інші. Playwright перед кліком автоматично прокручує елемент у видиму область, але якщо там його перекриває «липка» шапка чи банер cookie, клік прилетить не туди — тому фіксований, свідомо обраний розмір viewport є базовою умовою відтворюваності.
Елемент є в DOM, локатор його знаходить, але клік не працює. Які можливі причини?
Причин кілька, і вони незалежні одна від одної: display: none (немає в render tree), visibility: hidden (є, але прозорий), opacity: 0 (невидимий оку, але формально клікабельний), pointer-events: none (видимий, але не приймає події), нульовий розмір, стан disabled у полів і кнопок — і найпідступніше: елемент перекритий іншим. В останньому випадку сам елемент повністю справний, але поверх нього лежить прозорий оверлей, спінер завантаження чи банер згоди на cookies, і клік дістається верхньому елементу за z-порядком. У DOM усе гаразд, тому діагностувати це можна лише розумінням композиції шарів: подивитись у DevTools, хто реально проходить hit-test у точці кліку. Сильний кандидат тут озвучує таблицю станів і одразу називає перекриття як найчастішого винуватця «клік у порожнечу».
Що перевіряє Playwright перед дією (actionability) і чим «видимість» фреймворка відрізняється від людської?
Перед дією Playwright проганяє елемент через набір перевірок: видимий, стабільний (не в процесі анімації), приймає події (hit-test: саме він, а не накладка, під курсором), увімкнений, а для введення тексту — редагований; поки якась умова не виконана, фреймворк чекає до таймауту. Кожна перевірка відповідає своєму місцю в конвеєрі: видимість — render tree, стабільність — відсутність поточного reflow чи анімації, hit-test — геометрія шарів і z-порядок. Розрив із людським сприйняттям у тому, що видимим Playwright вважає елемент із ненульовим прямокутником і без visibility: hidden, а opacity не враховує: повністю прозорий елемент для фреймворка видимий і клікабельний. Виходить пастка навпаки — тест успішно «клікає в порожнечу», а користувач на цьому місці нічого не бачить; такі кейси автоматика не зловить, потрібна окрема перевірка.
Звідки береться флак «сторінка показана, але ще не готова» і як з ним боротися системно?
Флак народжується з розриву між першим paint і реальною готовністю застосунку: кнопка вже намальована, але defer-скрипти ще не навісили обробники — вона «мертва»; дані прийшли, але JavaScript ще не перемалював список; елемент з'явився, але триває анімація появи; поверх контенту ще висить спінер чи модалка. Фіксована пауза (sleep) — головна антипрактика: вона або закоротка (тест однаково мигає на повільному оточенні), або задовга (набір повзе). Системна відповідь — три важелі. Перший: чекати на конкретний стан, що відповідає потрібній стадії конвеєра, — автоочікування дій у Playwright самі дочекаються видимості, стабільності й доступності для подій. Другий: перехоплення мережі — page.route з детермінованою заглушкою робить момент «дані готові» передбачуваним замість випадкового. Третій: ізоляція стану — cookies і сховище переживають перезавантаження, тому кожен незалежний сценарій ганяти в чистому контексті, інакше тести залежатимуть від порядку запуску. Більшість «нестабільних» тестів насправді стабільні — вони просто перевіряють стан на стадії конвеєра, яка ще не настала.
Три кейси, де конвеєр рендерингу вирішує, зелений тест чи флак: чому клік «влучає в порожнечу» попри знайдений локатор (розбір actionability в DevTools), як layout thrashing вішає головний потік і робить кнопку намальованою, але «мертвою», і як прибрати мережеву гонку перехопленням запиту замість sleep. Скрізь — що дивитися і чому.
Кейс 1. Локатор є, а клік не працює: перекриття й невидимість очима фреймворка
Класичний флак: тест падає на кроці «клікнути Submit», хоча в трасі видно, що локатор знайшов елемент. Присутність у DOM і клікабельність — різні речі, розділені кількома незалежними умовами. Відтворимо всі три частих причини одним HTML:
<!-- 1) банер cookie перекриває кнопку зверху -->
<div style="position: fixed; inset: 0; z-index: 999;">Прийняти cookie</div>
<!-- 2) кнопка є в DOM, але display:none — її немає в render tree -->
<button style="display: none">Submit A</button>
<!-- 3) кнопка прозора: opacity:0 — Playwright вважає її видимою -->
<button style="opacity: 0">Submit B</button>
Що дивитися і чому:
- Перекриття (
z-index-оверлей) — найкаверзніший випадок. Кнопка сама по собі видима й клікабельна, але поверх неї лежитьposition: fixed-банер. Playwright робить hit-test: перевіряє, що під курсором саме ціль, а не накладка, — тож кине помилкуintercepts pointer events. У DOM усе гаразд, локатор знаходить ціль, а клік дістається верхньому елементу. Діагностується лише розумінням композиції шарів і z-порядку. display: nonevsvisibility: hidden— різні стадії конвеєра. Елемент зdisplay: noneу render tree відсутній, геометрії не має,getBoundingClientRect()дасть нульовий прямокутник — фреймворк чекатиме видимості до таймауту. Елемент зvisibility: hiddenу render tree присутній і місце займає, але теж «не видно». Симптом однаковий, причина різна.opacity: 0— пастка розбіжності «видимість очима людини й очима фреймворка». Playwright вважає елемент видимим, якщо той має ненульовий прямокутник і не маєvisibility: hidden;opacityвін не враховує. Тому прозору кнопку фреймворк клікне «успішно», а очі бачать порожнє місце — тест зелений там, де користувач нічого б не побачив.
У DevTools це видно так: вкладка Elements показує вузол на місці (це лише DOM), але панель Performance у кроці «Recalculate Style» і далі Layout підкаже, чи елемент узагалі потрапив у render tree й отримав геометрію. Для оверлея зручний прийом — навести курсор на точку кліку в Inspector: браузер підсвітить саме той елемент, що ловить події, і одразу видно чужий банер.
Кейс 2. Layout thrashing: кнопка намальована, але «мертва»
JavaScript виконується в тому самому головному потоці, що й побудова дерев та розрахунок геометрії. Довгий синхронний скрипт блокує рендеринг, і поки головний потік зайнятий, елементи намальовані, але на кліки не реагують. Найлегший спосіб самому створити такий затик — примусовий синхронний layout (forced synchronous layout), він же layout thrashing:
// Погано: кожна ітерація примусово запускає reflow
for (const el of boxes) {
el.style.width = el.offsetWidth + 10 + 'px'; // читання геометрії одразу після запису → негайний layout
}
// Краще: спочатку всі читання, потім усі записи
const widths = boxes.map(el => el.offsetWidth); // читаємо
boxes.forEach((el, i) => { // пишемо
el.style.width = widths[i] + 10 + 'px';
});
Що дивитися і чому:
- У панелі Performance шукай фіолетові смуги Layout. Перший варіант дає їх стільки, скільки ітерацій: кожне читання
offsetWidthпісля запису змушує браузер негайно дорахувати щойно «забруднений» layout. Другий варіант згорне все в один-два Layout наприкінці. Якщо в трасі видно довгу суцільну задачу (long task) з піками «Layout» — це не абстрактна повільність, а конкретний дефект продуктивності, який можна зафіксувати як баг. - Reflow дорожчий за repaint. Зміна
widthтягне перерахунок геометрії (reflow), і нерідко ще й paint. Якби змінювали лише колір чиvisibility— був би дешевший repaint без геометрії. Плавні анімації тому роблять черезtransform/opacity: їх браузер обробляє на composite силами GPU, не чіпаючи layout. - Практичний наслідок для тесту. Поки цей цикл крутиться в головному потоці, кнопка вже намальована, але клік нічого не дасть — обробник не встигає відпрацювати. Тест «мигає» насамперед на повільних або перевантажених машинах CI — там головний потік зайнятий довше і вікно «мертвої» кнопки ширше. Лікувати паузою марно: чекати треба на реальний стан, а не на годинник (див. кейс 3).
Кейс 3. Флак від гонки: перехоплення мережі замість sleep
Флак найчастіше народжується з розриву між «браузер уже щось намалював» і «застосунок готовий до взаємодії». Дані підвантажилися запитом, але JavaScript ще не перемалював список — старий вміст видно, новий іще ні. Спокуса залатати це фіксованою паузою — головна антипрактика: sleep або занадто короткий (мигає на повільному оточенні), або занадто довгий (набір тестів повзе).
import { test, expect } from '@playwright/test';
test('список рендериться після відповіді API', async ({ page }) => {
// Погано: сподіваємось, що 3 секунд вистачить
// await page.waitForTimeout(3000);
// Добре: підміняємо відповідь детермінованою заглушкою —
// момент «дані готові» стає передбачуваним
await page.route('**/api/users', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Test User' }]),
})
);
await page.goto('https://app.example.com/users');
// клік і локатор самі дочекаються видимості, стабільності,
// увімкненості й доступності для подій — без ручного таймера
await expect(page.getByText('Test User')).toBeVisible();
});
Що дивитися і чому:
route.fulfillприбирає гонку в корені. Замість того щоб ловити випадковий момент приходу даних, тест сам диктує відповідь. Момент «дані готові» стає детермінованим, і очікування чіпляється до реального стану (toBeVisible), а не до годинника.- Не прив'язуй wait до
load. Подіяloadнастає, коли догрузилися всі ресурси, включно з картинками, — це часто занадто грубо: сторінка буває інтерактивною задовго доload, а буває, що й післяloadзастосунок ще домальовує вміст через JavaScript. Авто-очікування дій Playwright точніше, ніж будь-яка глобальна подія готовності. - Ізолюй стан між сценаріями. Cookies і сховище (
localStorage, IndexedDB) переживають перезавантаження. Якщо не чистити їх, попередній тест лишає застряглий стан — закритий банер, авторизацію, — і наступний поводиться інакше залежно від порядку запуску. Кожен незалежний сценарій ганяй у чистому контексті: більшість «нестабільних» тестів насправді стабільні, вони лише перевіряють стан на стадії конвеєра, яка ще не настала.
Компоненти браузера і два рушії
- Знаю, за що відповідають підсистеми браузера, і що ззовні в тестах ми чіпаємо насамперед мережевий шар (підміна відповідей) та сховище даних (чистка cookies/
localStorageміж сценаріями). - Можу пояснити різницю між рушієм рендерингу (структура й вигляд: Blink, Gecko, WebKit) і JS-рушієм (поведінка: V8, SpiderMonkey, JavaScriptCore).
- Розумію, чому DOM живе на боці рушія рендерингу, а звернення з JS до нього йдуть через прив'язки (bindings) — тому масові операції з DOM у циклі коштують дорого.
- Розумію, чому довгий синхронний JavaScript блокує рендеринг: V8 і побудова дерев ділять один головний потік (main thread), тож кнопка може бути намальована, але «мертва».
Конвеєр рендерингу: від HTML до пікселів
- Знаю порядок конвеєра: parsing → DOM/CSSOM → render tree → layout → paint → composite.
- Розумію, чому наявність вузла в DOM (
toBeAttached) — найслабша гарантія: вузол є, але про видимість і геометрію це ще нічого не каже. - Можу пояснити, чому в render tree потрапляють лише видимі вузли:
display: noneу ньому відсутній (немає геометрії), аvisibility: hiddenприсутній (займає місце, просто прозорий). - Розумію, що таке layout (він же reflow): обчислення точної геометрії в пікселях; до нього
getBoundingClientRect()дає нульовий прямокутник, аoffsetHeight— 0. - Знаю різницю між paint («яким кольором що фарбувати») і composite (склеювання готових шарів-бітмапів, часто на GPU).
- Можу пояснити критичний шлях рендерингу (critical rendering path) і чому «сторінка відкрилась» та «сторінкою можна користуватись» — різні моменти в часі.
Блокуючі ресурси і момент готовності
- Розумію, чому CSS блокує рендеринг (щоб не показати FOUC) і навіть гальмує наступні скрипти, бо ті можуть спитати
getComputedStyle. - Можу пояснити, чому звичайний
<script>блокує парсинг DOM (може викликатиdocument.write), і що preload scanner підвантажує ресурси наперед, але сам DOM далі не будує. - Знаю різницю
async(виконується щойно готовий, порядок не гарантований) vsdefer(після парсингу, за порядком, передDOMContentLoaded) і чомуasync— законне джерело гоночного флаку. - Розумію різницю між
DOMContentLoaded(HTML розібрано,deferвиконано) іload(догрузились усі ресурси, включно з картинками), і чому прив'язувати wait доloadчасто занадто грубо.
Продуктивність, шари і viewport
- Можу пояснити reflow (перерахунок геометрії, дорогий і каскадний) vs repaint (лише вигляд без геометрії, дешевший).
- Розумію, що таке примусовий синхронний layout (layout thrashing): чергування запису в DOM і читання геометрії в циклі; лікується патерном «спершу всі читання, потім усі записи».
- Знаю, чому анімують
transform/opacity, а неtop/left: перші браузер обробляє на composite силами GPU без layout і paint; за окремі шари (will-change) платимо пам'яттю. - Розумію, чому свідомо фіксований viewport — базова умова відтворюваності: адаптивна верстка на різних розмірах веде однакові локатори до різних елементів, а «липка» шапка чи банер cookie можуть перехопити клік.
Чому елемент «є, але не клікається», і корінь флаку
- Можу пояснити стани недосяжності:
display: none,visibility: hidden,opacity: 0(фреймворк вважає видимим!),pointer-events: none, розмір 0×0, перекриття оверлеєм,disabled. - Розумію перевірки доступності (actionability) Playwright: видимий, стабільний (не в анімації), приймає події (hit-test), увімкнений, редагований — і що «видимість» очима людини й фреймворка не збігаються.
- Розумію, звідки береться флак: розрив між першим paint і готовністю застосунку (обробники ще не навісились, дані є, але список не перемальовано, триває анімація появи, зверху висить спінер).
- Знаю правильну відповідь замість
sleep: чекати на конкретний стан (авточекання кліку), детермінувати мережу через перехоплення (page.route) і ізолювати стан — чистий контекст із порожніми cookies/сховищем.
Де фізично живе DOM — на боці якої підсистеми браузера?
Питання
Рушій рендерингу проти JS-рушія: хто за що відповідає?