JavaScript — асинхронность и Event Loop
Асинхронность позволяет запустить операцию сейчас и обработать её результат позже, не блокируя ожиданием основной поток JavaScript. Так работают таймеры, сетевые запросы, события и Web Workers.
console.log("Начало");
setTimeout(() => console.log("Таймер"), 0);
console.log("Конец");Результат:
Начало
Конец
ТаймерEvent Loop
JavaScript-код обычно выполняется в одном основном потоке. Event Loop, или цикл событий, координирует стек вызовов и очереди задач.
- Call Stack — стек выполняемых функций.
- Web APIs — предоставляемые браузером таймеры,
fetch, события и другие API. - Очередь микрозадач — обработчики Promise, продолжение после
await,queueMicrotask(). - Очередь задач, часто называемая очередью макрозадач, — таймеры, события интерфейса и сообщения Worker.
После завершения текущей задачи среда очищает очередь микрозадач, а затем переходит к следующей задаче.
Microtasks и macrotasks
К микрозадачам относятся:
- callbacks
then(),catch()иfinally(); - продолжение
async-функции послеawait; - callbacks
queueMicrotask(); MutationObserverв браузере.
К макрозадачам обычно относят:
- первоначальный запуск скрипта;
- callbacks
setTimeout()иsetInterval(); - события пользовательского интерфейса;
- сообщения от Web Workers.
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(); // внутри WorkerWorker как ES-модуль:
const worker = new Worker("worker.js", {
type: "module",
});Данные между потоками обычно передаются структурированным клонированием. Функции и DOM-элементы передать нельзя. ArrayBuffer и некоторые другие transferable-объекты можно передать без копирования.
Worker имеет накладные расходы, поэтому он полезен для вычислительно тяжёлых задач, а не для каждого асинхронного действия. Ожидание обычного fetch() само по себе не требует Worker.
Частые ошибки
- ожидание немедленного выполнения
setTimeout(..., 0); - отсутствие
returnу Promise внутриthen(); - отсутствие проверки
response.okпослеfetch(); - последовательный запуск независимых запросов вместо
Promise.all(); - использование
awaitвнутриforEach(); - пустой
catch, скрывающий причину ошибки; - создание нового Promise вокруг API, которое уже возвращает Promise;
- запуск слишком большого числа операций одновременно;
- использование
setInterval()без учёта перекрытия асинхронных вызовов; - отсутствие очистки таймеров и отмены ненужных запросов.
Общий пример
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 всегда очищает таймер и скрывает индикатор загрузки.
Краткие рекомендации
- Используйте
async/awaitдля читаемой последовательной логики. - Применяйте
Promise.all()для независимых операций, которые должны завершиться успешно вместе. - Выбирайте
Promise.allSettled(), когда нужны результаты всех операций, включая неудачные. - Проверяйте
response.okпослеfetch(). - Обрабатывайте каждый Promise через
catch()илиtry/catchна подходящем уровне. - Используйте
finallyдля очистки ресурсов и восстановления интерфейса. - Для отмены
fetch()применяйтеAbortController. - Для последовательного асинхронного цикла используйте
for...of, для параллельного —map()сPromise.all(). - Очищайте таймеры и учитывайте риск перекрытия
setInterval(). - Переносите тяжёлые вычисления в Web Worker, если они блокируют интерфейс.
- Помните: после текущего кода микрозадачи выполняются раньше следующей макрозадачи.