Урок 19 из 49 · Месяц 3. JavaScript, часть 2
Асинхронность: Event Loop, Promise и Promise.all
Содержание урока
- Синхронный и асинхронный код
- Синхронный код
- Проблема: долгие операции
- Асинхронный код
- Event Loop — как это работает
- Микрозадачи и макрозадачи
- Колбэки и «ад колбэков»
- Что такое Promise
- Создаём свой промис
- Получаем результат: then, catch, finally
- Функция, которая возвращает промис
- Цепочки промисов
- Promise.all — несколько промисов сразу
- Если один промис упал
- Родственники Promise.all
- Типичные ошибки
- Практика
- Итоги
До сих пор наш код выполнялся строго сверху вниз: строка за строкой. Но в реальных сайтах многое занимает время — загрузка данных с сервера, таймеры, ожидание клика. В этом уроке разберёмся, что такое асинхронность, как браузер успевает делать много дел одновременно с помощью Event Loop, и познакомимся с Promise — главным инструментом для работы с «отложенными» результатами, включая Promise.all().
Синхронный и асинхронный код
Синхронный код
Синхронный код выполняется по очереди: следующая строка ждёт, пока закончится предыдущая.
console.log("1. Встали");
console.log("2. Почистили зубы");
console.log("3. Позавтракали");
Результат всегда в том же порядке: 1, 2, 3.
Проблема: долгие операции
JavaScript в браузере однопоточный — у него один «исполнитель», который в каждый момент делает только одно дело. Представьте, что запрос на сервер занимает 3 секунды. Если бы JavaScript просто стоял и ждал, вся страница «замерзла» бы: кнопки не нажимаются, прокрутка не работает.
Проверим, как выглядит блокировка:
console.log("Начало");
const start = Date.now();
while (Date.now() - start < 3000) {
// ничего не делаем 3 секунды — страница зависла
}
console.log("Конец");
Пока работает этот цикл, страница не реагирует ни на что. Так делать нельзя.
Асинхронный код
Асинхронный код говорит: «Запусти это дело, а когда будет готово — позови меня. А я пока займусь другим».
Аналогия — кафе. Вы делаете заказ, получаете номерок и садитесь за стол. Вы не стоите у кассы, пока готовят кофе, — вы общаетесь с друзьями. Когда кофе готов, вас вызывают по номеру.
console.log("1. Заказали кофе");
setTimeout(() => {
console.log("3. Кофе готов!");
}, 2000);
console.log("2. Болтаем с друзьями");
Результат:
1. Заказали кофе
2. Болтаем с друзьями
3. Кофе готов!
setTimeout(функция, задержка) откладывает выполнение функции на указанное число миллисекунд (2000 мс = 2 секунды). JavaScript не ждёт таймер, а сразу идёт дальше.
Event Loop — как это работает
Если JavaScript однопоточный, кто же считает эти 2 секунды? Ответ: браузер. У браузера есть свои помощники (Web API): таймеры, сетевые запросы, обработка событий. JavaScript передаёт им долгую работу, а сам продолжает выполнять код.
Чтобы всё это сработало вместе, есть четыре части:
| Часть | Что делает | Аналогия |
|---|---|---|
| Call Stack (стек вызовов) | Здесь выполняется текущий код, функция за функцией | Повар у плиты |
| Web API | Браузер выполняет долгие операции: таймеры, fetch, события |
Кухонные таймеры и курьеры |
| Очередь задач (task queue) | Готовые колбэки ждут своей очереди | Список готовых заказов |
| Event Loop (цикл событий) | Следит: если стек пуст — берёт следующую задачу из очереди | Администратор зала |
Пошагово для примера с кофе:
console.log("1...")попадает в стек, выполняется и уходит.setTimeoutпередаёт таймер браузеру (Web API) и сразу уходит из стека.console.log("2...")выполняется.- Через 2 секунды браузер кладёт колбэк в очередь задач.
- Event Loop видит: стек пуст, в очереди есть задача → отправляет колбэк в стек.
- Выполняется
console.log("3...").
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
// A, C, B
Микрозадачи и макрозадачи
Очередей на самом деле две:
- Макрозадачи —
setTimeout,setInterval, события (клики). - Микрозадачи — колбэки промисов (
.then,.catch,.finally) иqueueMicrotask.
Правило: после каждой макрозадачи Event Loop сначала выполняет все микрозадачи, а потом берёт следующую макрозадачу. Микрозадачи — как VIP-клиенты: их обслуживают без очереди.
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), потом все микрозадачи (3), потом макрозадача (2). Это классический вопрос на собеседованиях.
Колбэки и «ад колбэков»
Раньше асинхронность писали только через колбэки — функции, которые передаются, чтобы их вызвали позже (вспомните урок про функции и колбэки). Когда действий много и каждое зависит от предыдущего, код уходит «лесенкой» вправо:
setTimeout(() => {
console.log("Получили пользователя");
setTimeout(() => {
console.log("Получили его заказы");
setTimeout(() => {
console.log("Получили товары заказа");
}, 1000);
}, 1000);
}, 1000);
Это называют callback hell — «ад колбэков». Такой код трудно читать и ещё труднее обрабатывать в нём ошибки. Для решения этой проблемы и придумали Promise.
Что такое Promise
Promise (промис, «обещание») — это объект, который представляет результат, который будет получен позже. Как номерок в кафе: кофе ещё нет, но есть обещание, что он будет — или вам скажут, что молоко закончилось.
У промиса три состояния:
| Состояние | Значение |
|---|---|
pending (ожидание) |
Работа ещё идёт |
fulfilled (выполнен) |
Всё получилось, есть результат |
rejected (отклонён) |
Произошла ошибка |
Из pending промис переходит один раз либо в fulfilled, либо в rejected — и больше не меняется.
Создаём свой промис
const orderCoffee = new Promise((resolve, reject) => {
const hasMilk = true;
setTimeout(() => {
if (hasMilk) {
resolve("Ваш латте готов ☕");
} else {
reject(new Error("Молоко закончилось"));
}
}, 2000);
});
Разбор:
new Promise(...)принимает функцию-исполнитель с двумя параметрами.resolve(значение)— вызываем, когда всё получилось. Промис становитсяfulfilled.reject(ошибка)— вызываем при неудаче. Промис становитсяrejected. Ошибку принято передавать какnew Error("текст").
Получаем результат: then, catch, finally
orderCoffee
.then((result) => {
console.log(result); // "Ваш латте готов ☕"
})
.catch((error) => {
console.log("Ошибка:", error.message);
})
.finally(() => {
console.log("Уходим из кафе"); // выполнится в любом случае
});
.then(fn)— вызывается при успехе, получает значение изresolve..catch(fn)— вызывается при ошибке, получает значение изreject..finally(fn)— вызывается всегда, независимо от результата. Удобно, например, чтобы спрятать индикатор загрузки.
Функция, которая возвращает промис
На практике промис обычно создают внутри функции, чтобы запускать операцию когда нужно:
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, name: "Анна" });
} else {
reject(new Error("Неверный id"));
}
}, 1000);
});
}
getUser(1).then((user) => console.log(user.name)); // через 1 с: "Анна"
getUser(-1).catch((err) => console.log(err.message)); // через 1 с: "Неверный id"
wait(ms) — полезная маленькая функция: промис, который просто выполняется через указанное время.
Цепочки промисов
Главная сила промисов — цепочки. То, что вы возвращаете из .then, попадает в следующий .then. Если вернуть промис, следующий .then дождётся его.
function getOrders(user) {
return new Promise((resolve) => {
setTimeout(() => resolve(["Заказ №1", "Заказ №2"]), 1000);
});
}
getUser(1)
.then((user) => {
console.log("Пользователь:", user.name);
return getOrders(user); // возвращаем промис
})
.then((orders) => {
console.log("Заказы:", orders); // дождались заказов
return orders.length; // возвращаем обычное значение
})
.then((count) => {
console.log("Всего заказов:", count);
})
.catch((err) => {
console.log("Что-то пошло не так:", err.message);
});
Сравните с «адом колбэков»: код идёт ровно сверху вниз, а один .catch в конце ловит ошибку из любого шага.
Promise.all — несколько промисов сразу
Иногда нужно загрузить несколько независимых вещей: профиль, уведомления, настройки. Делать это по очереди — долго. Promise.all запускает всё параллельно и ждёт, пока выполнятся все.
const p1 = wait(1000).then(() => "Профиль");
const p2 = wait(2000).then(() => "Уведомления");
const p3 = wait(1500).then(() => "Настройки");
console.time("загрузка");
Promise.all([p1, p2, p3])
.then((results) => {
console.log(results); // ["Профиль", "Уведомления", "Настройки"]
console.timeEnd("загрузка"); // примерно 2000 мс, а не 4500
})
.catch((err) => {
console.log("Один из запросов упал:", err.message);
});
Разбор:
Promise.allпринимает массив промисов и возвращает один новый промис.- Результат — массив значений в том же порядке, что и промисы в массиве (даже если второй закончился последним).
- Общее время ≈ время самого долгого промиса (2 с), потому что все работают одновременно.
console.time/console.timeEnd— удобный способ замерить время.
Если один промис упал
Promise.all работает по принципу «все или ничего»: если хотя бы один промис отклонён, весь Promise.all сразу уходит в .catch.
Promise.all([getUser(1), getUser(-1)])
.then((users) => console.log(users)) // не выполнится
.catch((err) => console.log(err.message)); // "Неверный id"
Родственники Promise.all
| Метод | Когда выполняется | Результат |
|---|---|---|
Promise.all |
все успешны (или первая ошибка) | массив значений |
Promise.allSettled |
все завершились, неважно как | массив { status, value / reason } |
Promise.race |
первый завершившийся (успех или ошибка) | его результат |
Promise.any |
первый успешный | его значение |
Promise.allSettled([getUser(1), getUser(-1)]).then((results) => {
console.log(results);
// [
// { status: "fulfilled", value: { id: 1, name: "Анна" } },
// { status: "rejected", reason: Error: Неверный id }
// ]
});
allSettled удобен, когда нужно показать то, что загрузилось, даже если часть данных не пришла.
Подробнее — в документации MDN: Promise.
Типичные ошибки
- Ожидать результат синхронно.
const user = getUser(1); console.log(user.name)— не сработает:userздесь промис, а не объект. Результат доступен только в.then(или черезawait— о нём в уроке про fetch и async/await). - Нет
.catch. Ошибка в промисе без обработчика попадёт в консоль какUncaught (in promise). - Забытый
returnв цепочке.then. - Последовательная загрузка независимых данных вместо
Promise.all.
Практика
- Порядок вывода. Не запуская код, запишите, в каком порядке появятся буквы, а затем проверьте в консоли:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
Ожидаемый ответ: A, D, C, B. Объясните почему.
- Свой промис. Напишите функцию
rollDice(), которая через 1 секунду выполняет промис со случайным числом от 1 до 6, но если выпало 1 — отклоняет его с ошибкой «Неудача». Обработайте оба варианта через.thenи.catch. - Цепочка. С помощью
waitвыведите в консоль «Раз», через секунду «Два», ещё через секунду «Три» — цепочкой.then, без вложенности. - Promise.all. Создайте три промиса с разными задержками (500, 1500, 1000 мс), которые возвращают названия фруктов. Через
Promise.allвыведите массив и замерьте общее время: должно быть около 1500 мс. - allSettled. Запустите
getUser(1),getUser(2)иgetUser(-5)черезPromise.allSettledи выведите только имена успешно загруженных пользователей.
Итоги
- JavaScript однопоточный, а долгие операции выполняет браузер (Web API).
- Event Loop переносит готовые колбэки из очереди в стек, когда стек пуст.
- Микрозадачи (промисы) выполняются раньше макрозадач (
setTimeout). - Promise — объект с состояниями
pending,fulfilled,rejected. .then— успех,.catch— ошибка,.finally— в любом случае.Promise.allзапускает задачи параллельно и падает при первой ошибке;allSettledждёт все.