Async/await в JavaScript: как работает и где ошибаются

Ноутбук с открытым редактором кода на JavaScript и консолью Node.js Программирование и разработка

Async/await в JavaScript позволяет писать асинхронный код так, будто он выполняется строка за строкой. Ключевое слово async перед функцией заставляет её всегда возвращать Promise, а await внутри такой функции приостанавливает её выполнение, пока промис не выполнится, и отдаёт результат в переменную.

Синтаксис async/await появился в стандарте ECMAScript 2017, работает поверх промисов и есть во всех основных браузерах с 2017 года (Chrome 55, Firefox 52, Edge 15). Примеры запускались в Node.js 24.14, вывод скопирован из консоли.

Зачем нужны async и await

Синхронный код выполняется по порядку: пока одна строка не закончилась, следующая ждёт. Запрос к серверу идёт сотни миллисекунд, и всё это время страница стояла бы. Поэтому fetch, таймеры и файловые операции Node.js (кроме методов с суффиксом Sync) асинхронные: вы запускаете асинхронную операцию, движок продолжает выполнять остальной код, а результат приходит позже.

Сначала результат ловили через колбэки, потом через промисы. В JavaScript промис находится в одном из трёх состояний: pending (ждёт), fulfilled (выполнен со значением) или rejected (отклонён с ошибкой). Обработчик, переданный в then, срабатывает, когда состояние сменилось. Async/await часто называют синтаксическим сахаром над промисами: под ним те же промисы и тот же цикл событий, только код без цепочек.

Сравним на одной задаче: загрузить пользователя, потом его заказы.

function loadUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) reject(new Error(`Нет пользователя с id ${id}`));
      else resolve({ id, name: "Аня" });
    }, 300);
  });
}

function loadOrders(user) {
  return new Promise((resolve) => {
    setTimeout(() => resolve([`заказ ${user.id}-1`, `заказ ${user.id}-2`]), 200);
  });
}

// цепочка then
loadUser(7)
  .then((user) => loadOrders(user))
  .then((orders) => console.log("then:", orders))
  .catch((err) => console.error("then, ошибка:", err.message));

// то же через async/await
async function showOrders(id) {
  try {
    const user = await loadUser(id);
    const orders = await loadOrders(user);
    console.log("await:", orders);
  } catch (err) {
    console.error("await, ошибка:", err.message);
  }
}

showOrders(7);
showOrders(0);
await, ошибка: Нет пользователя с id 0
then: [ 'заказ 7-1', 'заказ 7-2' ]
await: [ 'заказ 7-1', 'заказ 7-2' ]

Функции loadUser и loadOrders имитируют асинхронные операции с сетью: new Promise получает resolve и reject, и таймер вызывает один из них. Ошибка для id = 0 пришла раньше всех: у неё одна задержка в 300 мс, а у успешных вызовов две, 300 и 200.

У версии с await нет вложенных колбэков, переменная user видна во всей функции, а ошибки ловит обычный try...catch. С then пользователя пришлось бы передавать по цепочке вручную.

Ключевое слово async: функция всегда возвращает промис

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

async function getAnswer() {
  return 42;
}

async function breakDown() {
  throw new Error("сломалось");
}

const answer = getAnswer();
console.log(answer);
console.log(answer instanceof Promise);

answer.then((value) => console.log("значение:", value));
breakDown().catch((e) => console.log("поймали:", e.message));
Promise { 42 }
true
значение: 42
поймали: сломалось

getAnswer() вернула промис с числом. Достать значение можно через then или через await в другой асинхронной функции. Асинхронная функция без return отдаст промис с undefined.

Слово async ставят перед объявлением async function, перед стрелочной функцией (async () => {}) и перед методом класса или объекта.

Ключевое слово await: что происходит во время ожидания

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

await приостанавливает выполнение только своей асинхронной функции. Поток JavaScript не блокируется, движок в это время выполняет остальной код. Порядок строк в выводе это показывает:

console.log("1: синхронный код");

setTimeout(() => console.log("6: setTimeout"), 0);

async function job() {
  console.log("2: job до await");
  const word = await "готово";
  console.log("4: job после await,", word);
}

job();
Promise.resolve().then(() => console.log("5: then"));
console.log("3: конец скрипта");
1: синхронный код
2: job до await
3: конец скрипта
4: job после await, готово
5: then
6: setTimeout

Строка «2» напечаталась сразу: выполнение асинхронной функции до первого await идёт синхронно. Дальше job встала на паузу, и скрипт дошёл до «3».

Читайте также:  Задача «Фруктовый салат» на Python: решение и разбор ошибок

Продолжение функции после await и колбэк then попадают в очередь микрозадач, setTimeout уходит в очередь макрозадач. Так работает цикл событий (event loop): когда синхронный код закончился, движок сначала выполняет все микрозадачи, потом берёт следующую макрозадачу. Поэтому таймер с нулевой задержкой сработал последним. Продолжение job встало в очередь раньше, чем then, и вывело «4» до «5». Оно уходит в следующую микрозадачу, даже если промис уже выполнен, как await "готово" здесь.

Ноутбук с открытым редактором кода на JavaScript и консолью Node.js

Где можно писать await

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

function readConfig() {
  const line = await Promise.resolve("port=8080");
  return line;
}
...\await-outside.js:2
  const line = await Promise.resolve("port=8080");
               ^^^^^

SyntaxError: await is only valid in async functions and the top level bodies of modules

Путь к файлу и стек вызовов здесь сокращены. Такое сообщение Node.js выдаёт для CommonJS-файла .js. Если тот же код лежит в .mjs или в проекте с "type": "module", текст другой: SyntaxError: Unexpected reserved word. Исправить можно двумя способами: объявить async function readConfig() или перенести код на верхний уровень модуля.

Top-level await вошёл в ECMAScript 2022 и работает только в модулях. В браузере это <script type="module">, в Node.js файл .mjs или "type": "module" в package.json:

// top.mjs
function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

console.log("старт");
await delay(500);
console.log("прошло полсекунды");
старт
прошло полсекунды

Node.js с версий 22.7 и 20.19 сам распознаёт модуль по синтаксису, поэтому тот же код в файле top.js без package.json у нас тоже отработал. Полагаться на это в проекте не стоит: указывайте "type" в package.json явно, а в браузере обычный <script> с таким кодом упадёт с SyntaxError.

Обработка ошибок: try, catch и finally

В асинхронной функции отклонённый промис ведёт себя как throw, поэтому ошибки ловит блок try...catch, а finally выполняется в любом случае. Вторая защита: вызвать у самой функции .catch(), как с breakDown() в разделе про async.

return без await внутри try выпускает ошибку мимо catch. Функции withoutAwait и withAwait отличаются одним словом:

async function risky() {
  throw new Error("сервер ответил 500");
}

async function withoutAwait() {
  try {
    return risky();
  } catch (e) {
    return "поймали в withoutAwait";
  }
}

async function withAwait() {
  try {
    return await risky();
  } catch (e) {
    return "поймали в withAwait";
  } finally {
    console.log("finally в withAwait выполнился");
  }
}

withoutAwait().then(console.log, (e) => console.log("улетело наружу:", e.message));
withAwait().then(console.log);
finally в withAwait выполнился
поймали в withAwait
улетело наружу: сервер ответил 500

withoutAwait вернула промис risky() сразу, не дожидаясь результата. Он отклонился уже после выхода из try, и catch ничего не поймал: ошибка ушла к вызывающему коду. С return await функция ждёт промис внутри try, и ошибка попадает в свой catch. Вне try разницы в результате нет, поэтому внутри try пишите return await.

Пример с fetch: запрос к API

Частый случай: получить данные с сервера через API. Чтобы пример работал без интернета, он поднимает маленький HTTP-сервер на порту 3000 и сам к нему обращается. Функция fetch доступна в Node.js 18 без флагов, а стабильной стала в Node.js 21. В браузерах она есть давно. await fetch(url) отдаёт объект Response с кодом ответа и заголовками, а тело читается отдельно.

// fetch.mjs
import http from "node:http";

// маленький сервер, чтобы пример работал без интернета
const server = http.createServer((req, res) => {
  if (req.url === "/users/1") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ id: 1, name: "Аня", role: "admin" }));
  } else {
    res.writeHead(404);
    res.end("not found");
  }
});
server.listen(3000);

async function getJson(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} для ${url}`);
  }
  return response.json();
}

try {
  const data = await getJson("http://localhost:3000/users/1");
  console.log("профиль:", data);
  await getJson("http://localhost:3000/users/2");
} catch (err) {
  console.error("Ошибка:", err.message);
} finally {
  server.close();
}
профиль: { id: 1, name: 'Аня', role: 'admin' }
Ошибка: HTTP 404 для http://localhost:3000/users/2

Проверка response.ok здесь обязательна. Промис fetch отклоняется, когда запрос не удался: сетевая ошибка, неверный адрес. Ответ 404 или 500 для него успешный. Без проверки код пошёл бы дальше и упало на response.json() с SyntaxError: Unexpected token 'o', "not found" is not valid JSON: вместо JSON сервер прислал текст.

Читайте также:  Перегрузка методов в Java: правила, выбор вызова и переопределение

response.json() тоже отдаёт промис. Ждать его внутри не нужно: return передаст промис наружу, и вызывающий код дождётся его через await getJson(...).

Последовательно или параллельно

Три асинхронные операции с await подряд выполняются по очереди: следующий запрос стартует, только когда закончился предыдущий. Если запросы друг от друга не зависят, это пустая трата времени. Замер на трёх операциях по 300 мс:

// parallel.mjs
function wait(ms, label) {
  return new Promise((resolve) => setTimeout(() => resolve(label), ms));
}

let start = performance.now();
const a = await wait(300, "профиль");
const b = await wait(300, "корзина");
const c = await wait(300, "отзывы");
console.log("по очереди:", [a, b, c], Math.round(performance.now() - start), "мс");

start = performance.now();
const result = await Promise.all([
  wait(300, "профиль"),
  wait(300, "корзина"),
  wait(300, "отзывы"),
]);
console.log("Promise.all:", result, Math.round(performance.now() - start), "мс");
по очереди: [ 'профиль', 'корзина', 'отзывы' ] 926 мс
Promise.all: [ 'профиль', 'корзина', 'отзывы' ] 315 мс

Это вывод одного запуска у нас, в другой раз вышло 923 и 307 мс, у вас числа будут другими. Соотношение то же: последовательный вариант занимает примерно сумму задержек, параллельный примерно равен самой долгой из них. Таймеры стартуют в момент вызова wait(...), при сборке массива, а Promise.all только ждёт уже созданные промисы и возвращает массив результатов в том порядке, в каком вы их передали, а не в порядке завершения.

Последовательность нужна, когда следующий шаг зависит от предыдущего, как loadOrders(user) в примере с заказами: без пользователя заказы не запросить.

Сравнение: три await подряд занимают около 900 мс, три операции в Promise.all около 300 мс

Promise.all, allSettled, any и race

У Promise четыре метода для нескольких операций сразу. Отличаются они тем, когда общий промис выполняется и когда отклоняется.

Метод Выполняется Отклоняется Стандарт
Promise.all когда выполнились все, результат: массив значений при первой ошибке любого промиса ES2015
Promise.allSettled когда все завершились, с успехом или ошибкой из-за ошибок в промисах не отклоняется ES2020
Promise.any при первом успешном промисе когда отклонились все, с AggregateError ES2021
Promise.race если первым завершился успешный промис если первым завершился отклонённый ES2015

Прогоним все четыре на одном сценарии: три зеркала, одно отвечает за 300 мс, второе падает через 100 мс, третье отвечает за 500 мс.

// combo.mjs
function ok(ms, value) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
function fail(ms, text) {
  return new Promise((_, reject) => setTimeout(() => reject(new Error(text)), ms));
}

const sources = () => [ok(300, "зеркало А"), fail(100, "зеркало Б недоступно"), ok(500, "зеркало В")];

try {
  console.log("all:", await Promise.all(sources()));
} catch (error) {
  console.log("all упал:", error.message);
}

const report = await Promise.allSettled(sources());
console.log("allSettled:", report.map((r) => r.status === "fulfilled" ? r.value : r.reason.message));

console.log("any:", await Promise.any(sources()));

try {
  console.log("race:", await Promise.race(sources()));
} catch (error) {
  console.log("race упал:", error.message);
}

try {
  await Promise.any([fail(50, "нет сети"), fail(80, "нет диска")]);
} catch (error) {
  console.log(error.name, "|", error.message, "|", error.errors.map((x) => x.message));
}
all упал: зеркало Б недоступно
allSettled: [ 'зеркало А', 'зеркало Б недоступно', 'зеркало В' ]
any: зеркало А
race упал: зеркало Б недоступно
AggregateError | All promises were rejected | [ 'нет сети', 'нет диска' ]

all сдался на первой ошибке, хотя два зеркала из трёх работали. Остальные асинхронные операции при этом не отменяются, их результат просто никто не прочитает. allSettled вернул отчёт по каждому: у элемента есть status со значением "fulfilled" или "rejected" и поле value или reason. any дождался успеха и пропустил ошибку, которая пришла раньше. race отдал то, что завершилось первым, и это оказалась ошибка.

Когда отклонились все промисы, any бросает AggregateError, а причины лежат в массиве errors. Пустой массив Promise.any([]) тоже сразу даёт отклонение.

Чаще всего нужен Promise.all: загрузить несколько независимых частей страницы. allSettled берут, когда частичный результат лучше никакого, например при массовой рассылке. race годится для таймаута: запрос соревнуется с промисом, который отклоняется через 5 секунд. Сам запрос race при этом не отменяет, он продолжит идти. Для fetch удобнее опция signal: AbortSignal.timeout(5000): через 5 секунд запрос прервётся, и промис отклонится с ошибкой TimeoutError.

Частые ошибки с async/await

Забыли await

Асинхронная функция вернула промис, а код работает с ним как с числом. Исключения нет, есть странный результат:

async function getPrice() {
  return 990;
}

const price = getPrice();          // забыли await
console.log(price);
console.log(price + 10);

const realPrice = await getPrice();
console.log(realPrice + 10);
Promise { 990 }
[object Promise]10
1000

Видите в консоли Promise { <pending> } или [object Promise]: ищите пропущенный await. TypeScript такой код не скомпилирует: error TS2365: Operator '+' cannot be applied to types 'Promise<number>' and '10'. Вызов вообще без await и без обработки, отдельной строкой вроде getPrice();, находит правило линтера @typescript-eslint/no-floating-promises.

Async-колбэк в forEach

forEach не ждёт промисы, которые возвращает асинхронный колбэк. Функция выходит сразу, а обработка идёт где-то в фоне:

// loop.mjs
function pause(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
const files = ["a.txt", "b.txt", "c.txt"];

async function withForEach() {
  files.forEach(async (name) => {
    await pause(100);
    console.log("forEach обработал", name);
  });
  console.log("forEach: функция уже вернула управление");
}

async function withForOf() {
  for (const name of files) {
    await pause(100);
    console.log("for...of обработал", name);
  }
  console.log("for...of: функция закончилась");
}

await withForEach();
await pause(200);
await withForOf();
forEach: функция уже вернула управление
forEach обработал a.txt
forEach обработал b.txt
forEach обработал c.txt
for...of обработал a.txt
for...of обработал b.txt
for...of обработал c.txt
for...of: функция закончилась

await withForEach() ничего не дождался, и следующий код начал бы работать с необработанными файлами. Async-колбэк в forEach мы бы не использовали вообще. Нужно по очереди: используйте цикл for...of с await. Нужно параллельно: await Promise.all(files.map(async (name) => { ... })).

Никто не обработал ошибку

Асинхронную функцию вызвали без await, без .catch() и без try, а она бросила исключение. В Node.js 24 такой скрипт падает:

async function broken() {
  throw new Error("никто не поймал");
}

broken();
setTimeout(() => console.log("эта строка не выведется"), 100);
...\unhandled.js:2
  throw new Error("никто не поймал");
        ^

Error: никто не поймал
    at broken (...\unhandled.js:2:9)

Стек сокращён. Процесс завершился с кодом 1, таймер не сработал. В браузере страница продолжит работу, а в консоли появится Uncaught (in promise). На сервере под Node.js без обработчика unhandledRejection такая ошибка роняет весь процесс, поэтому у каждого вызова асинхронной функции верхнего уровня должен быть свой catch.

Then и await вперемешку

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

async function loadProfile() {
  throw new Error("профиль не загрузился");
}

async function render() {
  try {
    const profile = await loadProfile().catch((e) => console.log("catch в цепочке:", e.message));
    console.log("профиль:", profile);
  } catch (e) {
    console.log("try...catch:", e.message);
  }
}

render();
catch в цепочке: профиль не загрузился
профиль: undefined

.catch() вернул undefined, код пошёл дальше с пустым профилем, а блок catch не сработал. В одной функции держитесь одного стиля, и в новом коде это async/await.

Читайте также:  Строки в PHP: кавычки, echo, функции и работа с UTF-8

Async-методы классов и вызов из обычной функции

Асинхронный метод класса объявляется с async так же, как функция, и внутри доступен this. Вызвать асинхронный метод из обычной функции можно, но ждать результат придётся через then, потому что await там нельзя:

class Api {
  constructor(base) {
    this.base = base;
  }

  async ping() {
    await new Promise((resolve) => setTimeout(resolve, 200));
    return `pong от ${this.base}`;
  }
}

function main() {
  // обычная функция не может ждать через await, поэтому берём then
  new Api("api.local").ping().then((reply) => console.log(reply));
  console.log("main закончилась раньше, чем пришёл ответ");
}

main();
main закончилась раньше, чем пришёл ответ
pong от api.local

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

Чем async/await лучше промисов?

Под ним те же промисы, выигрыш в чтении: нет вложенности, переменные видны во всей функции, ошибки ловит try...catch, циклы и условия пишутся как в синхронном коде.

Блокирует ли await браузер или сервер?

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

Можно ли использовать await без async?

Только на верхнем уровне ES-модуля. В обычной функции будет SyntaxError: в CommonJS-файле Node.js пишет await is only valid in async functions and the top level bodies of modules, в модуле .mjs пишет Unexpected reserved word.

Оцените статью
bestprogrammer.ru
Добавить комментарий