JavaScript: Руководство по async/await, с примерами

Источник: «A Beginner’s Guide to JavaScript async/await, with Examples»
Ключевые слова async и await представляют современный синтаксис JavaScript, помогающий обрабатывать асинхронный операции. В этом руководстве мы подробно рассмотрим, как использовать async/await для управления потоком данных.

В JavaScript некоторые операции асинхронны. Это означает, что результат или значение, которые они производят, не доступны немедленно.

Рассмотрим следующий код:

function fetchDataFromApi() {
// Логика получения данных
console.log(data);
}

fetchDataFromApi();
console.log('Finished fetching data');

Интерпретатор JavaScript не будет ждать завершения асинхронной функции fetchDataFromApi, прежде чем перейти к следующему оператору. Следовательно, он выведет Finished fetching data до того, как данные фактически будут получены из API.

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

Этот функционал появился в JavaScript в ES2017 и поддерживается во всех современных браузерах.

Как создать асинхронную функцию JavaScript

Давайте подробнее рассмотрим логику получения данных в функции fetchDataFromApi. Получение данных в JavaScript — яркий пример асинхронной операции.

Используя Fetch API мы могли бы реализовать это так:

function fetchDataFromApi() {
fetch('https://v2.jokeapi.dev/joke/Programming?type=single')
.then(res => res.json())
.then(json => console.log(json.joke));
}

fetchDataFromApi();
console.log('Finished fetching data');

Мы получаем шутку о программировании из JokeAPI. Ответ API находится в формате JSON, поэтому мы извлекаем ответ после завершения запроса (используя метод json()), а затем записываем шутку в консоль.

Обратите внимание, что JokeAPI — сторонний API, поэтому мы не можем гарантировать качество возвращаемых шуток!

Если мы запустим этот код в браузере или Node.js (версия 17.5+ с использованием флага --experimental-fetch), мы увидим, что данные по-прежнему выводятся в консоль в неправильном порядке.

Давайте это изменим.

Ключевое слово async

Первое, что нужно сделать, это пометить функцию как асинхронную. Мы можем сделать это, используя ключевое слово async, которое нужно поместить перед ключевым словом function:

async function fetchDataFromApi() {
fetch('https://v2.jokeapi.dev/joke/Programming?type=single')
.then(res => res.json())
.then(json => console.log(json.joke));
}

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

fetchDataFromApi()
.then(() => {
console.log('Finished fetching data');
});

Если мы запустим код, то увидим что-то вроде этого:

If Bill Gates had a dime for every time Windows crashed ... Oh wait, he does.
Finished fetching data

Но мы не хотим это делать! Синтаксис промисов в JavaScript может показаться немного запутанным, и именно в этом проявляется преимущество async/await: он позволяет писать асинхронный код с синтаксисом, который больше похож на синхронный код и более удобочитаем.

Ключевое слово await

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

async function fetchDataFromApi() {
const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
const json = await res.json();
console.log(json.joke);
}

Также нам нужно дождаться результатов вызова функции fetchDataFromApi:

await fetchDataFromApi();
console.log('Finished fetching data');

К сожалению, если мы попытаемся запустить код сейчас, то столкнёмся с ошибкой:

Uncaught SyntaxError: await is only valid in async functions, async generators and modules

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

async function fetchDataFromApi() {
const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
const json = await res.json();
console.log(json.joke);
}

async function init() {
await fetchDataFromApi();
console.log('Finished fetching data');
}

init();

Если мы запустим код сейчас, всё должно выводиться в правильном порядке:

UDP is better in the COVID era since it avoids unnecessary handshakes.
Finished fetching data

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

Различные способы объявления асинхронных функций

В предыдущем примере используются два объявления именованных функций (за ключевым словом function следует имя функции), но мы не ограничиваемся ими. Мы также можем пометить функциональные выражения, стрелочные и анонимные функции как async.

Выражение асинхронной функции

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

const fetchDataFromApi = async function() {
const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
const json = await res.json();
console.log(json.joke);
}

Это будет работать так же, как наш предыдущий код.

Асинхронная стрелочная функция

Стрелочные функции были введены в JavaScript в ES6. Они представляют собой компактную альтернативу функциональным выражениям и всегда анонимны. Их основной синтаксис следующий:

(params) => { <function body> }

Чтобы пометить стрелочную функцию как асинхронную, вставьте ключевое слово async перед открывающей скобкой.

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

(async () => {
async function fetchDataFromApi() {
const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
const json = await res.json();
console.log(json.joke);
}
await fetchDataFromApi();
console.log('Finished fetching data');
})();

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

JavaScript Await/Async использует промисы под капотом

Как вы уже могли догадаться, async/await а значительной степени является синтаксическим сахаром для промисов. Давайте рассмотрим это немного подробнее, так как лучшее понимание того, что происходит под капотом, будет иметь большое значение для понимания того, как работает async/await.

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

async function echo(arg) {
return arg;
}

const res = echo(5);
console.log(res);

В консоль будет выведено следующее:

Promise { <state>: "fulfilled", <value>: 5 }

Промис может находиться в одном из трёх состояний: pending, fulfilled или rejected. Промис начинает жизненный цикл с состояния pending. Если действие, связанное с промисом, успешно, то промис переходит в состояние fulfilled. Если действие не увенчалось успехом, промис переходит в состояние rejected. Как только промис перешёл в состояние fulfilled или rejected, но не в pending он считается выполненным.

Когда мы используем ключевое слово await внутри async функции, чтобы приостановить выполнение функции, на самом деле происходит то, что мы ожидаем, что промис (явно или неявно) перейдёт в разрешённое (resolved) или отклонённое (rejected) состояние.

Основываясь на предыдущем примере, мы можем сделать следующее:

async function echo(arg) {
return arg;
}

async function getValue() {
const res = await echo(5);
console.log(res);
}

getValue();
// 5

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

Промисы — это большое улучшение управления потоком в JavaScript, и они используются несколькими новыми API-интерфейсами браузера, такими как API статуса батареи, API буфера обмена, Fetch API, MediaDevices API и т.д.

Node.js также добавила промисы во встроенный модуль util, преобразовав код, использующий функции обратного вызова для возврата промиса. А начиная с версии 10, функции в модуле fs могут напрямую возвращать промисы.

Переход от промисов к async/await

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

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

Используя Promise.then():

const { promises: fs } = require('fs');

const getFileContents = function(fileName) {
return fs.readFile(fileName, enc)
}

getFileContents('myFile.md', 'utf-8')
.then((contents) => {
console.log(contents);
});

Используя async/await это станет:

import { readFile } from 'node:fs/promises';

const getFileContents = function(fileName, enc) {
return readFile(fileName, enc)
}

const contents = await getFileContents('myFile.md', 'utf-8');
console.log(contents);

Примечание: используется функционал называемый top level await, доступный только в ES6. Для запуска этого кода в Node.js, сохраните файл как index.mjs и используйте версию Node.js >= 14.8

Хотя эти примеры простые, я считаю, что синтаксис async/await легче читается. Это становится актуальным при работе с несколькими операторами then() и при обработке ошибок. Я бы не стал преобразовывать существующий код основанный на промисах для использования async/await. Но вы в этом заинтересованы, то VS Code может сделать это за вас.

Обработка ошибок в асинхронных функциях

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

В следующем примере обратите внимание, как я изменил URL, на несуществующий:

async function fetchDataFromApi() {
try {
const res = await fetch('https://non-existent-url.dev');
const json = await res.json();
console.log(json.joke);
} catch (error) {
// Handle the error here in whichever way you like
console.log('Something went wrong!');
console.warn(error)
}
}

await fetchDataFromApi();
console.log('Finished fetching data');

Это приведёт к следующему сообщению в консоли:

Something went wrong!
TypeError: fetch failed
...
cause: Error: getaddrinfo ENOTFOUND non-existent-url.dev
Finished fetching data

Это работает, потому что fetch возвращает промис. Когда операция fetch не выполняется, вызывается метод промиса reject и ключевое слово await конвертирует необрабатываемое отклонение в перехватываемую ошибку.

Тем не менее есть несколько проблем с этим методом. Основная критика заключается в том, что вербально это довольно уродливо. Представьте, что мы строили CRUD приложение, и у нас была отдельная функция для каждого из методов CRUD (create, read, update, destroy). Если каждый из этих методов выполнил асинхронный вызов API, нам придётся каждый вызов обернуть в свой try...catch блок. Это довольно много дополнительного кода.

Другая проблема заключается в том, что если мы не использовали ключевое слово await, это приводит к необрабатываемому отказу промиса.

import { readFile } from 'node:fs/promises';

const getFileContents = function(fileName, enc) {
try {
return readFile(fileName, enc)
} catch (error) {
console.log('Something went wrong!');
console.warn(error)
}
}

const contents = await getFileContents('this-file-does-not-exist.md', 'utf-8');
console.log(contents);

Этот код даст следующий вывод:

node:internal/process/esm_loader:91
internalBinding('errors').triggerUncaughtException(
^
[Error: ENOENT: no such file or directory, open 'this-file-does-not-exist.md'] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: 'this-file-does-not-exist.md'
}

В отличие от await, ключевое слово return не преобразует отклонение промиса в перехватываемые ошибки.

Использование catch() в вызове функции

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

С помощью этого простого дополнения код в приведённом выше примере будет изящно обрабатывать ошибку:

const contents = await getFileContents('this-file-does-not-exist.md', 'utf-8')
.catch((error) => {
console.log('Something went wrong!');
console.warn(error);
});
console.log(contents);

Теперь мы получим следующий вывод:

Something went wrong!
[Error: ENOENT: no such file or directory, open 'this-file-does-not-exist.md'] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: 'this-file-does-not-exist.md'
}
undefined

Что касается стратегии использования, я согласен с советом Валерия Карпова. Используйте try...catch для обработки ожидаемых ошибок внутри асинхронной функции, но обрабатывайте неожиданные ошибки, добавив catch() в вызываемую функцию.

Параллельный запуск асинхронных команд

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

(async () => {
async function getStarCount(repo){
const repoData = await fetch(repo);
const repoJson = await repoData.json()
return repoJson.stargazers_count;
}

const reactStars = await getStarCount('https://api.github.com/repos/facebook/react');
const vueStars = await getStarCount('https://api.github.com/repos/vuejs/core');
console.log(`React has ${reactStars} stars, whereas Vue has ${vueStars} stars`)
})();

Мы выполняем два вызова API для получения количества GitHub звёзд для React и Vue. Хотя это работает просто отлично, у нас нет никаких причин ждать завершения первого промиса, прежде чем мы сделаем второй запрос. Это было бы бутылочным горлышком, если бы мы делали много запросов.

Для решения этой проблемы, мы можем использовать Promise.all, который получает массив промисов и ожидает когда все промисы будут разрешены или кто-то из них получит отказ:

(async () => {
async function getStarCount(repo){
// Такой же как раньше
}

const reactPromise = getStarCount('https://api.github.com/repos/facebook/react');
const vuePromise = getStarCount('https://api.github.com/repos/vuejs/core');
const [reactStars, vueStars] = await Promise.all([reactPromise, vuePromise]);

console.log(`React has ${reactStars} stars, whereas Vue has ${vueStars} stars`);
})();

Намного лучше!

Асинхронные ожидания в синхронных циклах

В какой-то момент вы попытаетесь вызвать асинхронную функцию внутри синхронного цикла. Например:

// Вернуть промис через указанное количество миллисекунд.
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

async function process(array) {
array.forEach(async (el) => {
await sleep(el); // не будет ждать промис
console.log(el);
});
}

const arr = [3000, 1000, 2000];
process(arr);

Это не сработает, как и ожидалось, так как forEach будет только вызывать функцию, не ожидая её завершения, и следующее будет выведено в консоль:

1000
2000
3000

То же самое относится ко многим другим методам массива, таким как map, filter и reduce.

К счастью, ES2018 представил асинхронные итераторы похожие на обычные итераторы, за исключением их метода next() возвращающего промис. Это значит, что мы можем использовать await внутри них. Давайте перепишем приведённый выше код, используя один из новых итераторов — for...of:

async function process(array) {
for (el of array) {
await sleep(el);
console.log(el);
};
}

Теперь функция process выводит всё в правильном порядке:

3000
1000
2000

Как и в нашем предыдущем примере ожидания асинхронного запроса fetch, это также будет влиять на производительность. Каждый await внутри цикла for, буде блокировать event loop (цикл событий JavaScript), и код должен быть оптимизирован, чтобы создать все промисы одновременно, а затем получить доступ к результатам используя Promise.all().

Есть правило ESLint no-await-in-loop сообщающее, если обнаруживает такое поведение.

Top level await

Наконец давайте посмотрим на то, что называется top level await или await верхнего уровня или глобальный await. Далее будем использовать термин await верхнего уровня. Это было введено в язык в ES2022 и было доступно в Node.js по состоянию на v14.8.

Мы уже сталкивались с проблемой на которую направлено это решение, когда запускали код в начале статьи. Помните эту ошибку?

Uncaught SyntaxError: await is only valid in async functions, async generators and modules

Это происходит, когда мы пытаемся использовать await за пределами асинхронной функции. Например, на верхнем уровне нашего кода:

const ms = await Promise.resolve('Hello, World!');
console.log(msg);

await верхнего уровня решает эту проблему, делая вышеуказанный код действительным, но только в ES-модуле. Если мы работаем в браузере, мы могли бы добавить этот код в файл с именем index.js, а затем загрузить его на нашу страницу так:

<script src="index.js" type="module"></script>

И всё будет работать, как и ожидалось — без необходимости в функции обёртке или уродливой IIFE.

Всё становится интереснее в Node.js. Чтобы объявить файл как ES-модуль, мы должны сделать одну из двух вещей. Один из вариантов — сохранить файл с расширением .mjs и запустить его так:

node index.mjs

Другой вариант — установить "type": "module" в файле package.json:

{
"name": "myapp",
"type": "module",
...
}

await высокого уровня также хорошо играет с динамическим импортом — функциональным выражением позволяющим асинхронно загружать ES-модуль. Оно возвращает промис, и этот промис превращается в объект модуля. Что означает, что мы можем сделать что-то вроде этого:

const locale = 'DE';

const { default: greet } = await import(
`${ locale === 'DE' ?
'./de.js' :
'./en.js'
}
`

);

greet();
// Выводит "Hello" или "Guten Tag" в зависимости от значения переменной locale

Опция динамического импорта также хорошо подходит для ленивой загрузки в сочетании с такими фреймворками, как React и Vue. Это позволяет уменьшить первоначальный размер пакета и метрику time to interactive.

Пишите асинхронный код с уверенностью

В этой статье мы рассмотрели, как управлять потоком вашей программы на JavaScript, используя async/await. Мы обсудили синтаксис, как async/await работает под капотом, обработку ошибок и несколько ловушек. Если вы забрались так далеко, вы профессионал.

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

Дополнительные материалы

Предыдущая Статья

JavaScript: Спасение из ада обратных вызовов

Следующая Статья

Laravel: Загрузка файлов с помощью FilePond