JavaScript — асинхронность и Event Loop

Асинхронность позволяет запустить операцию сейчас и обработать её результат позже, не блокируя ожиданием основной поток JavaScript. Так работают таймеры, сетевые запросы, события и Web Workers.

console.log("Начало");
setTimeout(() => console.log("Таймер"), 0);
console.log("Конец");

Результат:

Начало
Конец
Таймер

Event Loop

JavaScript-код обычно выполняется в одном основном потоке. Event Loop, или цикл событий, координирует стек вызовов и очереди задач.

  1. Call Stack — стек выполняемых функций.
  2. Web APIs — предоставляемые браузером таймеры, fetch, события и другие API.
  3. Очередь микрозадач — обработчики Promise, продолжение после await, queueMicrotask().
  4. Очередь задач, часто называемая очередью макрозадач, — таймеры, события интерфейса и сообщения Worker.

После завершения текущей задачи среда очищает очередь микрозадач, а затем переходит к следующей задаче.

Microtasks и macrotasks

К микрозадачам относятся:

К макрозадачам обычно относят:

console.log("1");

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

console.log("5");

Результат:

1
5
3
4
2

Сначала выполняется синхронный код, затем микрозадачи в порядке добавления, после них — callback таймера.

setTimeout(..., 0)

Нулевая задержка не означает немедленный вызов. Callback выполняется не раньше, чем завершатся текущий код и микрозадачи. Если поток занят, фактическая задержка будет больше.

setTimeout(() => console.log("Позже"), 0);
console.log("Сейчас");

Блокировка основного потока

const start = Date.now();

while (Date.now() - start < 3000) {
  // Основной поток занят
}

Пока синхронный цикл работает, браузер не может нормально обрабатывать клики, таймеры и отрисовку. Тяжёлые вычисления следует оптимизировать, разбивать на части или переносить в Web Worker.


Callbacks и callback hell

Callback — функция, переданная другой функции для вызова позже.

setTimeout(() => {
  console.log("Прошла секунда");
}, 1000);

В callback API часто используется соглашение: первый аргумент — ошибка, второй — результат.

loadData((error, data) => {
  if (error) {
    console.error(error);
    return;
  }

  console.log(data);
});

Callback hell

Зависимые асинхронные действия создают глубокую вложенность:

getUser(userId, (userError, user) => {
  if (userError) return handleError(userError);

  getOrders(user.id, (ordersError, orders) => {
    if (ordersError) return handleError(ordersError);

    getDetails(orders[0].id, (detailsError, details) => {
      if (detailsError) return handleError(detailsError);
      console.log(details);
    });
  });
});

Такой код сложно читать, расширять и централизованно обрабатывать. Именованные callbacks уменьшают вложенность, но для современной асинхронной логики обычно удобнее Promise и async/await.


Promise

Promise представляет будущий результат операции.

Состояние Описание
pending Операция ещё выполняется
fulfilled Операция успешно завершена
rejected Операция завершилась ошибкой

После перехода в fulfilled или rejected состояние Promise больше не меняется.

Создание Promise

const promise = new Promise((resolve, reject) => {
  const success = true;

  if (success) {
    resolve("Готово");
  } else {
    reject(new Error("Сбой"));
  }
});

Executor конструктора запускается синхронно, а callbacks Promise — как микрозадачи.

console.log("1");

const promise = new Promise((resolve) => {
  console.log("2");
  resolve();
});

promise.then(() => console.log("4"));
console.log("3");

then()

then() обрабатывает успешный результат и возвращает новый Promise.

Promise.resolve(5)
  .then((value) => value * 2)
  .then((value) => value + 1)
  .then(console.log); // 11

Если обработчик возвращает Promise, цепочка ждёт его завершения:

getUser()
  .then((user) => getOrders(user.id))
  .then((orders) => console.log(orders));

Важно вернуть вложенный Promise:

// Ошибка: следующий then получит undefined
getUser().then((user) => {
  getOrders(user.id);
});

// Правильно
getUser().then((user) => {
  return getOrders(user.id);
});

catch()

catch() обрабатывает отклонение Promise и исключения из предыдущих обработчиков.

loadData()
  .then((data) => transformData(data))
  .then((result) => render(result))
  .catch((error) => {
    console.error("Ошибка:", error);
  });

Если восстановление невозможно, ошибку следует пробросить:

promise.catch((error) => {
  console.error(error);
  throw error;
});

finally()

finally() выполняется независимо от результата. Он подходит для очистки ресурсов и восстановления интерфейса.

showLoader();

loadData()
  .then(render)
  .catch(showError)
  .finally(hideLoader);

Обычный callback finally() не получает результат или ошибку и сохраняет прежний результат цепочки. Если он выбросит ошибку, цепочка завершится этой новой ошибкой.

Promise.all()

Ожидает успешного завершения всех операций и возвращает результаты в исходном порядке.

const [user, products] = await Promise.all([
  loadUser(),
  loadProducts(),
]);

Если один Promise отклонится, Promise.all() тоже отклонится. Остальные запущенные операции автоматически не отменяются.

Promise.race()

Завершается результатом первой завершившейся операции — успешным или ошибочным.

const timeout = new Promise((_, reject) => {
  setTimeout(() => reject(new Error("Тайм-аут")), 5000);
});

const response = await Promise.race([
  fetch("/api/data"),
  timeout,
]);

Promise.race() не отменяет проигравшие операции. Для отмены fetch() применяется AbortController.

Promise.allSettled()

Ожидает все операции, даже если некоторые завершились ошибкой.

const results = await Promise.allSettled([
  Promise.resolve("A"),
  Promise.reject(new Error("Сбой")),
]);

for (const result of results) {
  if (result.status === "fulfilled") {
    console.log(result.value);
  } else {
    console.error(result.reason);
  }
}
Метод Поведение
Promise.all() Все должны завершиться успешно; иначе общий Promise отклоняется
Promise.race() Возвращает результат первой завершившейся операции
Promise.allSettled() Ждёт все операции и возвращает их статусы

async и await

Функция с async всегда возвращает Promise. Обычное возвращённое значение становится успешным результатом, а исключение — отклонением.

async function getNumber() {
  return 42;
}

getNumber().then(console.log);

await приостанавливает конкретную async-функцию до завершения Promise, но не блокирует основной поток.

async function loadUser() {
  const response = await fetch("/api/user");
  return response.json();
}

Продолжение после await выполняется как микрозадача:

async function example() {
  console.log("2");
  await Promise.resolve();
  console.log("4");
}

console.log("1");
example();
console.log("3");

Результат: 1, 2, 3, 4.

Последовательное и параллельное выполнение

Последовательно, если вторая операция зависит от первой:

const user = await loadUser();
const orders = await loadOrders(user.id);

Параллельно, если операции независимы:

const [categories, products] = await Promise.all([
  loadCategories(),
  loadProducts(),
]);

await в циклах

Последовательная обработка:

for (const id of userIds) {
  const user = await loadUser(id);
  console.log(user);
}

Параллельная обработка:

const users = await Promise.all(
  userIds.map((id) => loadUser(id))
);

forEach() не ожидает асинхронный callback:

// Внешний код не ждёт завершения операций
userIds.forEach(async (id) => {
  await loadUser(id);
});

При большом количестве задач может понадобиться ограничение числа одновременно выполняемых операций.


Fetch API

fetch() выполняет HTTP-запрос и возвращает Promise с объектом Response.

async function loadUsers() {
  const response = await fetch("/api/users");

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

Проверка HTTP-статуса

Ответы 404 и 500 обычно не отклоняют Promise fetch(). Ошибка Promise возникает, например, при сетевом сбое или отмене. Поэтому response.ok нужно проверять явно.

Полезные свойства:

Свойство Назначение
ok true для статусов 200–299
status Числовой HTTP-статус
statusText Текст статуса
headers Заголовки ответа
url Итоговый URL

Чтение тела

const data = await response.json();
const text = await response.text();
const blob = await response.blob();
const buffer = await response.arrayBuffer();

Тело ответа обычно можно прочитать только один раз.

POST-запрос с JSON

async function createUser(user) {
  const response = await fetch("/api/users", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(user),
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

Отмена запроса

const controller = new AbortController();

const request = fetch("/api/data", {
  signal: controller.signal,
});

controller.abort();

try {
  await request;
} catch (error) {
  if (error.name === "AbortError") {
    console.log("Запрос отменён");
  } else {
    throw error;
  }
}

Тайм-аут запроса:

async function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    return await fetch(url, {
      ...options,
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timeoutId);
  }
}

Ошибки в асинхронном коде

С async/await применяется обычный try/catch:

async function showUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const user = await response.json();
    renderUser(user);
  } catch (error) {
    console.error(error);
    renderError("Не удалось загрузить пользователя");
  } finally {
    hideLoader();
  }
}

Внешний try/catch не ловит ошибку, которая возникла позже в callback:

try {
  setTimeout(() => {
    throw new Error("Сбой");
  }, 0);
} catch (error) {
  // Не выполнится
}

Обрабатывать её нужно внутри callback либо через Promise.

setTimeout(() => {
  try {
    riskyOperation();
  } catch (error) {
    console.error(error);
  }
}, 0);

Не оставляйте отклонённый Promise без обработки:

loadData().catch(console.error);

Если ошибка не может быть исправлена на текущем уровне, добавьте контекст и пробросьте её выше:

async function loadSettings() {
  try {
    return await requestJson("/api/settings");
  } catch (error) {
    throw new Error("Не удалось загрузить настройки", {
      cause: error,
    });
  }
}

Таймеры

setTimeout()

Однократно вызывает callback после минимальной задержки:

const timeoutId = setTimeout(() => {
  console.log("Готово");
}, 1000);

clearTimeout(timeoutId);

setInterval()

Периодически вызывает callback:

const intervalId = setInterval(() => {
  console.log(new Date().toLocaleTimeString());
}, 1000);

clearInterval(intervalId);

Задержка не гарантирует точного времени: callback ждёт освобождения основного потока. В фоновых вкладках браузер может дополнительно ограничивать частоту таймеров.

Перекрытие интервалов

setInterval(async () => {
  await loadUpdates();
}, 1000);

Если запрос выполняется дольше секунды, запуски могут перекрываться. Для опроса сервера часто лучше рекурсивный setTimeout():

let stopped = false;

async function poll() {
  if (stopped) return;

  try {
    const data = await loadUpdates();
    renderUpdates(data);
  } catch (error) {
    console.error(error);
  } finally {
    if (!stopped) setTimeout(poll, 5000);
  }
}

poll();
// stopped = true — остановка

Следующий запуск планируется после завершения предыдущего. Ненужные таймеры следует очищать.


Web Workers

Web Worker выполняет JavaScript в отдельном потоке и подходит для тяжёлых вычислений. Он не может напрямую работать с DOM.

Основной файл:

const worker = new Worker("worker.js");

worker.postMessage({ numbers: [10, 20, 30] });

worker.addEventListener("message", (event) => {
  console.log("Результат:", event.data);
});

worker.addEventListener("error", (event) => {
  console.error(event.message);
});

Файл worker.js:

self.addEventListener("message", (event) => {
  const result = event.data.numbers.reduce(
    (sum, number) => sum + number,
    0
  );

  self.postMessage(result);
});

Остановка:

worker.terminate(); // из основного потока
self.close();       // внутри Worker

Worker как ES-модуль:

const worker = new Worker("worker.js", {
  type: "module",
});

Данные между потоками обычно передаются структурированным клонированием. Функции и DOM-элементы передать нельзя. ArrayBuffer и некоторые другие transferable-объекты можно передать без копирования.

Worker имеет накладные расходы, поэтому он полезен для вычислительно тяжёлых задач, а не для каждого асинхронного действия. Ожидание обычного fetch() само по себе не требует Worker.


Частые ошибки


Общий пример

class HttpError extends Error {
  constructor(response, body = "") {
    super(`HTTP ${response.status}: ${response.statusText}`);
    this.name = "HttpError";
    this.status = response.status;
    this.body = body;
  }
}

async function requestJson(url, options = {}, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
      headers: {
        Accept: "application/json",
        ...options.headers,
      },
    });

    if (!response.ok) {
      const body = await response.text();
      throw new HttpError(response, body);
    }

    if (response.status === 204) return null;
    return await response.json();
  } finally {
    clearTimeout(timeoutId);
  }
}

async function loadDashboard(userId) {
  showLoader();

  try {
    const [user, orders] = await Promise.all([
      requestJson(`/api/users/${userId}`),
      requestJson(`/api/users/${userId}/orders`),
    ]);

    renderDashboard({ user, orders });
  } catch (error) {
    if (error.name === "AbortError") {
      showError("Сервер не ответил вовремя");
    } else if (error instanceof HttpError && error.status === 404) {
      showError("Пользователь не найден");
    } else {
      console.error("Ошибка панели:", error);
      showError("Не удалось загрузить данные");
    }
  } finally {
    hideLoader();
  }
}

loadDashboard(10).catch(console.error);

В примере Promise.all() запускает независимые запросы параллельно, AbortController ограничивает время ожидания, response.ok проверяет HTTP-статус, try/catch обрабатывает ошибки, а finally всегда очищает таймер и скрывает индикатор загрузки.


Краткие рекомендации