Урок 19 из 49 · Месяц 3. JavaScript, часть 2

Асинхронность: Event Loop, Promise и 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 (цикл событий) Следит: если стек пуст — берёт следующую задачу из очереди Администратор зала

Пошагово для примера с кофе:

  1. console.log("1...") попадает в стек, выполняется и уходит.
  2. setTimeout передаёт таймер браузеру (Web API) и сразу уходит из стека.
  3. console.log("2...") выполняется.
  4. Через 2 секунды браузер кладёт колбэк в очередь задач.
  5. Event Loop видит: стек пуст, в очереди есть задача → отправляет колбэк в стек.
  6. Выполняется 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.

Практика

  1. Порядок вывода. Не запуская код, запишите, в каком порядке появятся буквы, а затем проверьте в консоли:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

Ожидаемый ответ: A, D, C, B. Объясните почему.

  1. Свой промис. Напишите функцию rollDice(), которая через 1 секунду выполняет промис со случайным числом от 1 до 6, но если выпало 1 — отклоняет его с ошибкой «Неудача». Обработайте оба варианта через .then и .catch.
  2. Цепочка. С помощью wait выведите в консоль «Раз», через секунду «Два», ещё через секунду «Три» — цепочкой .then, без вложенности.
  3. Promise.all. Создайте три промиса с разными задержками (500, 1500, 1000 мс), которые возвращают названия фруктов. Через Promise.all выведите массив и замерьте общее время: должно быть около 1500 мс.
  4. 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 ждёт все.
Отзыв