Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

JavaScript: асинхронність

20 питань · ~20 хв · Версія v3.0

Увійдіть, щоб продовжити

Проміси, async/await, цикл подій, мікро- й макрозадачі, паралельні запити й скасування через AbortController - питання всіх рівнів, від junior до senior.

За спробу
20
У пулі
63
Проходжень
0
Середній бал
-
Пройшли на 70%+
-

Питання для підготовки

15 питань

Promise - об'єкт-обіцянка результату, який з'явиться пізніше: відповідь сервера, прочитаний файл, таймер. Замість того щоб передавати колбек, функція одразу повертає Promise, а результат на нього «підписують».

Три стани:

  • pending - очікування, результату ще немає;
  • fulfilled - виконано успішно, є значення;
  • rejected - відхилено, є причина (зазвичай об'єкт Error).

Перехід з pending відбувається один раз і назавжди: виконаний Promise не стане відхиленим.

fetch('/api/user')
  .then((response) => response.json())
  .then((user) => console.log(user.name))
  .catch((error) => console.error('Не вдалося завантажити', error))
  .finally(() => hideSpinner());

Що варто знати:

  • then() повертає новий Promise, тому виклики можна з'єднувати в ланцюжок. Значення, повернене з then, стає результатом наступного кроку.
  • Один catch() наприкінці ловить помилку з будь-якого попереднього кроку.
  • Promise без обробника помилки, який відхилився, дає unhandledrejection - у Node.js це за замовчуванням завершує процес.
  • fetch відхиляє Promise лише при мережевій помилці. Відповідь 404 чи 500 - це виконаний Promise з response.ok === false.

Докладніше в документації: Promise

По суті нічим: async/await - зручніший запис тих самих Promise. Асинхронний код читається зверху вниз, як синхронний.

// then()
function loadUser(id) {
  return fetch(`/api/users/${id}`)
    .then((r) => r.json())
    .then((user) => user.name);
}

// async/await
async function loadUser(id) {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user.name;
}

Правила:

  • async-функція завжди повертає Promise. return 5 у ній - це Promise, що виконається з 5.
  • await чекає Promise і повертає його значення. Якщо Promise відхилено, await кидає виняток.
  • Помилки ловлять звичайним try/catch.
try {
  const user = await loadUser(1);
} catch (error) {
  showError(error.message);
}

Пастка - зайва послідовність:

const user = await loadUser();     // чекаємо
const posts = await loadPosts();   // і лише тоді починаємо друге

Якщо запити незалежні, їх запускають разом: const [user, posts] = await Promise.all([loadUser(), loadPosts()]);.

await на верхньому рівні (поза функцією) працює в ES-модулях.

Докладніше в документації: async function

JavaScript виконує код в одному потоці. Асинхронні дії (таймери, мережа, події) виконує середовище - браузер чи Node.js, а колбеки, що мають спрацювати після них, стають у черги. Event loop бере з черг наступне завдання, коли стек викликів порожній.

Дві черги з різним пріоритетом:

  • Макрозадачі (tasks): setTimeout, setInterval, події користувача, повідомлення з мережі.
  • Мікрозадачі (microtasks): колбеки Promise (then, продовження після await), queueMicrotask, MutationObserver.

Порядок: виконати одну макрозадачу → виконати всі мікрозадачі, що накопичилися (включно з тими, що додалися під час виконання) → (у браузері) відмалювати сторінку → наступна макрозадача.

console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 1, 4, 3, 2

setTimeout(fn, 0) не означає «одразу»: це макрозадача, і вона чекає, поки виконаються всі мікрозадачі.

Практичні наслідки:

  • Довгий синхронний код (великий цикл, важкий JSON.parse) блокує все: кліки, анімацію, рендер. Важку роботу ділять на частини або виносять у Web Worker.
  • Нескінченний ланцюжок мікрозадач теж заморожує сторінку: рендер чекає, поки черга мікрозадач спорожніє.

Докладніше в документації: Модель виконання JavaScript

Усі чотири приймають масив Promise і запускаються паралельно, але по-різному вирішують, коли й чим завершитися:

  • Promise.all - чекає всі. Результат - масив значень у тому ж порядку. Перша ж помилка відхиляє весь результат (інші Promise продовжують виконуватися, але їхні результати губляться).
  • Promise.allSettled - чекає всі, ніколи не відхиляється. Результат - масив об'єктів {status: 'fulfilled', value} або {status: 'rejected', reason}.
  • Promise.race - результат першого, що завершився, хай успіхом, хай помилкою.
  • Promise.any - перший успішний. Відхиляється з AggregateError, лише якщо відхилено всі.
// Усе потрібне для сторінки - інакше показати помилку
const [user, settings] = await Promise.all([getUser(), getSettings()]);

// Розіслати сповіщення й порахувати, скільки не дійшло
const results = await Promise.allSettled(recipients.map(send));
const failed = results.filter((r) => r.status === 'rejected').length;

// Тайм-аут
const data = await Promise.race([fetchData(), sleep(5000).then(() => { throw new Error('timeout'); })]);

// Перше доступне дзеркало
const file = await Promise.any(mirrors.map((url) => fetch(url)));

Нюанс race для тайм-ауту: «програлий» запит не скасовується, він продовжує виконуватися. Для справжнього скасування потрібен AbortController (у fetch - AbortSignal.timeout(5000)).

Докладніше в документації: Promise.all

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

const controller = new AbortController();

fetch('/api/search?q=laravel', { signal: controller.signal })
  .then((r) => r.json())
  .then(render)
  .catch((error) => {
    if (error.name === 'AbortError') return; // скасовано свідомо - не помилка
    showError(error);
  });

controller.abort(); // запит перервано, з'єднання закрито

Типові сценарії:

  • Пошук під час набору: кожна нова літера скасовує попередній запит, щоб старіша відповідь не перезаписала новішу (класична гонка).
  • Компонент зник (React-ефект, Vue onUnmounted) - скасувати його запити й підписки.
  • Тайм-аут: fetch(url, { signal: AbortSignal.timeout(5000) }).
  • Кілька причин скасування одразу: AbortSignal.any([userSignal, AbortSignal.timeout(5000)]).

Сигнал працює не лише з fetch: addEventListener(type, fn, { signal }) знімає обробник при скасуванні, а у власних функціях можна перевіряти signal.aborted чи слухати подію abort:

async function processAll(items, signal) {
  for (const item of items) {
    signal.throwIfAborted();
    await process(item);
  }
}

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

Докладніше в документації: AbortController

Прочитати - ще не значить знати

20 питань, по одному на екран, ~20 хв. Після завершення - розбір кожної помилки з посиланням на питання.