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

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

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

    Зміст

    Коли автотест «клікає занадто рано», коли expect спрацьовує до того, як зʼявився елемент, коли той самий тест то зелений, то червоний — за цим майже завжди стоїть нерозуміння того, як браузер виконує JavaScript. Ця модель виконання пояснює, чому інтерфейс не «застигає» під час завантаження даних, чому setTimeout(fn, 0) не миттєвий і чому очікування в тестах — не милиця, а прямий наслідок асинхронної природи вебу.

    Розберемо механізм по шарах: від одного потоку і стека викликів до черг задач, промісів і практичних висновків для написання стабільних тестів.

    Однопотоковість JavaScript

    JavaScript у браузері виконується в одному потоці (single-threaded). Це означає, що в кожен момент часу рушій виконує рівно один фрагмент коду — не два, не десять. Немає паралельного виконання твого коду, немає двох функцій, які працюють «одночасно» і борються за одну змінну так, як це буває в багатопотоковій Java чи C++.

    Розберімося з «чому». Мова створювалась для маніпуляцій з інтерфейсом сторінки — з DOM (Document Object Model). Якби два потоки могли одночасно змінювати одне й те саме дерево елементів, знадобилися б блокування (locks), і будь-яка помилка розробника вела б до дедлоків і гонок прямо у відмальовуванні сторінки. Один потік прибирає цей клас проблем: порядок операцій над DOM у межах одного синхронного блоку завжди детермінований.

    Тут важливо не сплутати два поняття. Однопотоковий — це рушій виконання JS-коду. Але сам браузер (чи Node.js) — багатопотоковий: мережеві запити, таймери, читання файлів, декодування зображень виконують окремі системні механізми поза потоком JS. Тобто твій код працює в один потік, а «важку» роботу (чекання відповіді сервера) браузер делегує назовні й повертає результат тоді, коли потік JS звільниться. Саме на цьому стику й народжується асинхронність.

    Call stack — стек викликів

    Стек викликів (call stack) — це структура, у якій рушій відстежує, яка функція зараз виконується і хто її викликав. Працює за принципом LIFO (last in, first out): останній зайшов — перший вийшов.

    Коли викликається функція, на вершину стека кладеться її кадр (frame) — запис про виклик. Коли функція завершується (return або кінець тіла), кадр знімається зі стека.

    function multiply(a, b) {
      return a * b;
    }
    
    function square(n) {
      return multiply(n, n);
    }
    
    function printSquare(n) {
      const result = square(n);
      console.log(result);
    }
    
    printSquare(5);

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

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

    Переповнення стека (RangeError: Maximum call stack size exceeded) — це коли рекурсія без виходу кладе кадри швидше, ніж вони знімаються, і стек упирається у свій ліміт.

    Event loop — цикл подій

    Якщо потік один, а стек виконує все синхронно — як тоді працює асинхронність? Відповідь: цикл подій (event loop).

    Event loop — це нескінченний цикл-координатор, який стежить за станом стека і черг. Його логіка спрощено така:

    1. Виконуй увесь синхронний код (стек росте і згортається).
    2. Коли стек порожній — візьми наступне завдання з черги і поклади його на стек.
    3. Повторюй.

    Асинхронні операції не виконуються «в паралель» у твоєму коді. Вони працюють так: коли ти викликаєш setTimeout, fetch чи вішаєш обробник події (event handler), рушій передає цю роботу відповідному механізму браузера (Web API) і одразу йде далі, не чекаючи. Коли механізм завершив роботу (таймер відлічив час, відповідь прийшла, користувач клікнув), функція-обробник (callback) потрапляє в чергу. Event loop дістане її звідти й покладе на стек лише тоді, коли стек стане порожнім.

    Звідси головний висновок: асинхронний колбек ніколи не перерве синхронний код, який уже виконується. Він завжди чекає своєї черги.

    Ні

    Так

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

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

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

    Покласти його на стек

    Ні

    Так

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

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

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

    Покласти його на стек

    Черга задач vs черга мікрозадач

    Черга не одна — і це деталь, яку найчастіше плутають. Черг щонайменше дві:

    • Черга задач / макрозадач (task / macrotask queue) — сюди потрапляють колбеки setTimeout, setInterval, обробники подій DOM, події XHR (onload, onreadystatechange); у Node.js також setImmediate та I/O.
    • Черга мікрозадач (microtask queue) — сюди потрапляють реакції промісів (.then, .catch, .finally), queueMicrotask, колбеки MutationObserver.

    Правило, яке пояснює майже всю поведінку асинхронного JS:

    Після кожної однієї макрозадачі event loop повністю спорожнює чергу мікрозадач — виконує всі мікрозадачі до останньої (включно з тими, що додалися під час обробки) — і лише потім бере наступну макрозадачу.

    Мікрозадачі мають пріоритет. Якщо всередині мікрозадачі створити ще одну мікрозадачу, вона теж виконається в цьому ж «прогоні», перш ніж дійде черга до макрозадач і до перемальовування сторінки. Саме перемальовування (rendering) браузер зазвичай робить між макрозадачами — вже після того, як спорожнив чергу мікрозадач, і лише коли є нагода для перемальовування (браузер прив’язує його до частоти оновлення екрана, тож рендер відбувається не після кожної задачі).

    Класичний приклад, який варто вміти пояснити:

    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). Стек порожніє. Далі event loop спорожнює чергу мікрозадач — тому 3 (проміс) випереджає 2 (setTimeout), хоч setTimeout у коді записаний вище. І тільки потім береться макрозадача — 2.

    Синхронний код
    1, 4

    Мікрозадачі
    3 · promise

    Макрозадача
    2 · setTimeout

    Синхронний код
    1, 4

    Мікрозадачі
    3 · promise

    Макрозадача
    2 · setTimeout

    АспектМікрозадачі (microtasks)Макрозадачі (macrotasks)
    Хто сюди потрапляє.then/.catch/.finally, queueMicrotask, MutationObserversetTimeout, setInterval, події DOM, події XHR
    Коли виконуютьсяУся черга — одразу після поточної задачі, до наступної макрозадачіПо одній за прохід циклу
    ПріоритетВищийНижчий
    РизикНескінченне додавання мікрозадач може «заморозити» рендер

    Окремо про fetch, бо це часте джерело плутанини. Саме завершення мережі браузер оформлює як задачу (macrotask), яка резолвить проміс. Але реакції .then/.catch, повішані на цей проміс, виконуються вже як мікрозадачі. Тому обробник відповіді від fetch за пріоритетом поводиться як мікрозадача, а не як макрозадача — на відміну від старого XHR.onload, який є звичайною задачею.

    Чому setTimeout(fn, 0) не миттєвий

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

    Перша. fn — це макрозадача. Навіть із нульовою затримкою вона потрапляє в чергу і чекає, поки (а) завершиться весь поточний синхронний код і (б) спорожниться черга мікрозадач. Тобто «0 мс» — це не «зараз», а «якнайшвидше після того, як потік звільниться». Якщо синхронний код виконується 500 мс, колбек чекатиме ці 500 мс.

    Друга. Значення затримки — це мінімум, а не гарантія. Браузер не зобовʼязаний виконати колбек рівно через задану кількість мілісекунд; він виконає його не раніше, ніж настане час і звільниться потік. Крім того, HTML Living Standard передбачає обмеження мінімальної затримки для вкладених таймерів: коли рівень вкладеності перевищує 5, а задана затримка менша за 4 мс, вона примусово піднімається до 4 мс. Тобто ланцюжок вкладених setTimeout(fn, 0) після кількох рівнів фактично тактує щонайменше кожні 4 мс.

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

    Синхронний vs асинхронний код

    Синхронний (synchronous) код виконується послідовно, рядок за рядком; кожен наступний рядок чекає завершення попереднього. Асинхронний (asynchronous) код запускає операцію і йде далі, не чекаючи результату; результат приходить пізніше — через колбек, проміс чи await.

    // Синхронно (Node.js): наступний рядок чекає завершення читання
    const data = readFileSync('config.json'); // блокує потік, поки файл не прочитано
    console.log(data);
    // Асинхронно: запит іде «у фон», потік вільний
    fetch('/api/config').then(res => console.log(res)); // не блокує
    console.log('цей рядок виконається ПЕРШИМ');

    У другому випадку fetch делегує роботу мережевому механізму браузера, потік одразу йде до console.log('...ПЕРШИМ'), і лише коли відповідь прийде, реакція стане в чергу.

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

    Callback і callback hell

    Найстаріший спосіб працювати з асинхронністю — колбек (callback): функція, яку передають як аргумент і викликають, коли операція завершилась.

    getUser(1, (user) => {
      getOrders(user.id, (orders) => {
        getOrderDetails(orders[0].id, (details) => {
          console.log(details);
        });
      });
    });

    Коли асинхронні кроки залежать один від одного, колбеки вкладаються один в один. Це зростає вправо «драбинкою», яку називають callback hell (або «піраміда приреченості», pyramid of doom). Проблеми не лише косметичні:

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

    Саме щоб розплутати цю драбину, зʼявилися проміси.

    Promise і його стани

    Проміс (Promise) — це обʼєкт, що представляє результат асинхронної операції, який ще, можливо, не готовий. Це «обіцянка» дати значення пізніше: або успішне, або помилку.

    Проміс має рівно три стани:

    СтанОпис
    pending (очікування)Операція ще триває, результату немає
    fulfilled (виконано)Операція успішна, є значення
    rejected (відхилено)Операція провалилась, є причина (помилка)

    Ключові властивості, які треба розуміти:

    • Проміс починає життя в стані pending.
    • Він переходить у fulfilled або rejected лише один раз. Після цього стан і значення незмінні — проміс завершився (settled). Другий виклик resolve/reject ігнорується.
    • Реакції, передані в .then/.catch/.finally, виконуються як мікрозадачі.

    resolve

    reject

    pending
    очікування

    fulfilled
    є значення

    rejected
    є помилка, причина

    settled
    стан і значення незмінні

    resolve

    reject

    pending
    очікування

    fulfilled
    є значення

    rejected
    є помилка, причина

    settled
    стан і значення незмінні

    function getUser(id) {
      return new Promise((resolve, reject) => {
        if (id <= 0) {
          reject(new Error('Некоректний id'));
          return;
        }
        // імітація асинхронної відповіді
        setTimeout(() => resolve({ id, name: 'Ada' }), 100);
      });
    }
    
    getUser(1)
      .then(user => console.log('Отримали:', user.name))
      .catch(err => console.error('Помилка:', err.message))
      .finally(() => console.log('Запит завершено'));

    Проміси розплутують callback hell, бо ланцюжок .then — це плаский список кроків, а не вкладеність, і єдиний .catch наприкінці ловить помилку з будь-якого кроку. Додатково є комбінатори: Promise.all (чекати всі, впасти на першій помилці), Promise.allSettled (дочекатися всіх незалежно від результату), Promise.race (перший, що завершився — байдуже, успіхом чи помилкою), Promise.any (перший успішний; відхилення ігноруються, доки не впадуть усі).

    async/await

    async/await — це синтаксичний цукор над промісами, який дозволяє писати асинхронний код так, ніби він синхронний.

    • Функція, позначена async, завжди повертає проміс.
    • await перед промісом «ставить на паузу» виконання цієї функції, доки проміс не завершиться, і повертає його значення (або кидає помилку, якщо rejected).
    async function loadOrders() {
      try {
        const user = await getUser(1);
        const orders = await getOrders(user.id);
        const details = await getOrderDetails(orders[0].id);
        return details;
      } catch (err) {
        console.error('Щось пішло не так:', err.message);
      }
    }

    Порівняй цей плаский, читабельний варіант із callback hell вище — це той самий ланцюжок, але виражений лінійно, з нормальним try/catch для помилок.

    Важлива деталь про механіку: await не блокує потік. Коли виконання доходить до await, async-функція «відкладається», а потік звільняється і виконує іншу роботу. Продовження функції після await ставиться в чергу мікрозадач і виконається, коли проміс завершиться. Тобто await — це пауза для однієї функції, а не заморозка всього браузера.

    Блокування головного потоку і наслідки

    Оскільки потік один, будь-який довгий синхронний код блокує все: обробку кліків, анімації, перемальовування сторінки, виконання відкладених колбеків.

    // Заморозить вкладку на кілька секунд
    const end = Date.now() + 3000;
    while (Date.now() < end) {
      // порожній цикл — потік зайнятий, стек не порожніє
    }

    Поки цей цикл крутиться, стек не порожній, тож event loop не може взяти жодної задачі з черги. Наслідки для користувача: сторінка «зависла», кнопки не реагують, анімації стоять, вкладка може показати «сторінка не відповідає».

    Типові причини блокування в реальному коді: важкі обчислення в циклі, синхронний парсинг великого JSON, синхронні операції над DOM у циклі. Рішення — розбивати роботу на частини (наприклад, через setTimeout/requestAnimationFrame, щоб віддати потік між шматками) або виносити обчислення у Web Worker (окремий потік без доступу до DOM).

    Для тестувальника це має пряме значення. Якщо застосунок блокує потік, автотест може зафіксувати «неклікабельний» елемент, таймаут очікування або нестабільну поведінку — не тому, що елемента немає, а тому, що потік був зайнятий і не встиг обробити подію чи домалювати.

    Автотест і застосунок працюють асинхронно й незалежно. Тест виконує команди швидко й послідовно, а браузер робить свою роботу (запити, рендер, реакції на події) у власному темпі через event loop. Між «тест наказав» і «браузер зробив» завжди є проміжок часу.

    Очікування (wait) — це синхронізація тесту з реальним станом сторінки. Тест має дочекатися, поки настане потрібна умова, і лише тоді діяти чи перевіряти.

    Наївний тест ігнорує цей проміжок:

    // Крихко: клік може статись до того, як кнопка зʼявилась/увімкнулась
    await page.click('#submit');
    expect(await page.textContent('#status')).toBe('Готово');

    Тут дві потенційні гонки: кнопки ще може не бути в DOM (її додає асинхронний код), а статус #status оновлюється після мережевого запиту, який ще триває.

    Сучасні інструменти (наприклад, Playwright) вбудовують автоочікування (auto-waiting): перед дією локатор чекає, поки елемент зʼявиться, стане видимим, стабільним і придатним для взаємодії. Це працює саме тому, що інструмент моделює асинхронну природу сторінки замість того, щоб діяти наосліп. Але автоочікування не всесильне — умови на кшталт «дані в таблиці оновилися» треба виражати явними очікуваннями стану, а не самого елемента.

    Race condition на конкретному прикладі

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

    Класичний приклад — живий пошук (autocomplete). Користувач швидко набирає ab, потім abc. Летять два запити. Логічно очікувати, що на екрані будуть результати для abc. Але:

    let latestResults = [];
    
    input.addEventListener('input', async (e) => {
      const query = e.target.value;
      const results = await fetchSearch(query); // час відповіді непередбачуваний
      latestResults = results;
      render(results);
    });

    Якщо відповідь на ab прийде пізніше, ніж на abc (мережа непередбачувана, запити можуть завершитись у будь-якому порядку), то render(results) для ab виконається останнім і перезапише правильні результати для abc. Користувач набрав abc, а бачить результати для ab. Це і є гонка: «останній, що відповів, перемагає», хоча має перемагати «останній, що надіслали».

    СерверКлієнтСерверКлієнтrender(abc) — правильноrender(ab) перезаписує abcзапит "ab"запит "abc"відповідь "abc" (швидша)відповідь "ab" (пізніша)СерверКлієнтСерверКлієнтrender(abc) — правильноrender(ab) перезаписує abcзапит "ab"запит "abc"відповідь "abc" (швидша)відповідь "ab" (пізніша)

    Виправлення — відстежувати актуальність запиту (наприклад, скасовувати попередній через AbortController або ігнорувати відповіді, чий запит уже застарів):

    let requestId = 0;
    
    input.addEventListener('input', async (e) => {
      const current = ++requestId;
      const results = await fetchSearch(e.target.value);
      if (current !== requestId) return; // прийшла відповідь на застарілий запит — ігноруємо
      render(results);
    });

    Гонки — головна причина флаку (flakiness) в автотестах. Тест, який не синхронізується зі станом, фактично сам створює гонку: іноді браузер устигає, іноді ні. Звідси «то зелений, то червоний» без змін у коді. Розуміння event loop дозволяє бачити ці гонки й закривати їх правильними очікуваннями, а не випадковістю.

    Чому sleep(3000) — поганий спосіб чекати

    Спокуса «полагодити» флак додаванням sleep(3000) (жорсткої паузи на фіксований час) велика, але це антипатерн. Причини:

    • Час непередбачуваний. Реальна затримка залежить від мережі, завантаженості CI, потужності машини. 3000 мс, що працює локально, може не вистачити на повільному раннері — тест знову флакне. А те, що вистачає, майже завжди більше, ніж треба.
    • Тести стають повільними. Кожен фіксований sleep додається до загального часу. Сотні тестів × секунди «про запас» — це десятки зайвих хвилин на прогін. Ти платиш максимальний час навіть тоді, коли сторінка готова за 50 мс.
    • Не усуває гонку, а маскує її. sleep не перевіряє стан — він просто сподівається, що за цей час усе встигне. Умова, від якої залежить тест, лишається невираженою. Це прихована крихкість, яка вистрелить у найгірший момент.

    Правильний підхід — очікування за умовою (conditional wait): чекати рівно доти, доки настане потрібний стан, і не довше.

    // Погано: фіксована пауза наосліп
    await page.waitForTimeout(3000);
    await expect(page.locator('#status')).toHaveText('Готово');
    
    // Добре: чекаємо саме потрібний стан, з розумним таймаутом
    await expect(page.locator('#status')).toHaveText('Готово', { timeout: 10000 });

    Різниця принципова. Очікування за умовою завершується щойно умова виконана (швидко, коли все гаразд), і падає з осмисленою помилкою, якщо умова так і не настала за розумний максимальний таймаут. Таймаут тут — це стеля терпіння, а не фіксований простій. Так тести лишаються і швидкими, і стабільними.

    Рідкісний виняток, де коротка фіксована пауза може бути виправдана, — коли немає жодного спостережуваного стану, за яким можна чекати (наприклад, дебаунс-анімація без будь-якого сигналу завершення). Але навіть тоді краще знайти сигнал: перехопити мережевий запит (page.route/waitForResponse), дочекатися зникнення спінера чи появи класу-маркера. Майже завжди спостережуваний стан існує — його треба лише знайти й виразити.

    Практичний підсумок для AQA

    Модель виконання JS — не абстрактна теорія, а щоденний інструмент діагностики:

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

    Хто розуміє call stack, черги й event loop, той не ліпить sleep навмання, а бачить конкретну асинхронну операцію, якої треба дочекатися, і виражає це очікування точно.