vyvchy
    Теми розділу

    03 · Веб і мережі для AQA

    Архітектура браузера й рендеринг

    Зміст

    Коли автотест падає з формулюванням «елемент не знайдено», хоча ти на власні очі бачив його на скріншоті, справжня причина майже завжди прихована в тому, як браузер перетворює текстовий 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, OperaBlinkV8
    FirefoxGeckoSpiderMonkey
    SafariWebKitJavaScriptCore (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>
    JS-рушійМережаПарсерJS-рушійМережаПарсерЗустрів <script src>Будує DOM з HTMLЗавантажити скриптФайл скриптаВиконати скриптГотовоПродовжує будувати DOMJS-рушійМережаПарсерJS-рушійМережаПарсерЗустрів <script src>Будує DOM з HTMLЗавантажити скриптФайл скриптаВиконати скриптГотовоПродовжує будувати DOM

    На практиці браузери пом’якшують це «спекулятивним» попереднім скануванням (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). Анімований елемент — окрема причина флаку: фреймворк може вимагати, щоб елемент був «стабільним» (не рухався) перед кліком.

    Розмір або положення

    Лише вигляд: колір, фон

    transform / opacity

    Що змінилось на сторінці?

    Тип зміни

    Reflow: перерахунок геометрії

    Repaint: перемалювати

    Composite на GPU
    без reflow і paint

    Показати кадр

    Розмір або положення

    Лише вигляд: колір, фон

    transform / opacity

    Що змінилось на сторінці?

    Тип зміни

    Reflow: перерахунок геометрії

    Repaint: перемалювати

    Composite на GPU
    без reflow і paint

    Показати кадр

    Композитні шари і 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-порядок.

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Дія над елементом

    Видимий?

    Чекати, потім таймаут

    Стабільний?
    не анімується

    Приймає події?
    hit-test проходить

    Увімкнений?

    Виконати клік

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Дія над елементом

    Видимий?

    Чекати, потім таймаут

    Стабільний?
    не анімується

    Приймає події?
    hit-test проходить

    Увімкнений?

    Виконати клік

    Тут ховається важлива невідповідність: «видимість» очима людини й «видимість» очима фреймворка — не одне й те саме. 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 до пікселів, той ставить очікування на правильну стадію й перетворює флак на детермінований результат.