Главная / Статьи

Рефакторинг цепочек Promise в Async/Await без изменения поведения

Как преобразовать цепочки .then() в читаемый код с использованием async/await, что на самом деле останавливает выполнение await, когда следует выполнять шаги параллельно и как try/catch заменяет .catch().

Издатель: REACTAPP.TOP7 мин чтения1597 слов

Источники: Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Обещания спасли JavaScript от вложенных обратных вызовов, но цепочка зависимых шагов .then() по-прежнему напоминает сложную схему вместо простой последовательности инструкций. async/await устраняет эту проблему, позволяя асинхронному коду выглядеть как обычный код, выполняемый сверху вниз, при этом сохраняя внутреннюю структуру на основе обещаний. В этом руководстве показано, как безопасно преобразовать цепочку обещаний, что на самом деле приостанавливается с помощью await, когда последовательные вызовы await тратят время впустую, и как меняется обработка ошибок, чтобы вы могли рефакторить существующий код, не меняя его функциональности.

От цепочки then к линейной функции

Вот типичная цепочка, в которой каждый шаг зависит от результата предыдущего: сначала загружается информация о пользователе, затем его заказы, после этого квитанции по этим заказам, причем существует один обработчик для любых сбоев.

getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getReceipts(orders))
  .then(receipts => {
    console.log(receipts);
  })
  .catch(err => handleError(err));

Это правильно и гораздо лучше, чем вложенные обратные вызовы, но вам всё равно приходится визуально отслеживать цепочку, чтобы понимать ход обработки данных. Синтаксис async/await, стандартизированный в ES2017, был создан для устранения этой последней проблемы. Та же логика, переписанная с использованием этого синтаксиса, выглядит так:

async function loadData(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    console.log(receipts);
  } catch (err) {
    handleError(err);
  }
}

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

Всегда имейте в виду одну вещь: async/await не заменяет обещания (promises). Это лишь синтаксический сахар над ними. Обещания по-прежнему выполняют асинхронную работу; синтаксис лишь меняет способ их написания и чтения.

Что меняет ключевое слово async

Размещение ключевого слова async перед функцией имеет два эффекта. Функция ниже возвращает обычный объект:

async function getUser(id) {
  return { id, name: "Aarav" };
}

Она всегда возвращает обещание

Несмотря на то, что функция getUser возвращает обычный объект, её вызов возвращает обещание, которое затем приводится к этому объекту. Поэтому вызывающие код могут обращаться с ним так же, как и с любым другим обещанием:

getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }

Если функция выбрасывает исключение, возвращаемое обещание отклоняется, вместо того чтобы ошибка распространялась синхронно. Это легко забыть, когда функция, ранее была синхронной, становится async: каждый вызывающий код, который раньше использовал её значение напрямую, теперь получает обещание и должен быть скорректирован.

Это позволяет использовать оператор await внутри её тела

await допускается внутри функции async. Современный JavaScript также позволяет использовать await на верхнем уровне в ES-модулях, что удобно для скриптов и инициализации модулей, но в обычных функциях всё равно требуется использование async.

Что на самом деле приостанавливает await

Выражение await возвращает управление до тех пор, пока его обещание не будет решено в том или ином виде. При успехе выражение возвращает решённое значение; при неудаче на этой строке происходит выброс ошибки, точно как при использовании оператора throw. В примере выводится сообщение, ожидается действие пользователя, а затем выводится результат:

async function loadUser() {
  console.log("Fetching user...");
  const user = await getUser(1); // pauses here until getUser's promise settles
  console.log("Got user:", user);
}

Результат отображается в следующем порядке:

Fetching user...
Got user: { id: 1, name: "Aarav" }

Важным моментом является то, что означает «пауза». Приостанавливается только эта функция, именно в той строке. Всё остальное в программе, включая другой код, таймеры и поступающие запросы на сервере, продолжает работать. Когда обещание выполнится, функция возобновит работу с того места, где остановилась, причем значение, полученное в результате выполнения, будет присвоено переменной.

Последовательные вызовы awaits и их скрытые издержки

Несколько вызовов awaits подряд выполняются один за другим:

async function loadDashboard(id) {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  const receipts = await getReceipts(orders);
  return receipts;
}

Каждая строка ожидает завершения своего этапа перед тем, как начнётся следующий, что воспроизводит поведение цепочки методов .then() без самой цепочки. Это корректно, когда каждый этап нуждается в результате предыдущего, как здесь: невозможно получить информацию о заказах до тех пор, пока не известен пользователь.

Проблемы возникают, когда один и тот же стиль применяется к операциям, которые не зависят друг от друга. В загрузчике панели управления ниже пользовательский интерфейс, уведомления и рекомендации являются независимыми, однако каждый запрос ждет предыдущего:

async function loadDashboard() {
  const user = await getUser();
  const notifications = await getNotifications();
  const recommendations = await getRecommendations();

  return { user, notifications, recommendations };
}

Общее время выполнения становится суммой времени всех трех запросов, а не времени самого медленного из них.

Начало одновременной обработки независимых задач

Поскольку три вызова независимы, их можно запустить одновременно и дождаться завершения всех с помощью Promise.all():

async function loadDashboard() {
  const [user, notifications, recommendations] = await Promise.all([
    getUser(),
    getNotifications(),
    getRecommendations()
  ]);

  return { user, notifications, recommendations };
}

Все три запроса выполняются одновременно, и с помощью деструктуризации результаты получаются в том же порядке, что и элементы входного массива. Следует знать один момент: Promise.all() срабатывает только тогда, когда все обещания сработают, а отклоняется сразу при отказе хотя бы одного из них, не дожидаясь остальных. Если вам нужны все результаты, независимо от сбоев, более подходящим вариантом будет Promise.allSettled(). Взаимосвязи между этими инструментами рассматриваются в статье choosing between Promise.all, Promise.race and sequential awaits.

Обработка ошибок с помощью try и catch

При использовании async/await знакомый блок try/catch заменяет метод .catch(), так что синхронные и асинхронные ошибки теперь используют одну и ту же структуру:

async function loadUser(id) {
  try {
    const user = await getUser(id);
    console.log(user);
  } catch (err) {
    console.log("Failed to load user:", err.message);
  }
}

Если обещание, возвращаемое функцией getUser(id), отклоняется, управление сразу переходит к блоку catch, точно так же, как это происходит при ошибках, возникающих в синхронном коде. Обратите внимание, что эта функция обрабатывает ошибку и затем завершается нормально, поэтому её собственное обещание выполняется с значением undefined. Звонящие не могут понять, что загрузка провалилась. Если звонящему необходимо отреагировать, следует перебросить исключение после логирования или вернуть явный результат вместо этого.

Один обработчик для нескольких шагов

Один блок try может охватывать всю последовательность операций await:

async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    return receipts;
  } catch (err) {
    console.log("Something failed in the dashboard load:", err.message);
  }
}

Если какой-либо шаг завершается с ошибкой, выполнение переходит непосредственно к блоку catch, поэтому не требуется отдельный обработчик для каждого шага. Это соответствует эффекту централизации, который обеспечивал блок .catch() в конце цепочки операций. Когда разным шагам требуется разное восстановление, например повторная попытка только для запроса к квитанциям, необходимо поместить этот шаг в более узкий блок try.

Сравнение обоих стилей

Вот тот же загрузчик в обоих форматах:

// Promises
function loadDashboard(id) {
  return getUser(id)
    .then(user => getOrders(user.id))
    .then(orders => getReceipts(orders))
    .catch(err => console.log("Failed:", err.message));
}

// async/await
async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    return await getReceipts(orders);
  } catch (err) {
    console.log("Failed:", err.message);
  }
}

Ни одна из версий не меняет основной асинхронный механизм работы. Выбор зависит от того, которую версию людям проще читать и обслуживать, причем для логики с несколькими шагами обычно преимущество имеет синтаксис async/await. Один важный нюанс второй версии: использование return await getReceipts(orders) внутри блока try критически важно. Если написать return getReceipts(orders) без await, обещание будет возвращено до того, как оно будет выполнено, поэтому ошибка последнего шага полностью пройдёт мимо блока catch.

Частые вопросы

Заблокирует ли await всё приложение?

Нет. Он приостанавливает только асинхронную функцию, в которой он находится. Другой код, включая другие запросы, продолжает работать в то время, пока эта функция ожидает.

Можно ли использовать await вне асинхронной функции?

Это не работает в обычных функциях. Исключением является оператор await на верхнем уровне в ES-модулях, который действует на уровне модуля, но не внутри обычных асинхронных функций.

Является ли async/await быстрее promises?

Нет. Он построен на promises, поэтому смена синтаксиса не приносит преимуществ в скорости. Преимущества заключаются в легкости чтения и обслуживания кода. Настоящее ускорение достигается за счёт одновременной обработки независимых задач, а не благодаря этому ключевому слову.

Что происходит без try/catch?

Если ожидаемое обещание отклоняется и ничто его не перехватывает, обещание самой асинхронной функции также отклоняется. Если никто из вызывающих кодов не обрабатывает это отклонение, среда выполнения сообщает о неперехваченном отклонении, что в текущих версиях Node.js по умолчанию приводит к завершению процесса. Обрабатывайте ошибки с помощью конструкций try/catch внутри функции или с помощью метода .catch() там, где она вызывается. Для информации о других подобных ситуациях см. распространённые заблуждения относительно async/await, приводящие к ошибкам в продакшене.

Основные выводы

  • async/await меняют способ написания асинхронного кода, но не способ его выполнения; работу по-прежнему выполняют обещания.
  • Асинхронная функция всегда возвращает обещание, поэтому преобразование функции в режим async меняет её поведение для всех вызывающих кодов.
  • await приостанавливает только собственную функцию, но никогда не всю программу.
  • Шаги, зависящие от await, выполняются последовательно, в то время как независимые шаги можно запускать одновременно с помощью Promise.all() или Promise.allSettled().
  • Использование блока try/catch вокруг нескольких операций await позволяет централизовать обработку ошибок; не забывайте перебросить исключение, если вызывающие функции должны об этом узнать, а также использовать return await внутри блока try.