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

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

    Виконання JavaScript та event loop

    Зміст

    «Клік не спрацював», «елемент є в DOM, а тест падає», «той самий тест то зелений, то червоний» — три типові скарги на автотести, і за всіма трьома стоїть одне: браузер виконує JavaScript не так, як здається з коду тесту. Сторінка робить одну річ за раз і має власний порядок, у якому бере відкладену роботу. Поки цього порядку немає в голові, очікування в тестах виглядають забобоном, а фіксована пауза — розумним компромісом.

    Це канонічна глава теми: тут повний виклад моделі виконання, інші глави розділу посилаються сюди. Синтаксис і async/await як мова — тема розділу про JavaScript і TypeScript; тут лише модель виконання.

    Один потік і стек викликів

    JavaScript у браузері виконується в одному потоці: у кожен момент часу (JavaScript engine) виконує рівно один фрагмент коду. Найменша одиниця виконання — контекст виконання, він же кадр стека (frame): виклик функції кладе кадр на стек викликів (call stack), завершення знімає його, і кожна порція роботи починається та закінчується порожнім стеком.

    Звідси головна властивість моделі — run-to-completion: кожне завдання обробляється повністю, перш ніж почнеться будь-яке інше, і середовище не витісняє JS-код примусово, як це роблять потокові мови. Поки стек не порожній, потік зайнятий — ні обробки кліків, ні анімацій, ні перемальовування, ні відкладених .

    Ось звідки береться «вкладка не відповідає»: важкі обчислення в циклі, синхронний розбір великого JSON, масові операції над деревом — одна , під час якої сторінка мертва. Модель обіцяє неблокуюче виконання, але обіцянка тримається лише на асинхронних API: легасі-винятки на кшталт alert() чи синхронного XHR блокують потік повністю. Лікується це двома способами — розбити роботу на частини або винести обчислення у Web Worker, окремий потік без доступу до DOM.

    Наша практика (не канон). Джерела реєстру цього не формулюють. Один потік існує через DOM: два потоки над одним деревом вимагали б блокувань. Stack trace — знімок стека в момент збою, читається згори вниз: угорі функція, де впало, нижче — ті, хто її викликав; RangeError: Maximum call stack size exceeded означає, що рекурсія кладе кадри швидше, ніж вони знімаються. І типовий симптом: «клік не спрацював» частіше означає зайнятий потік, а не поганий .

    Подієвий цикл: хто вирішує, коли виконати відкладене

    (event loop) — механізм, яким браузер узгоджує події, дії користувача, скрипти, рендеринг і мережу; потоком операційної системи він не є — кілька циклів можуть кооперативно жити в одному потоці. Спрощена логіка така: виконати весь синхронний код → коли стек порожній, узяти наступне завдання з черги й покласти на стек → повторити. Нові завдання додаються в кінець черги.

    Ні

    Так

    Виконати синхронний код
    стек росте і згортається

    Стек порожній?

    Спорожнити чергу мікрозадач

    Узяти наступну задачу з черги

    Ні

    Так

    Виконати синхронний код
    стек росте і згортається

    Стек порожній?

    Спорожнити чергу мікрозадач

    Узяти наступну задачу з черги

    Ключове: таймери, ввід-вивід і події лише додають завдання в чергу — момент виконання визначає цикл. Колбек асинхронної дії не виконується миттєво, він стає завданням; звідси головний наслідок: асинхронний колбек ніколи не перерве синхронний код, що вже виконується.

    Черг завдань буває кілька, і формально це навіть не черги, а множини: цикл бере перше придатне до виконання завдання, а не буквально перше. Кожне завдання належить своєму джерелу (події, парсер, таймери), а джерело прив'язане до конкретної черги — це зберігає порядок усередині джерела. Рендеринг же відбувається не після кожного завдання, а в «нагоду для рендерингу», яку визначають частота оновлення екрана й браузера.

    Задачі і мікрозадачі: чому порядок саме такий

    Черга не одна, і це те місце, де плутаються найчастіше. Черга мікрозадач — не одна з черг задач, а окрема сутність: специфікація каже прямо («The microtask queue is not a task queue»). Розподіл такий:

    • Задачі (task) — колбеки setTimeout і setInterval, події DOM, події XHR.
    • Мікрозадачі (microtask) — реакції промісів, queueMicrotask, колбеки MutationObserver.

    Правило, яке пояснює майже всю поведінку асинхронного JS: після кожного виконаного завдання цикл проходить контрольну точку мікрозадач і спорожнює чергу мікрозадач повністю — включно з тими, що додалися під час її виконання, — і лише потім бере наступну задачу. Тому мікрозадачі мають вищий пріоритет; а код, який безперервно додає нові мікрозадачі, не дає циклу дійти ні до наступної задачі, ні до нагоди перемалювати сторінку.

    console.log('1: синхронний старт');
    setTimeout(() => console.log('2: setTimeout — задача'), 0);
    Promise.resolve().then(() => console.log('3: promise — мікрозадача'));
    console.log('4: синхронний кінець');
    1: синхронний старт
    4: синхронний кінець
    3: promise — мікрозадача
    2: setTimeout — задача

    Спершу виконується весь синхронний код (1 і 4), стек порожніє. Далі цикл спорожнює чергу мікрозадач — тому 3 випереджає 2, хоч setTimeout записаний вище. І лише потім береться задача.

    АспектМікрозадачіЗадачі
    Хто потрапляєреакції промісів, queueMicrotask, MutationObserversetTimeout, setInterval, події DOM, події XHR
    Коли виконуютьсяуся черга — одразу після поточного завданняпо одній за прохід циклу
    Пріоритетвищийнижчий

    Про термін. Пару «мікрозадача — макрозадача» вживають у побуті, але канонічні джерела її не знають: слова macrotask немає ні в MDN, ні в HTML Living Standard — MDN каже «HTML event loops split jobs into two categories: tasks and microtasks». Тому «макрозадача» лишається жаргоном: розпізнавати на слух, самому не вживати.

    Проміси, async/await і мережа в цій моделі

    Проміс (promise) має рівно три взаємовиключні стани — pending, fulfilled, rejected, — і це нормативна вимога мови; завершений (settled) проміс уже не змінюється, а повторний resolve чи reject не робить нічого. Ланцюг промісів зʼявився як заміна вкладеним колбекам («callback hell», «піраміда приреченості»): кроки стають лінійними, а помилки збирає один catch. Комбінатори Promise.all, Promise.allSettled, Promise.race і Promise.any узгоджують кілька операцій; повний розбір разом із синтаксисом — у розділі про JavaScript і TypeScript. Для подієвого циклу важать три речі.

    Реакції промісів — мікрозадачі. Функція, передана в then(), ніколи не викликається синхронно, навіть на вже розвʼязаному промісі: вона йде в чергу мікрозадач і виконується, коли стек порожній, перед поверненням керування в цикл.

    await не блокує потік. Асинхронна функція завжди повертає проміс; код до першого await включно виконується синхронно, а код після кожного await — це фактично колбек .then, тобто продовження їде в чергу мікрозадач. Це пауза для однієї функції, а не заморозка сторінки.

    Мережа приходить задачею, а обробляється мікрозадачею. Стандарт формулює це загальним правилом: коли алгоритм тягне ресурс неблокуючим способом, обробка отриманого «is performed by a task» — саме ця задача й резолвить проміс запиту. Реакції .then/.catch виконуються вже мікрозадачами, і це не домовленість реалізацій: планування промісних операцій HTML кладе саме в чергу мікрозадач («HTML schedules these operations in the microtask queue»). Старий XHR.onload мікрозадачного етапу не має — він лишається задачею.

    Одну модальність тут варто взяти точно, бо її часто переказують сильнішою, ніж вона є. Стандарт каже, що диспетчеризація події на цілі «often done by a dedicated task», і одразу застерігає: «Not all events are dispatched using the task queue; many are dispatched during other tasks». Тобто коректне формулювання — «обробник події лишається задачею, а не мікрозадачею», а не «подія завжди створює окрему задачу».

    Таймери: чому нуль не означає нуль

    setTimeout(fn, 0) не виконує fn негайно з двох незалежних причин.

    Перша — черга. Колбек таймера потрапляє в чергу як глобальна задача з джерела задач таймерів. Навіть із нульовою він чекає, поки завершиться синхронний код і спорожниться черга мікрозадач. «Нуль» означає «якнайшвидше після того, як звільниться потік», а не «зараз».

    Друга — затримка це мінімум, а не розклад. Специфікація заявляє прямо: «This API does not guarantee that timers will run exactly on schedule. Delays due to CPU load, other tasks, etc, are to be expected». Відʼємна затримка нормалізується в нуль.

    Для вкладених таймерів додається жорсткий мінімум: якщо рівень вкладеності більший за 5, а задана затримка менша за 4, затримка стає 4 мс; примітка стандарту каже те саме словами — після пʼяти таких вкладень інтервал примусово стає не меншим за чотири мілісекунди. Умова саме «більший за 5», тож clamp вмикається з шостого рівня. Дві деталі відрізняють вивчене від зрозумілого: рівень рахується для вкладених викликів самого алгоритму, тож повторюваний setInterval набирає рівні без ручного вкладання; а 4 мс — нижня межа, а не обіцяний період, бо браузеру окремо дозволено доповнити затримку на визначений реалізацією час, наприклад заради економії енергії.

    Практичний висновок: setTimeout(fn, 0) — спосіб відкласти роботу на наступний прохід циклу, а не спосіб виміряти час.

    AJAX і fetch: звідки береться проміжок

    AJAX — підхід, за якого сторінка обмінюється даними із сервером у фоні, без повного перезавантаження: сторінка не «моргає», а оновлюється лише потрібна частина DOM. Технічно це роблять двома способами — старим XMLHttpRequest і сучасним Fetch API, побудованим на промісах, а не колбеках.

    Для тестів тут дві дорогі пастки. Перша: fetch() не реджектиться на HTTP-помилці. Відповідь 404 чи 500 дає успішний проміс із обʼєктом Response, тож статус перевіряють вручну (response.ok істинний лише для діапазону 2xx). Реджект настає «on some errors, such as a network error or a bad scheme» — перелік у джерелі навмисно відкритий. Друга: у XMLHttpRequest стан 4 DONE означає «операція завершилась» — успішно або з помилкою, тож сам по собі readyState === 4 про успіх не свідчить.

    Обидва механізми йдуть через мережевий стек браузера, тому інструменти автоматизації ловлять їх однаково — і щоб дочекатися конкретної відповіді, і щоб її підмінити. Це тема глав Перехоплення й мокання мережі та Асинхронне завантаження: SPA/MPA, CSR/SSR; статуси відповідей — HTTP статус-коди.

    Події DOM: порядок обробників

    Подія проходить дерево трьома фазами: захоплення (capturing) — до цілі, фаза цілі, спливання (bubbling) — після цілі; поточну фазу видно у властивості eventPhase. Порядок точний: спершу в порядку дерева спрацьовують слухачі, зареєстровані з capture: true, а потім, якщо подія спливає, — у зворотному порядку решта. За замовчуванням обробник слухає саме спливання.

    Порядок ламають три методи, і плутати їх не можна. stopPropagation() не дає події дістатися інших обʼєктів дерева, але стандартної дії браузера не скасовує. stopImmediatePropagation() додатково відсікає решту слухачів на тому самому обʼєкті. preventDefault() скасовує стандартну дію (лише для подій із cancelable: true) і поширення не спиняє; успіх видно через defaultPrevented.

    Спливають не всі події. blur не спливає, а focusout — спливає; порядок теж нормативний: focus спрацьовує перед focusin, blur — перед focusout. Практичний наслідок: делегування (event delegation) на групу полів працює через focusout і не працює через blur. Саме делегування прямо випливає зі спливання: один обробник на спільному предку обслуговує всі дочірні елементи, зокрема ті, що зʼявляться згодом, а e.target — найглибший елемент, по якому реально клікнули.

    У часі ж обробники живуть усередині завдання: воно виконується до завершення й нікого не перериває. Тому «порядок обробників» має два виміри — у дереві (фази) і в часі (черги). Канон подій — глава DOM, селектори та події.

    DOM і головний потік: що ділиться, а що виконується поза ним

    DOM — не текст і не файл, а структура в памʼяті браузера: розібравши HTML, браузер будує дерево й показує сторінку вже з нього, а скрипти змінюють це дерево безперервно — у відповідь на дії користувача, відповіді сервера й таймери.

    Важливо, де це дерево живе. JS-рушій виконується в тому самому головному потоці, що й побудова дерев та розрахунок геометрії, тож довгий синхронний JavaScript блокує рендеринг — сторінка замерзає. Ролі при цьому розділені: (rendering engine) відповідає за структуру й вигляд, JS-рушій — за поведінку, а потік у них спільний.

    Поза цим потоком браузер робить чимало: мережеві запити, таймери, читання файлів, декодування зображень виконують окремі системні механізми. Саме на цьому стику й народжується асинхронність — важку роботу браузер делегує назовні й повертає результат, коли потік JS звільниться. Окремий потік можна взяти й самому — це Web Worker, але доступу до DOM він за задумом не має. Про підсистеми детально — глава Архітектура браузера й рендеринг.

    Наша практика (не канон). Джерела реєстру цього не формулюють: DOM фізично живе на боці рушія рендерингу, тож кожен виклик на кшталт document.querySelector — перехід межі між підсистемами, і масові операції з деревом у циклі коштують дорожче, ніж здається з коду.

    Гонка станів як наслідок моделі

    Гонка (race condition) — ситуація, коли результат залежить від порядку завершення асинхронних операцій, а цей порядок не гарантований. Підґрунтя гонок — сама модель виконання: таймери, ввід-вивід і події лише додають завдання в чергу.

    Тонкість, яку варто розуміти точно: всередині одного завдання все детерміновано — воно виконується до завершення, і вивід передбачуваний. Гонка виникає між завданнями.

    СерверКлієнтСерверКлієнтпоказали результати abcпоказали ab поверх abcзапит "ab"запит "abc"відповідь "abc" — швидшавідповідь "ab" — пізнішаСерверКлієнтСерверКлієнтпоказали результати abcпоказали ab поверх abcзапит "ab"запит "abc"відповідь "abc" — швидшавідповідь "ab" — пізніша

    Лікується це в коді відстеженням актуальності запиту: скасувати попередній через AbortController або ігнорувати відповіді, чий запит уже застарів.

    let requestId = 0;
    
    async function search(query: string): Promise<void> {
      const current = ++requestId;
      const results = await fetchSearch(query);
      if (current !== requestId) return; // відповідь на застарілий запит
      render(results);
    }

    Наша практика (не канон). Приклад вище — наша ілюстрація, а не теза джерела. Живий пошук: користувач набирає ab, потім abc, летять два запити, і якщо відповідь на ab прийде пізніше, вона перезапише правильні результати. Суть дефекту одним рядком: перемагає «останній, що відповів», хоча має перемагати «останній, що надіслали». Гонки ми вважаємо головною причиною : тест, який не синхронізується зі станом, сам створює гонку.

    Наслідок для тестів: синхронізація замість пауз

    Тест і застосунок живуть у різному часі: тест виконує команди швидко й послідовно, браузер робить свою роботу через подієвий цикл у власному темпі — і між «тест наказав» і «браузер зробив» завжди є проміжок.

    Частину проміжку інструмент закриває сам. Перед кожною дією він виконує набір перевірок придатності (actionability) — що локатор вказує рівно на один елемент і що елемент видимий, стабільний, отримує події й увімкнений, — чекає, поки релевантні пройдуть, і лише тоді діє; не дочекався в межах — падає з TimeoutError. Web-first самі повторюють перевірку, тому ручні паузи навколо них зайві.

    Але не знає прикладних станів: чи навісився обробник після гідратації, чи завершився запит, чи оновилися дані в таблиці. Там потрібне явне очікування сигналу — і одна деталь, на якій ловляться навіть досвідчені: проміс очікування відповіді створюють до дії, що тригерить запит, інакше швидка відповідь прослизне у вікно між ними.

    // Погано: пауза синхронізується з годинником, а не із застосунком
    await page.waitForTimeout(3000);
    await expect(page.locator('#status')).toHaveText('Готово');
    
    // Добре: очікування відповіді створене до кліку, асерт чекає стану
    const response = page.waitForResponse('**/api/orders');
    await page.getByRole('button', { name: 'Зберегти' }).click();
    await response;
    await expect(page.locator('#status')).toHaveText('Готово');

    Фіксована пауза програє двічі: замало — тест на повільному CI, забагато — прогін марно розтягується. Дока Selenium каже те саме прямо: код не може знати, скільки чекати насправді, тож замала пауза падає, а завелика в кожному потрібному місці робить тривалість сесії неприйнятною; явне ж очікування вона описує як цикл, що опитує застосунок на конкретну умову, і два механізми синхронізації називає прямо «better». Виняток «а якщо перевіряємо відсутність зміни» ходить по статтях, але джерельної опори він не має: у доці Selenium про виправданість такої паузи не сказано нічого — ні за, ні проти. Практичні рецепти — у главі Практичні сценарії AQA: флак і синхронізація.

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

    • «setTimeout(fn, 0) виконає функцію негайно». Насправді це задача: вона чекає кінця синхронного коду й повного спорожнення черги мікрозадач, а у вкладених таймерах ще й отримує мінімум 4 мс.
    • «Проміс уже розвʼязаний, тож .then спрацює одразу». Насправді функція, передана в then(), ніколи не викликається синхронно — вона йде в чергу мікрозадач.
    • «await зупиняє браузер». Насправді призупиняється лише ця функція; потік вільний, а продовження повертається мікрозадачею.
    • «fetch не кинув помилку, отже запит успішний». Насправді 404 і 500 дають успішний проміс — статус перевіряють вручну.
    • «Елемент є в DOM, отже можна діяти». Насправді його вміст могла оновлювати мікрозадача чи мережевий колбек, який ще не відпрацював.
    • «Клік не спрацював — поганий локатор». Насправді потік міг бути зайнятий довгим синхронним кодом, і подія просто не оброблялась.
    • «Обробника немає на кнопці — баг». Насправді це може бути делегування: обробник висить на предку, а перевіряти треба результат кліку.
    • «Винесемо роботу з DOM у Web Worker». Насправді доступу до DOM не має: у нього виносять обчислення.
    • «Порядок логів не такий, як у коді — щось поламано». Насправді це майже завжди пріоритет мікрозадач над задачами.
    • «Тест то падає, то ні — інфраструктура». Насправді це майже завжди гонка між темпом тесту й темпом асинхронного застосунку.

    Підсумок

    • Один потік і run-to-completion. Поки поточне завдання не закінчилось, не станеться нічого іншого — ні кліку, ні анімації, ні перемальовування. Звідси і «зависла вкладка», і «мертві» кнопки.
    • Асинхронність — не паралельність. Таймери, мережа й події лише додають завдання в чергу; момент виконання визначає подієвий цикл, тому асинхронний колбек ніколи не перерве синхронний код.
    • Черга мікрозадач спорожнюється повністю після кожного завдання. Тому реакція проміса завжди випереджає setTimeout(fn, 0), а «макрозадача» — жаргон, якого канонічні джерела не вживають.
    • Затримка таймера — мінімум, а не розклад. Гарантій щодо часу специфікація не дає, а з шостого рівня вкладеності ще й піднімає до 4 мс затримку, задану меншою.
    • Гонка — не , а наслідок моделі. У коді її закривають перевіркою актуальності запиту, у тестах — синхронізацією за спостережуваним станом, а не паузою на годинник.

    Можливі питання

    • «Що таке event loop і навіщо він, якщо потік один?» Перевіряють, чи розуміє кандидат, що асинхронність — це порядок узяття завдань, а не паралельне виконання. Сильна відповідь називає стек, чергу й правило «наступне завдання — коли стек порожній».
    • «У якому порядку виведуться console.log, setTimeout(0) і Promise.then Класика на пріоритет черг. Дивляться не на вгаданий порядок, а на пояснення: спершу синхронний код, потім уся черга мікрозадач, і лише потім задача.
    • «Чому setTimeout(fn, 0) не спрацьовує миттєво?» Чекають дві незалежні причини — черга задач і те, що затримка є мінімумом, а не гарантією. Згадка про 4-мс clamp відрізняє прочитане від зрозумілого.
    • «Чим мікрозадача відрізняється від задачі і що куди потрапляє?» Питання на точність: реакції промісів, queueMicrotask і MutationObserver проти таймерів, подій DOM і XHR — плюс повне спорожнення черги.
    • «Що станеться зі сторінкою під час довгого синхронного циклу?» Перевіряють звʼязок моделі з UX і з тестами: потік зайнятий, події не обробляються, рендер стоїть, а тест бачить неклікабельний елемент або таймаут.
    • «Що таке race condition у браузері та як від неї захиститися?» Чекають означення через порядок завершення операцій і два рівні лікування — AbortController чи перевірка актуальності в коді й синхронізація за станом у тесті.
    • «Чому фіксована пауза — погана відповідь на нестабільний тест?» Дивляться, чи бачить кандидат, що пауза синхронізується з годинником, а не із застосунком: вона маскує гонку й водночас розтягує прогін.

    Джерела

    Один потік і стек викликів

    Подієвий цикл: хто вирішує, коли виконати відкладене

    • WHATWG HTML Standard — Event loops — нормативний опис циклу, черг завдань, джерел завдань і нагоди для рендерингу.
    • MDN — JavaScript execution model — спрощена логіка циклу, додавання завдань у кінець черги, «таймери лише додають завдання».

    Задачі і мікрозадачі: чому порядок саме такий

    • WHATWG HTML Standard — Event loops — «The microtask queue is not a task queue», контрольна точка й повне спорожнення черги мікрозадач.
    • MDN — JavaScript execution model — склад задач і мікрозадач, пріоритет мікрозадач, розподіл на tasks and microtasks.

    Проміси, async/await і мережа в цій моделі

    • MDN — Using promises (JavaScript Guide) — ланцюг замість callback hell, колбеки промісів як мікрозадачі, гарантія несинхронного виклику then.
    • TC39 — ECMAScript 2027 Language Specification (ECMA-262, чинний draft) — три взаємовиключні стани проміса як нормативна вимога, безрезультатність повторного resolve/reject.
    • MDN — Promisesettled як «не pending» і комбінатори для узгодження кількох асинхронних операцій.
    • MDN — async function — асинхронна функція завжди повертає проміс, межа синхронного до першого await, продовження як колбек .then.
    • WHATWG HTML Standard — Event loops — обробка неблокуюче отриманого ресурсу як задача; планування промісних операцій у черзі мікрозадач; диспетчеризація події як задача з модальністю «often» і застереженням, що не всі події йдуть чергою задач.
    • MDN — Using microtasks in JavaScript with queueMicrotask() — склад черг: проміси й MutationObserver у мікрозадачах, спрацьований таймер у черзі задач, клік як задача, що виконує колбеки події.

    Таймери: чому нуль не означає нуль

    • WHATWG HTML Standard — Timers (§8.6) — джерело задач таймерів, відсутність гарантії розкладу, нормалізація відʼємної затримки, 4-мс clamp з умовою вкладеності й дозвіл на додаткову затримку.
    • MDN — JavaScript execution model — таймер лише додає задачу в чергу, момент виконання визначає цикл.

    AJAX і fetch: звідки береться проміжок

    Події DOM: порядок обробників

    • WHATWG DOM Standard — три фази поширення, eventPhase, точний порядок слухачів, stopPropagation, stopImmediatePropagation, preventDefault, делегування і e.target.
    • W3C — UI Events — спливання focusin/focusout проти blur і нормативний порядок цих подій.
    • MDN — Introduction to events — свідомий preventDefault() в обробнику як причина «клік нічого не зробив».

    DOM і головний потік: що ділиться, а що виконується поза ним

    Гонка станів як наслідок моделі

    • MDN — JavaScript execution model — відсутність гарантій щодо порядку задач і повна обробка кожного завдання перед наступним.
    • MDN — Using the Fetch API — скасування запиту через AbortController і signal.

    Наслідок для тестів: синхронізація замість пауз

    • Playwright — Auto-waiting (actionability) — набір перевірок придатності, падіння з TimeoutError, самоповторні асерти й межі автоочікування.
    • Selenium — Waiting Strategies — подвійна ціна фіксованої паузи, явне очікування як цикл опитування умови, два механізми синхронізації як «better».
    • Playwright — class Page — очікування мережевої відповіді, створене до дії, що тригерить запит.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.