js

JavaScript async/await и промисы: полное руководство с примерами

Асинхронный код в JavaScript поначалу кажется сложным: колбэки нарастают лесенкой, цепочки промисов запутываются, ошибки теряются где-то внутри. В этой статье разберём промисы и async/await от основ до продвинутых приёмов — с рабочими примерами, которые можно сразу применять в проектах.

Что такое промис и зачем он нужен

Промис (Promise) — это объект, который представляет результат асинхронной операции: он либо выполнится успешно (fulfilled), либо завершится ошибкой (rejected), либо ещё ждёт результата (pending).

Без промисов асинхронный код выглядел так — «callback hell»:

// Так делать не надо — callback hell
getUser(id, function(user) {
  getPosts(user.id, function(posts) {
    getComments(posts[0].id, function(comments) {
      // ещё один уровень...
    }, function(err) { console.error(err); });
  }, function(err) { console.error(err); });
}, function(err) { console.error(err); });

Промисы решают эту проблему — асинхронные операции выстраиваются в читаемую цепочку:

// Создание промиса вручную
const delay = (ms) => new Promise((resolve, reject) => {
  if (ms < 0) {
    reject(new Error('Задержка не может быть отрицательной'));
    return;
  }
  setTimeout(() => resolve(`Прошло ${ms}мс`), ms);
});

// Использование
delay(1000)
  .then(result => console.log(result)) // "Прошло 1000мс"
  .catch(err => console.error(err.message));

Конструктор new Promise(executor) принимает функцию с двумя параметрами: resolve (вызывается при успехе) и reject (при ошибке). После вызова одного из них состояние промиса фиксируется навсегда.


Цепочки промисов: .then(), .catch(), .finally()

Каждый .then() возвращает новый промис, что позволяет строить цепочки. Если внутри .then() вернуть значение — следующий .then() получит его. Если вернуть промис — цепочка дождётся его выполнения.

// Цепочка промисов для работы с API
fetch('https://api.example.com/users/1')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP ошибка: ${response.status}`);
    }
    return response.json(); // возвращаем новый промис
  })
  .then(user => {
    console.log('Пользователь:', user.name);
    return fetch(`https://api.example.com/posts?userId=${user.id}`);
  })
  .then(response => response.json())
  .then(posts => {
    console.log(`Найдено постов: ${posts.length}`);
  })
  .catch(err => {
    // Ловит ошибки из ЛЮБОГО шага цепочки
    console.error('Что-то пошло не так:', err.message);
  })
  .finally(() => {
    // Выполняется всегда — и при успехе, и при ошибке
    console.log('Запрос завершён');
  });

Важное правило: .catch() в конце цепочки перехватывает ошибки из всех предшествующих шагов. Не забывайте его добавлять — необработанные ошибки промисов в Node.js приводят к падению процесса.


async/await — синтаксический сахар над промисами

async/await появился в ES2017 и делает асинхронный код похожим на синхронный. Под капотом это те же промисы, просто записанные иначе.

// Функция с async всегда возвращает промис
async function getUser(id) {
  // await «ждёт» выполнения промиса
  const response = await fetch(`https://api.example.com/users/${id}`);

  if (!response.ok) {
    throw new Error(`Пользователь ${id} не найден`);
  }

  return response.json(); // автоматически оборачивается в Promise.resolve()
}

// Вызов async-функции
async function main() {
  const user = await getUser(1);
  console.log(user.name);
}

main().catch(console.error);

await работает только внутри async-функции (или на верхнем уровне модуля в современных окружениях). Попытка использовать await вне async-функции — синтаксическая ошибка.

Обработка ошибок в async/await

Для обработки ошибок используется привычный try/catch:

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

    if (!response.ok) {
      // throw внутри async-функции — это rejected Promise
      throw new Error(`Сервер вернул ${response.status}`);
    }

    const user = await response.json();
    const posts = await fetch(`/api/posts?author=${user.id}`).then(r => r.json());

    return { user, posts };

  } catch (err) {
    // Обрабатываем и сетевые ошибки, и ошибки парсинга, и наши throw
    console.error('Ошибка загрузки:', err.message);

    // Можно пробросить дальше или вернуть fallback
    return { user: null, posts: [] };
  }
}

// Использование
const { user, posts } = await loadUserData(42);

Если нужно обрабатывать ошибки отдельно для каждого await, оберните каждый вызов в свой try/catch или используйте паттерн с деструктуризацией:

// Паттерн «Go-style» для отдельной обработки каждой ошибки
async function safeAwait(promise) {
  try {
    const result = await promise;
    return [null, result];
  } catch (err) {
    return [err, null];
  }
}

async function example() {
  const [userErr, user] = await safeAwait(fetchUser(1));
  if (userErr) {
    console.log('Не удалось загрузить пользователя:', userErr.message);
    return;
  }

  const [postsErr, posts] = await safeAwait(fetchPosts(user.id));
  if (postsErr) {
    console.log('Посты недоступны, показываем только профиль');
  }

  return { user, posts: posts || [] };
}

Параллельное выполнение: Promise.all, Promise.allSettled, Promise.race

Частая ошибка — последовательно ждать несколько независимых запросов. Если они не зависят друг от друга, запускайте их параллельно.

// ПЛОХО: запросы выполняются последовательно (2 секунды вместо 1)
async function slowVersion() {
  const user = await fetchUser(1);    // 1 сек
  const config = await fetchConfig(); // ещё 1 сек
  return { user, config };
}

// ХОРОШО: запросы выполняются параллельно (~1 секунда)
async function fastVersion() {
  const [user, config] = await Promise.all([
    fetchUser(1),
    fetchConfig()
  ]);
  return { user, config };
}

Promise.all — ждёт все промисы. Если хоть один завершится ошибкой — весь Promise.all отклонится.

Promise.allSettled — ждёт все промисы и возвращает результаты каждого, независимо от успеха или ошибки:

const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/posts'),
  fetch('/api/comments')
]);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`Запрос ${index} успешен:`, result.value);
  } else {
    console.log(`Запрос ${index} упал:`, result.reason.message);
  }
});

Promise.race — возвращает результат первого завершившегося промиса (успех или ошибка). Полезно для реализации таймаутов:

// Таймаут для fetch через Promise.race
function fetchWithTimeout(url, ms = 5000) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Таймаут: ${ms}мс истекло`)), ms)
  );

  return Promise.race([fetch(url), timeout]);
}

// Использование
try {
  const response = await fetchWithTimeout('/api/data', 3000);
  const data = await response.json();
} catch (err) {
  console.error(err.message); // "Таймаут: 3000мс истекло"
}

Типичные ошибки при работе с async/await

1. Забыть await — функция вернёт промис, а не значение:

// ОШИБКА: user — это Promise, а не объект
async function bad() {
  const user = fetchUser(1); // пропущен await!
  console.log(user.name);    // undefined
}

// ПРАВИЛЬНО:
async function good() {
  const user = await fetchUser(1);
  console.log(user.name); // "Иван"
}

2. async в forEach — итерация не дожидается завершения:

// ОШИБКА: forEach не знает об async, не ждёт завершения
const ids = [1, 2, 3];
ids.forEach(async (id) => {
  await processUser(id); // порядок не гарантирован, код после forEach не ждёт
});

// ПРАВИЛЬНО (последовательно):
for (const id of ids) {
  await processUser(id);
}

// ПРАВИЛЬНО (параллельно):
await Promise.all(ids.map(id => processUser(id)));

3. Не обработать ошибку — в Node.js приведёт к UnhandledPromiseRejection:

// ПЛОХО: ошибка из async-функции нигде не обрабатывается
async function riskyOperation() {
  throw new Error('Что-то сломалось');
}
riskyOperation(); // UnhandledPromiseRejection!

// ХОРОШО:
riskyOperation().catch(err => console.error(err.message));
// или
await riskyOperation(); // внутри другой async-функции с try/catch

Практический пример: загрузка данных с повторными попытками

Финальный пример — функция с retry-логикой, которая пригодится в реальных проектах:

/**
 * Выполняет async-функцию с повторными попытками при ошибке
 * @param {Function} fn - async-функция для выполнения
 * @param {number} retries - максимум попыток
 * @param {number} delayMs - задержка между попытками (мс)
 */
async function withRetry(fn, retries = 3, delayMs = 1000) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      console.warn(`Попытка ${attempt}/${retries} не удалась: ${err.message}`);

      if (attempt === retries) {
        throw new Error(`Все ${retries} попытки исчерпаны. Последняя ошибка: ${err.message}`);
      }

      // Экспоненциальная задержка: 1с, 2с, 4с...
      await new Promise(resolve => setTimeout(resolve, delayMs * 2 ** (attempt - 1)));
    }
  }
}

// Использование
async function main() {
  try {
    const data = await withRetry(
      () => fetch('/api/unstable-endpoint').then(r => r.json()),
      3,    // 3 попытки
      500   // начальная задержка 500мс
    );
    console.log('Данные получены:', data);
  } catch (err) {
    console.error('Не удалось получить данные:', err.message);
  }
}

main();

Итог: шпаргалка

  • new Promise(resolve, reject) — создать промис вручную
  • .then() / .catch() / .finally() — цепочки обработки
  • async function — всегда возвращает промис
  • await — ждёт промис, работает только в async-функции
  • try/catch — обработка ошибок в async/await
  • Promise.all([]) — параллельно, падает при первой ошибке
  • Promise.allSettled([]) — параллельно, возвращает все результаты
  • Promise.race([]) — первый завершившийся побеждает
  • В forEach async не работает как ожидается — используйте for...of или Promise.all + map
  • Всегда обрабатывайте ошибки: .catch() или try/catch