Menu
Русский

async/await в JavaScript: асинхронный код по-человечески

Разбираемся, как на самом деле работают async/await в JavaScript: асинхронные функции, await, обработка ошибок через try/catch и параллельный запуск задач через Promise.all.

async/await — это просто промисы в красивой обёртке

async/await — это не новая модель асинхронности в JavaScript. Это синтаксический сахар поверх промисов: код выглядит так, будто выполняется последовательно, хотя на самом деле он асинхронный. Механика та же, просто форма гораздо приятнее.

Вот одна и та же задача, написанная двумя способами:

index.js
Output
Click Run to see the output here.

Обе функции возвращают промис. Обе делают ровно одно и то же. Но async-версия читается сверху вниз, без цепочки .then — собственно, в этом и весь смысл.

async помечает функцию как возвращающую промис

Добавьте async перед function, стрелочной функцией или методом — и происходят две вещи:

  1. Функция всегда возвращает промис. То, что вы return, становится значением, с которым промис зарезолвится.
  2. Внутри такой функции можно использовать await.
index.js
Output
Click Run to see the output here.

Обратите внимание: result — это не строка, а промис, который разрешится строкой. Несмотря на то что внутри greet нет ни await, ни какой-либо асинхронной работы, ключевое слово async всё равно оборачивает возвращаемое значение в промис. Если функция бросит исключение — промис отклонится.

await ждёт, пока промис завершится

Внутри async-функции выражение await somePromise приостанавливает её выполнение до тех пор, пока промис не разрешится, а затем возвращает полученное значение. Если промис отклонён, await бросит исключение.

index.js
Output
Click Run to see the output here.

Обратите внимание на порядок вывода. "отсчёт запущен" печатается раньше, чем "2" — потому что await ставит на паузу только async-функцию, а не всю программу целиком. Event loop продолжает крутиться, а countdown просто возобновится позже, когда очередной промис из wait зарезолвится.

Через await можно ждать что угодно промисоподобное. Даже await 42 — вполне рабочий код: всё, что не является промисом, оборачивается в Promise.resolve(42) и тут же резолвится.

Обработка ошибок через try/catch в async/await

С обычными промисами ошибки ловят через .catch(). А в случае с async/await отклонённый промис превращается в обычное исключение, которое ловится привычным способом:

index.js
Output
Click Run to see the output here.

Один блок try/catch ловит всё, что случилось внутри с любым await. Сетевые сбои, ошибки парсинга JSON, ваши собственные throw — всё прилетит в один и тот же catch. По сравнению с вложенными цепочками .then/.catch это просто глоток свежего воздуха.

Есть один нюанс: fetch реджектится только на сетевых ошибках, а HTTP-статусы 4xx/5xx он считает нормальным ответом. Поэтому res.ok придётся проверять руками и кидать ошибку самостоятельно — этот паттерн вы будете встречать в боевом коде постоянно.

Не ставьте await в цикле, если без него можно обойтись

Это самая частая ошибка при работе с async/await в JavaScript. Последовательный await внутри цикла означает, что каждая итерация ждёт завершения предыдущей:

index.js
Output
Click Run to see the output here.

sequential отрабатывает примерно за 900 мс, parallel — около 300 мс. Правило простое: если задачи не зависят от результатов друг друга, запускайте их все сразу и делайте await Promise.all. Поштучный await нужен только тогда, когда следующему вызову реально требуется результат предыдущего.

Для коллекций идиома такая: Promise.all(items.map(async (x) => ...)). Обычный for...of с await внутри работает последовательно — иногда это как раз то, что нужно (rate-limiting, сохранение порядка), но чаще всего нет.

Совмещаем async/await и обычные промисы

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

index.js
Output
Click Run to see the output here.

Эти два стиля взаимозаменяемы. Используйте await, когда код лучше читается сверху вниз, и .then, если нужно быстро что-то сделать по ходу дела или вы работаете вне async-контекста.

Top-level await в ES-модулях

Раньше await можно было писать только внутри async-функции — на верхнем уровне скрипта он был запрещён. Теперь всё изменилось: внутри ES-модуля (файл .mjs или <script type="module">) await можно использовать прямо на верхнем уровне:

// в ES-модуле
const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
const user = await res.json();
console.log(user.name);

await на верхнем уровне откладывает завершение модуля до тех пор, пока ожидаемый промис не разрешится — и все, кто импортирует этот модуль, тоже будут ждать. Удобно для загрузки конфигов и динамических импортов, но злоупотреблять не стоит: один медленный top-level await тормозит всех, кто подтягивает этот модуль.

В файлах CommonJS и в обычных инлайновых скриптах такое по-прежнему падает с SyntaxError. Классический обходной путь — самовызывающаяся async-функция:

index.js
Output
Click Run to see the output here.

Типичные грабли, на которые все наступают

Короткий обзор самых частых проблем:

  • Забыли async. await внутри обычной функции — это синтаксическая ошибка. Лечится либо добавлением async, либо вызовом асинхронной функции-обёртки через .then.
  • Забыли await у результата. Запись const data = getJSON(url); вернёт вам промис, а не данные. Если попробовать использовать это как значение, в выводе вы увидите [object Promise].
  • Необработанные отклонения. Асинхронная функция, запущенная «вслепую» (doWork();), молча проглотит ошибки, если вы не навесите .catch или не обернёте вызов в try/catch с await.
  • forEach с асинхронным коллбэком. Конструкция array.forEach(async (x) => await something(x)) на самом деле ничего не ждёт — forEach просто игнорирует возвращённые промисы. Используйте for...of с await либо Promise.all(array.map(...)).
index.js
Output
Click Run to see the output here.

Запустите — "завершено?" выведется раньше любого "готово", потому что broken возвращает управление, не дожидаясь ничего. А fixed дожидается всех операций и только потом печатает "завершено!".

Когда стоит использовать async/await

По умолчанию выбирайте async/await для любого кода, где асинхронные шаги идут друг за другом, или там, где удобна обработка ошибок через try/catch. С «голыми» промисами стоит остаться для простых однострочников, для библиотечного кода, который просто возвращает промис и сам ничего не ждёт, либо когда действительно нужны комбинаторы вроде Promise.race или .finally() в цепочке.

Если пользоваться с умом, async/await превращает асинхронный код в рецепт: сделай это, потом это, потом вот это. Event loop никуда не делся — просто вам больше не нужно мыслить коллбэками.

Дальше: Fetch API

Почти во всех примерах fetch выступал просто как «какая-то асинхронная штука». Но он заслуживает отдельного разговора: как устроены запросы и ответы, как работать с JSON, как задавать заголовки и почему fetch не реджектится на HTTP-ошибках. Об этом — на следующей странице.

Часто задаваемые вопросы

Что вообще делают async/await в JavaScript?

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

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

На верхнем уровне ES-модуля — да, это называется top-level await. А вот в обычных функциях или в CommonJS-скриптах так не получится: await вне async — это синтаксическая ошибка. Решение простое: либо оберните код в async-функцию и вызовите её, либо переведите файл на ES-модули.

Как ловить ошибки с async/await?

Заверните вызовы с await в try/catch. Любой отклонённый (rejected) промис, который вы ожидаете через await, превратится в обычное исключение, и его подхватит catch. Для фоновых задач, которые вы не ожидаете через await, вешайте .catch() на сам промис — иначе получите unhandled rejection.

А await не блокирует всю программу?

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

Учитесь программировать с Coddy

НАЧАТЬ