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
. Это было введено в язык в ES2022 и было доступно в Node.js по состоянию на v14.8.await
верхнего уровня
Мы уже сталкивались с проблемой на которую направлено это решение, когда запускали код в начале статьи. Помните эту ошибку?
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
работает под капотом, обработку ошибок и несколько ловушек. Если вы забрались так далеко, вы профессионал.
Написание асинхронного кода может быть сложным, особенно для начинающих, но теперь, когда у вас есть чёткое понимание техник, вы сможете использовать их с большей эффективностью.