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

Middle: питання на співбесіді з теми «Асинхронність»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

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

Старі API (події, таймери, бібліотеки з колбеками) зручно «загорнути» в Promise, щоб використовувати з await. Це називають промісифікацією.

Класичний спосіб - конструктор new Promise:

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = () => reject(new Error(`Не вдалося завантажити ${src}`));
    image.src = src;
  });
}

await sleep(500);
const image = await loadImage('/logo.png');

Правила:

  • resolve і reject діють лише перший раз - повторні виклики ігноруються;
  • виняток, кинутий синхронно всередині функції-виконавця, автоматично відхиляє Promise;
  • не загортайте в new Promise те, що вже повертає Promise, - це зайвий шар і місце для втрачених помилок.

Promise.withResolvers() (ES2024) повертає Promise разом з його resolve і reject назовні:

const { promise, resolve, reject } = Promise.withResolvers();

Це зручно, коли виконання відбувається не в одному колбеку, а десь пізніше - наприклад, чекаємо на відповідь у WebSocket за ідентифікатором запиту:

const pending = new Map();

function request(socket, payload) {
  const id = crypto.randomUUID();
  const { promise, resolve, reject } = Promise.withResolvers();
  pending.set(id, { resolve, reject });
  socket.send(JSON.stringify({ id, ...payload }));
  return promise;
}

socket.onmessage = (event) => {
  const { id, result, error } = JSON.parse(event.data);
  const handlers = pending.get(id);
  pending.delete(id);
  error ? handlers?.reject(new Error(error)) : handlers?.resolve(result);
};

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

У Node.js для функцій зі стилем (error, result) є готовий util.promisify, а більшість модулів мають Promise-версії (node:fs/promises).

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

for await...of перебирає послідовність значень, що надходять асинхронно: кожен крок циклу чекає на наступне значення.

Асинхронний генератор - найпростіший спосіб таку послідовність створити. Наприклад, обхід API з пагінацією:

async function* fetchAllPages(url) {
  let next = url;
  while (next) {
    const response = await fetch(next);
    const page = await response.json();
    yield* page.data;          // віддати елементи сторінки по одному
    next = page.links.next;    // колекція API Resource у Laravel кладе сюди URL наступної сторінки
  }
}

for await (const user of fetchAllPages('/api/users')) {
  console.log(user.name);
  if (user.id === 42) break;   // решта сторінок навіть не буде завантажена
}

Переваги перед «завантажити все в масив»:

  • лінивість - наступна сторінка запитується лише тоді, коли попередню оброблено;
  • пам'ять - не треба тримати весь набір одночасно;
  • break чи return з циклу зупиняє генератор, і зайві запити не робляться.

Де ще зустрічається:

  • потоки даних: ReadableStream у Node.js, Chrome і Firefox можна перебирати через for await (Safari поки ні - там читають через getReader()):
const response = await fetch('/export.csv');
const decoder = new TextDecoder();
for await (const chunk of response.body) {
  process(decoder.decode(chunk, { stream: true }));
}
  • читання файлів построково в Node.js (readline), події, черги повідомлень.

for await над масивом Promise теж працює, але виконує їх у порядку масиву, а не в порядку завершення, і помилки обробляє незручно: for await (const r of [p1, p2, p3]). Якщо Promise вже запущені, краще Promise.all чи Promise.allSettled.

Array.fromAsync() (ES2024) збирає асинхронну послідовність у масив: const users = await Array.fromAsync(fetchAllPages('/api/users'));.

Докладніше в документації: for await...of