Время прочтения
9 мин
Просмотры 52K
Автор статьи разбирает на примерах Async/Await в JavaScript. В целом, Async/Await — удобный способ написания асинхронного кода. До появления этой возможности подобный код писали с использованием коллбэков и промисов. Автор оригинальной статьи раскрывает преимущества Async/Await, разбирая различные примеры.
Напоминаем: для всех читателей «Хабра» — скидка 10 000 рублей при записи на любой курс Skillbox по промокоду «Хабр».
Skillbox рекомендует: Образовательный онлайн-курс «Java-разработчик».
Callback
Callback представляет собой функцию, вызов которой отложен на неопределенное время. Раньше обратные вызовы использовались в тех участках кода, где результат не мог быть получен сразу.
Вот пример асинхронного чтения файла на Node.js:
fs.readFile(__filename, 'utf-8', (err, data) => {
if (err) {
throw err;
}
console.log(data);
});
Проблемы возникают в тот момент, когда требуется выполнить сразу несколько асинхронных операций. Давайте представим себе вот такой сценарий: выполняется запрос в БД пользователя Arfat, нужно считать его поле profile_img_url и загрузить картинку с сервера someserver.com.
После загрузки конвертируем изображение в иной формат, например из PNG в JPEG. Если конвертация прошла успешно, на почту пользователя отправляется письмо. Далее информация о событии заносится в файл transformations.log с указанием даты.
Стоит обратить внимание на наложенность обратных вызовов и большое количество }) в финальной части кода. Это называется Callback Hell или Pyramid of Doom.
Недостатки такого способа очевидны:
- Этот код сложно читать.
- В нем также сложно обрабатывать ошибки, что зачастую приводит к ухудшению качества кода.
Для того чтобы решить эту проблему, в JavaScript были добавлены промисы. Они позволяют заменить глубокую вложенность коллбэков словом .then.
Положительным моментом промисов стало то, что с ними код читается гораздо лучше, причем сверху вниз, а не слева направо. Тем не менее у промисов тоже есть свои проблемы:
- Нужно добавлять большое количество .then.
- Вместо try/catch используется .catch для обработки всех ошибок.
- Работа с несколькими промисами в рамках одного цикла далеко не всегда удобна, в некоторых случаях они усложняют код.
Вот задача, которая покажет значение последнего пункта.
Предположим, что есть цикл for, выводящий последовательность чисел от 0 до 10 со случайным интервалом (0–n секунд). Используя промисы, нужно изменить этот цикл таким образом, чтобы числа выводились в последовательности от 0 до 10. Так, если вывод нуля занимает 6 секунд, а единицы — 2 секунды, сначала должен быть выведен ноль, а потом уже начнется отсчет вывода единицы.
И конечно, для решения этой задачи мы не используем Async/Await либо .sort. Пример решения — в конце.
Async-функции
Добавление async-функций в ES2017 (ES8) упростило задачу работы с промисами. Отмечу, что async-функции работают «поверх» промисов. Эти функции не представляют собой качественно другие концепции. Async-функции задумывались как альтернатива коду, который использует промисы.
Async/Await дает возможность организовать работу с асинхронным кодом в синхронном стиле.
Таким образом, знание промисов облегчает понимание принципов Async/Await.
Синтаксис
В обычной ситуации он состоит из двух ключевых слов: async и await. Первое слово и превращает функцию в асинхронную. В таких функциях разрешается использование await. В любом другом случае использование этой функции вызовет ошибку.
// With function declaration
async function myFn() {
// await ...
}
// With arrow function
const myFn = async () => {
// await ...
}
function myFn() {
// await fn(); (Syntax Error since no async)
}
Async вставляется в самом начале объявления функции, а в случае использования стрелочной функции — между знаком «=» и скобками.
Эти функции можно поместить в объект в качестве методов либо же использовать в объявлении класса.
// As an object's method
const obj = {
async getName() {
return fetch('https://www.example.com');
}
}
// In a class
class Obj {
async getResource() {
return fetch('https://www.example.com');
}
}
NB! Стоит помнить, что конструкторы класса и геттеры/сеттеры не могут быть асинхронными.
Семантика и правила выполнения
Async-функции, в принципе, похожи на стандартные JS-функции, но есть и исключения.
Так, async-функции всегда возвращают промисы:
async function fn() {
return 'hello';
}
fn().then(console.log)
// hello
В частности, fn возвращает строку hello. Ну а поскольку это асинхронная функция, то значение строки обертывается в промис при помощи конструктора.
Вот альтернативная конструкция без Async:
function fn() {
return Promise.resolve('hello');
}
fn().then(console.log);
// hello
В этом случае возвращение промиса производится «вручную». Асинхронная функция всегда обертывается в новый промис.
В том случае, если возвращаемое значение — примитив, async-функция выполняет возврат значения, обертывая его в промис. В том случае, если возвращаемое значение и есть объект промиса, его решение возвращается в новом промисе.
const p = Promise.resolve('hello')
p instanceof Promise;
// true
Promise.resolve(p) === p;
// true
Но что произойдет в том случае, если внутри асинхронной функции окажется ошибка?
async function foo() {
throw Error('bar');
}
foo().catch(console.log);
Если она не будет обработана, foo() вернет промис с реджектом. В этой ситуации вместо Promise.resolve вернется Promise.reject, содержащий ошибку.
Async-функции на выходе всегда дают промис, вне зависимости от того, что возвращается.
Асинхронные функции приостанавливаются при каждом await .
Await влияет на выражения. Так, если выражение является промисом, async-функция приостанавливается до момента выполнения промиса. В том случае, если выражение не является промисом, оно конвертируется в промис через Promise.resolve и потом завершается.
// utility function to cause delay
// and get random value
const delayAndGetRandom = (ms) => {
return new Promise(resolve => setTimeout(
() => {
const val = Math.trunc(Math.random() * 100);
resolve(val);
}, ms
));
};
async function fn() {
const a = await 9;
const b = await delayAndGetRandom(1000);
const c = await 5;
await delayAndGetRandom(1000);
return a + b * c;
}
// Execute fn
fn().then(console.log);
А вот описание того, как работает fn-функция.
- После ее вызова первая строка конвертируется из const a = await 9; в const a = await Promise.resolve(9);.
- После использования Await выполнение функции приостанавливается, пока а не получает свое значение (в текущей ситуации это 9).
- delayAndGetRandom(1000) приостанавливает выполнение fn-функции, пока не завершится сама (после 1 секунды). Это фактически является остановкой fn-функции на 1 секунду.
- delayAndGetRandom(1000) через resolve возвращает случайное значение, которое затем присваивается переменной b.
- Ну а случай с переменной с аналогичен случаю с переменной а. После этого все останавливается на секунду, но теперь delayAndGetRandom(1000) ничего не возвращает, поскольку этого не требуется.
- В итоге значения считаются по формуле a + b * c. Результат же обертывается в промис при помощи Promise.resolve и возвращается функцией.
Эти паузы могут напоминать генераторы в ES6, но этому есть свои причины.
Решаем задачу
Ну а теперь давайте рассмотрим решение задачи, которая была указана выше.
В функции finishMyTask используется Await для ожидания результатов таких операций, как queryDatabase, sendEmail, logTaskInFile и других. Если же сравнивать это решение с тем, где использовались промисы, станет очевидным сходство. Тем не менее версия с Async/Await довольно сильно упрощает все синтаксические сложности. В этом случае нет большого количества коллбэков и цепочек вроде .then/.catch.
Вот решение с выводом чисел, здесь есть два варианта.
const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
let pr = Promise.resolve(0);
for (let i = 1; i <= 10; i += 1) {
pr = pr.then((val) => {
console.log(val);
return wait(i, Math.random() * 1000);
});
}
resolve(pr);
});
// Implementation Two (Using Recursion)
const printNumbersRecursive = () => {
return Promise.resolve(0).then(function processNextPromise(i) {
if (i === 10) {
return undefined;
}
return wait(i, Math.random() * 1000).then((val) => {
console.log(val);
return processNextPromise(i + 1);
});
});
};
А вот решение с использованием async-функций.
async function printNumbersUsingAsync() {
for (let i = 0; i < 10; i++) {
await wait(i, Math.random() * 1000);
console.log(i);
}
}
Обработка ошибок
Необработанные ошибки обертываются в rejected промис. Тем не менее в async-функциях можно использовать конструкцию try/catch для того, чтобы выполнить синхронную обработку ошибок.
async function canRejectOrReturn() {
// wait one second
await new Promise(res => setTimeout(res, 1000));
// Reject with ~50% probability
if (Math.random() > 0.5) {
throw new Error('Sorry, number too big.')
}
return 'perfect number';
}
canRejectOrReturn() — это асинхронная функция, которая либо удачно выполняется (“perfect number”), либо неудачно завершается с ошибкой (“Sorry, number too big”).
async function foo() {
try {
await canRejectOrReturn();
} catch (e) {
return 'error caught';
}
}
Поскольку в примере выше ожидается выполнение canRejectOrReturn, то собственное неудачное завершение повлечет за собой исполнение блока catch. В результате функция foo завершится либо с undefined (когда в блоке try ничего не возвращается), либо с error caught. В итоге у этой функции не будет неудачного завершения, поскольку try/catch займется обработкой самой функции foo.
Вот еще пример:
async function foo() {
try {
return canRejectOrReturn();
} catch (e) {
return 'error caught';
}
}
Стоит уделить внимание тому, что в примере из foo возвращается canRejectOrReturn. Foo в этом случае завершается либо perfect number, либо возвращается ошибка Error (“Sorry, number too big”). Блок catch никогда не будет исполняться.
Проблема в том, что foo возвращает промис, переданный от canRejectOrReturn. Поэтому решение функции foo становится решением для canRejectOrReturn. В этом случае код будет состоять всего из двух строк:
try {
const promise = canRejectOrReturn();
return promise;
}
А вот что будет, если использовать вместе await и return:
async function foo() {
try {
return await canRejectOrReturn();
} catch (e) {
return 'error caught';
}
}
В коде выше foo удачно завершится как с perfect number, так и с error caught. Здесь отказов не будет. Но foo завершится с canRejectOrReturn, а не с undefined. Давайте убедимся в этом, убрав строку return await canRejectOrReturn():
try {
const value = await canRejectOrReturn();
return value;
}
// …
Распространенные ошибки и подводные камни
В некоторых случаях использование Async/Await может приводить к ошибкам.
Забытый await
Такое случается достаточно часто — перед промисом забывается ключевое слово await:
async function foo() {
try {
canRejectOrReturn();
} catch (e) {
return 'caught';
}
}
В коде, как видно, нет ни await, ни return. Поэтому foo всегда завершается с undefined без задержки в 1 секунду. Но промис будет выполняться. Если же он выдает ошибку или реджект, то в этом случае будет вызываться UnhandledPromiseRejectionWarning.
Async-функции в обратных вызовах
Async-функции довольно часто используются в .map или .filter в качестве коллбэков. В качестве примера можно привести функцию fetchPublicReposCount(username), которая возвращает количество открытых на GitHub репозиториев. Допустим, есть три пользователя, чьи показатели нам нужны. Вот код для этой задачи:
const url = 'https://api.github.com/users';
// Utility fn to fetch repo counts
const fetchPublicReposCount = async (username) => {
const response = await fetch(`${url}/${username}`);
const json = await response.json();
return json['public_repos'];
}
Нам нужны аккаунты ArfatSalman, octocat, norvig. В этом случае выполняем:
const users = [
'ArfatSalman',
'octocat',
'norvig'
];
const counts = users.map(async username => {
const count = await fetchPublicReposCount(username);
return count;
});
Стоит обратить внимание на Await в обратном вызове .map. Здесь counts — массив промисов, ну а .map — анонимный обратный вызов для каждого указанного пользователя.
Чрезмерно последовательное использование await
В качестве примера возьмем такой код:
async function fetchAllCounts(users) {
const counts = [];
for (let i = 0; i < users.length; i++) {
const username = users[i];
const count = await fetchPublicReposCount(username);
counts.push(count);
}
return counts;
}
Здесь в переменную count помещается число репо, затем это число добавляется в массив counts. Проблема кода в том, что пока с сервера не придут данные первого пользователя, все последующие пользователи будут находиться в режиме ожидания. Таким образом, в единый момент обрабатывается лишь один пользователь.
Если, например, на обработку одного пользователя уходит около 300 мс, то для всех пользователей это уже секунда, затрачиваемое время линейно зависит от числа пользователей. Но раз получение количества репо не зависит друг от друга, процессы можно распараллелить. Для этого нужна работа с .map и Promise.all:
async function fetchAllCounts(users) {
const promises = users.map(async username => {
const count = await fetchPublicReposCount(username);
return count;
});
return Promise.all(promises);
}
Promise.all на входе получает массив промисов с возвращением промиса. Последний после завершения всех промисов в массиве или при первом реджекте завершается. Может случиться так, что все они не запустятся одновременно, — для того чтобы обеспечить одновременный запуск, можно использовать p-map.
Заключение
Async-функции становятся все более важными для разработки. Ну а для адаптивного использования async-функций стоит воспользоваться Async Iterators. JavaScript-разработчик должен хорошо разбираться в этом.
Skillbox рекомендует:
- Практический курс «Мобильный разработчик PRO».
- Прикладной онлайн-курс «Аналитик данных на Python».
- Двухлетний практический курс «Я — Веб-разработчик PRO».
Объявление async function
определяет асинхронную функцию, которая возвращает объект AsyncFunction
.
Вы также можете определить async-функции, используя выражение async function
.
Синтаксис
async function name([param[, param[, ... param]]]) { statements }
name
-
Имя функции.
param
-
Имя аргумента, который будет передан в функцию.
statements
-
Выражение, содержащее тело функции.
Описание
После вызова функция async
возвращает Promise
. Когда результат был получен, Promise
завершается, возвращая полученное значение. Когда функция async
выбрасывает исключение, Promise
ответит отказом с выброшенным (throws
) значением.
Функция async может содержать выражение await
, которое приостанавливает выполнение функции async и ожидает ответа от переданного Promise
, затем возобновляя выполнение функции async
и возвращая полученное значение.
Ключевое слово await
допустимо только в асинхронных функциях. В другом контексте вы получите ошибку SyntaxError
.
Примечание: Цель функций async/await упростить использование promises синхронно и воспроизвести некоторое действие над группой Promises
. Точно так же как Promises
подобны структурированным колбэкам, async/await подобна комбинации генераторов и promises.
Примеры
Простой пример
function resolveAfter2Seconds(x) {
return new Promise(resolve => {
setTimeout(() => {
resolve(x);
}, 2000);
});
}
async function add1(x) {
const a = await resolveAfter2Seconds(20);
const b = await resolveAfter2Seconds(30);
return x + a + b;
}
add1(10).then(v => {
console.log(v); // prints 60 after 4 seconds.
});
async function add2(x) {
const a = resolveAfter2Seconds(20);
const b = resolveAfter2Seconds(30);
return x + await a + await b;
}
add2(10).then(v => {
console.log(v); // prints 60 after 2 seconds.
});
Предупреждение: #### Не путайте await и Promise.allФункция add1
приостанавливается на 2 секунды для первого await
и ещё на 2 для второго. Второй таймер создаётся только после срабатывания первого. В функции add2
создаются оба и оба же переходят в состояние await
. В результате функция add2
завершится скорее через две, чем через четыре секунды, поскольку таймеры работают одновременно. Однако запускаются они всё же не параллельно, а друг за другом — такая конструкция не означает автоматического использования Promise.all
. Если два или более Promise должны разрешаться параллельно, следует использовать Promise.all
.
Когда функция async
выбрасывает исключение
async function throwsValue() {
throw new Error('oops');
}
throwsValue()
.then((resolve) => {
console.log("resolve:" + resolve);
},
(reject) => {
console.log("reject:" + reject);
});
//prints "reject:Error: oops"
//or
throwsValue()
.then((resolve) => {
console.log("resolve:" + resolve);
})
.catch((reject) => {
console.log("reject:" + reject);
});
//prints "reject:Error: oops"
Перепись цепочки promise с использованием функции async
API, которое возвращает Promise
, будет возвращать значение в цепочке, тем самым разбивая функцию на много частей. Рассматривая следующий код:
function getProcessedData(url) {
return downloadData(url) // returns a promise
.catch(e => {
return downloadFallbackData(url) // returns a promise
})
.then(v => {
return processDataInWorker(v); // returns a promise
});
}
он может быть переписан с одним использованием функции async
:
async function getProcessedData(url) {
let v;
try {
v = await downloadData(url);
} catch(e) {
v = await downloadFallbackData(url);
}
return processDataInWorker(v);
}
Заметьте, что пример выше не содержит await
на return
, потому что возвращаемое значение функции async
неявно обёрнуто в Promise.resolve
.
Спецификации
Specification |
---|
ECMAScript Language Specification # sec-async-function-definitions |
Поддержка браузерами
BCD tables only load in the browser
Смотрите также
This article is intended to suggest a better way to handle errors when using async/await syntax. Prior knowledge of how promises work is important.
From Callback Hell to Promises
Callback Hell, also known as Pyramid of Doom, is an anti-pattern seen in code of programmers who are not wise in the ways of asynchronous programming. — Colin Toh
Callback hell makes your code drift to the right instead of downward due to multiple nesting of callback functions.
I wont go into details of what callback hell is, but I’ll give an example of how it looks.
User profile example 1
// Code that reads from left to right
// instead of top to bottom
let user;
let friendsOfUser;
getUser(userId, function(data) {
user = data;
getFriendsOfUser(userId, function(friends) {
friendsOfUser = friends;
getUsersPosts(userId, function(posts) {
showUserProfilePage(user, friendsOfUser, posts, function() {
// Do something here
});
});
});
});
Enter fullscreen mode
Exit fullscreen mode
Promises
Promises were introduced to the Javascript(ES6) language to handle asynchronous operations better without it turning into a callback hell.
The example below use promises to solve callback hell by using multiple chained .then
calls instead of nesting callbacks.
User profile example 2
// A solution with promises
let user;
let friendsOfUser;
getUser().then(data => {
user = data;
return getFriendsOfUser(userId);
}).then(friends => {
friendsOfUser = friends;
return getUsersPosts(userId);
}).then(posts => {
showUserProfilePage(user, friendsOfUser, posts);
}).catch(e => console.log(e));
Enter fullscreen mode
Exit fullscreen mode
The solution with promise looks cleaner and more readable.
Promises with with async/await
Async/await is a special syntax to work with promises in a more concise way.
Adding async before any function
turns the function into a promise.
All
async
functions return promises.
Example
// Arithmetic addition function
async function add(a, b) {
return a + b;
}
// Usage:
add(1, 3).then(result => console.log(result));
// Prints: 4
Enter fullscreen mode
Exit fullscreen mode
Making the User profile example 2
look even better using async/await
User profile example 3
async function userProfile() {
let user = await getUser();
let friendsOfUser = await getFriendsOfUser(userId);
let posts = await getUsersPosts(userId);
showUserProfilePage(user, friendsOfUser, posts);
}
Enter fullscreen mode
Exit fullscreen mode
Wait! there’s a problem
If theres a promise rejection in any of the request in User profile example 3
, Unhandled promise rejection
exception will be thrown.
Before now Promise rejections didn’t throw errors. Promises with unhandled rejections used to fail silently, which could make debugging a nightmare.
Thank goodness promises now throws when rejected.
-
Google Chrome throws:
VM664:1 Uncaught (in promise) Error
-
Node will throw something like:
(node:4796) UnhandledPromiseRejectionWarning: Unhandled promise rejection (r ejection id: 1): Error: spawn cmd ENOENT
[1] (node:4796) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
No promise should be left uncaught. — Javascript
Notice the .catch
method in User profile example 2
.
Without the .catch block Javascript will throw Unhandled promise rejection
error when a promise is rejected.
Solving this issue in User profile example 3
is easy. Unhandled promise rejection
error can be prevented by wrapping await operations in a try…catch block:
User profile example 4
async function userProfile() {
try {
let user = await getUser();
let friendsOfUser = await getFriendsOfUser(userId);
let posts = await getUsersPosts(userId);
showUserProfilePage(user, friendsOfUser, posts);
} catch(e) {
console.log(e);
}
}
Enter fullscreen mode
Exit fullscreen mode
Problem solved!
…But error handling could be improved
How do you know with error is from which async request?
We can call a .catch
method on the async requests to handle errors.
User profile example 5
let user = await getUser().catch(e => console.log('Error: ', e.message));
let friendsOfUser = await getFriendsOfUser(userId).catch(e => console.log('Error: ', e.message));
let posts = await getUsersPosts(userId).catch(e => console.log('Error: ', e.message));
showUserProfilePage(user, friendsOfUser, posts);
Enter fullscreen mode
Exit fullscreen mode
The solution above will handle individual errors from the requests, but its a mix of patterns. There should be a cleaner way to use async/await without using .catch
method (Well, you could if you don’t mind).
Here’s my solution to a better async/await error handling
User profile example 6
/**
* @description ### Returns Go / Lua like responses(data, err)
* when used with await
*
* - Example response [ data, undefined ]
* - Example response [ undefined, Error ]
*
*
* When used with Promise.all([req1, req2, req3])
* - Example response [ [data1, data2, data3], undefined ]
* - Example response [ undefined, Error ]
*
*
* When used with Promise.race([req1, req2, req3])
* - Example response [ data, undefined ]
* - Example response [ undefined, Error ]
*
* @param {Promise} promise
* @returns {Promise} [ data, undefined ]
* @returns {Promise} [ undefined, Error ]
*/
const handle = (promise) => {
return promise
.then(data => ([data, undefined]))
.catch(error => Promise.resolve([undefined, error]));
}
async function userProfile() {
let [user, userErr] = await handle(getUser());
if(userErr) throw new Error('Could not fetch user details');
let [friendsOfUser, friendErr] = await handle(
getFriendsOfUser(userId)
);
if(friendErr) throw new Error('Could not fetch user's friends');
let [posts, postErr] = await handle(getUsersPosts(userId));
if(postErr) throw new Error('Could not fetch user's posts');
showUserProfilePage(user, friendsOfUser, posts);
}
Enter fullscreen mode
Exit fullscreen mode
Using the handle
utility function, we are able to avoid Unhandled promise rejection
error and also handle error granularly.
Explanation
The handle
utility function takes a promise as an argument and always resolves it, returning an array with [data|undefined, Error|undefined]
.
- If the promise passed to the
handle
function resolves it returns[data, undefined]
; - If it was rejected, the
handle
function still resolves it and returns[undefined, Error]
Similar solutions
- Easier Error Handling Using Async/Await — Jesse Warden
- NPM Package — await-to-js
Conclusion
Async/await has a clean syntax, but you still have to handle thrown exceptions in async functions.
Handling error with .catch
in promise .then
chain can be difficult unless you implement custom error classes.
Using the handle
utility function, we are able to avoid Unhandled promise rejection
error and also handle error granularly.
Callback — это не что-то замысловатое или особенное, а просто функция, вызов которой отложен на неопределённое время. Благодаря асинхронному характеру JavaScript, обратные вызовы нужны были везде, где результат не может быть получен сразу.
Ниже приведён пример асинхронного чтения файла на Node.js:
fs.readFile(__filename, 'utf-8', (err, data) => {
if (err) {
throw err;
}
console.log(data);
});
Проблемы начинаются, когда нужно выполнить несколько асинхронных операций. Просто представьте себе подобный сценарий:
- Выполняется запрос в БД на некого пользователя
Arfat
. Нужно считать его полеprofile_img_url
и загрузить соответствующее изображение с сервераsomeServer.ru
. - После загрузки изображения необходимо его конвертировать, допустим из PNG в JPEG.
- В случае успешной конвертации нужно отправить письмо на почту пользователя.
- Это событие нужно занести в файл
transformations.log
и указать дату.
queryDatabase({ username: 'Arfat'}, (err, user) => {
// Обработка ошибок при запросе в БД
const image_url = user.profile_img_url;
getImageByURL('someServer.com/q=${image_url}', (err, image) => {
// Обработка ошибок получения изображения
transformImage(image, (err, transformedImage) => {
// Обработка ошибок конвертирования
sendEmail(user.email, (err) => {
// Обработка ошибок отсылки по почте
logTaskInFile('Конвертирование файла и отсылка по почте', (err)
// Обработка ошибок лога
})
})
})
})
})
Обратите внимание на вложенность обратных вызовов и пирамиду из })
в конце. Подобные случаи принято называть Callback Hell или Pyramid of Doom. Вот основные недостатки:
- Такой код сложно читать.
- В таком коде сложно обрабатывать ошибки и одновременно сохранять его «качество».
Для решения этой проблемы в JavaScript были придуманы промисы (англ. promises). Теперь глубокую вложенность коллбэков можно заменить ключевым словом then
:
queryDatabase({ username: 'Arfat'})
.then((user) => {
const image_url = user.profile_img_url;
return getImageByURL('someServer.com/q=${image_url}')
.then(image => transformImage(image))
.then(() => sendEmail(user.email))
})
.then(() => logTaskInFile('...'))
.catch(() => handleErrors()) // Обработка ошибок
Код стал читаться сверху вниз, а не слева направо, как это было в случае с обратными вызовами. Это плюс к читаемости. Однако и у промисов есть свои проблемы:
- Всё ещё нужно работать с кучей
.then
. - Вместо обычного
try/catch
нужно использовать.catch
для обработки всех ошибок. - Работа с несколькими промисами в цикле не всегда интуитивно понятна и местами сложна.
В качестве демонстрации последнего пункта попробуйте выполнить такое задание:
Предположим, что у вас есть цикл for
, который выводит последовательность чисел от 0 до 10 со случайным интервалом (от 0 до n секунд). Используя промисы нужно изменить цикл так, чтобы числа выводились в строгой последовательности от 0 до 10. К примеру, если вывод нуля занимает 6 секунд, а единицы 2 секунды, то единица должна дождаться вывода нуля и только потом начать свой отсчёт (чтобы соблюдать последовательность).
Стоит ли говорить, что в решении этой задачи нельзя использовать конструкцию async/await
либо .sort
функцию? Решение будет в конце.
Добавление async-функций в ES2017 (ES8) сделало работу с промисами легче.
- Важно отметить, что async-функции работают поверх промисов.
- Эти функции не являются принципиально другими концепциями.
- Async-функции были задуманы как альтернатива коду, использующему промисы.
- Используя конструкцию async/await, можно полностью избежать использование цепочек промисов.
- С помощью async-функций возможно организовать работу с асинхронным кодом в синхронном стиле.
Как видите, знание промисов всё же необходимо для понимания работы async/await.
Синтаксис
Синтаксис состоит из двух ключевых слов: async
и await
. Первое делает функцию асинхронной. Именно в таких функциях разрешается использование await
. Использование await
в любом другом случае вызовет ошибку.
// В объявлении функции
async function myFn() {
// await ...
}
// В стрелочной функции
const myFn = async () => {
// await ...
}
function myFn() {
// await fn(); (синтаксическая ошибка, т. к. нет async)
}
Обратите внимание, что async
вставляется в начале объявления функции, а в случае стрелочной функции — между знаком =
и скобками.
Async-функции могут быть помещены в объект в качестве методов или же просто использоваться в объявлении класса.
// В качестве метода объекта
const obj = {
async getName() {
return fetch('https://www.example.com');
}
}
// В самом классе
class Obj {
async getResource() {
return fetch('https://www.example.com');
}
}
Примечание Конструкторы класса и геттеры/сеттеры не могут быть асинхронными.
Семантика и правила выполнения
Async-функции похожи на обычные функции в JavaScript, за исключением нескольких вещей:
Async-функции всегда возвращают промисы
async function fn() {
return 'hello';
}
fn().then(console.log)
// hello
Функция fn
возвращает строку 'hello'
. Т. к. это асинхронная функция, значение строки обёртывается в промис (с помощью конструктора).
Код выше можно переписать и без использования async
:
function fn() {
return Promise.resolve('hello');
}
fn().then(console.log);
// hello
В таком случае, вместо async
, код вручную возвращает промис.
Тело асинхронной функции всегда обёртывается в новый промис
Если возвращаемое значение является примитивом, async-функция возвращает это значение, обёрнутое в промис. Но если возвращаемое значение и есть объект промиса, его решение возвращается в новом промисе.
// В случае примитивного типа значения
const p = Promise.resolve('hello')
p instanceof Promise;
// true
// p возвращается как есть
Promise.resolve(p) === p;
// true
Что происходит, когда внутри асинхронной функции возникает какая-нибудь ошибка?
async function foo() {
throw Error('bar');
}
foo().catch(console.log);
Если ошибка не будет обработана, foo()
вернёт промис с реджектом. В таком случае вместо Promise.resolve
вернётся Promise.reject
, содержащий ошибку.
Суть async-функций в том, что что бы вы не возвращали, на выходе вы всегда будете получать промис.
Асинхронные функции приостанавливаются при каждом await выражении
await
сказывается на выражениях. Если выражение является промисом, то async-функция будет приостановлена до тех пор, пока промис не выполнится. Если же выражение не является промисом, то оно конвертируется в промис через Promise.resolve
и потом завершается.
// Функция задержки
// с возвращением случайного числа
const delayAndGetRandom = (ms) => {
return new Promise(resolve => setTimeout(
() => {
const val = Math.trunc(Math.random() * 100);
resolve(val);
}, ms
));
};
async function fn() {
const a = await 9;
const b = await delayAndGetRandom(1000);
const c = await 5;
await delayAndGetRandom(1000);
return a + b * c;
}
// Вызов fn
fn().then(console.log);
Как работает fn
функция?
- После вызова
fn
функции первая строка конвертируется изconst a = await 9;
вconst a = await Promise.resolve(9);
. - После использования
await
, выполнение функции приостанавливается, покаa
не получит своё значение (в данном случае это 9). delayAndGetRandom(1000)
приостанавливает выполнениеfn
функции, пока не завершится сама (после 1 секунды). Это, фактически, можно назвать остановкойfn
функции на 1 секунду.- Также
delayAndGetRandom(1000)
черезresolve
возвращает случайное значение, которое присваивается переменнойb
. - Случай с переменной
c
идентичен случаю переменнойa
. После этого опять происходит пауза на 1 секунду, но теперьdelayAndGetRandom(1000)
ничего не возвращает, т. к. этого не требуется. - Под конец эти значения считаются по формуле
a + b * c
. Результат обёртывается в промис с помощьюPromise.resolve
и возвращается функцией.
Примечание Если такие паузы напоминают вам генераторы в ES6, то на это есть свои причины.
Решение задачи
Вот решение задачи, поставленной в начале статьи, с использованием async/await.
async function finishMyTask() {
try {
const user = await queryDatabase({ username: 'Arfat' });
const image_url = user.profile_img_url;
const image = await getImageByURL('someServer.com/q=${image_url}');
const transformedlmage = await transformImage(image);
await sendEmail(user.email);
await logTaskInFile(' ... ');
} catch(err) {
// Обработка всех ошибок
}
}
В функции finishMyTask
используется await
для ожидания результатов таких операций, как queryDatabase
, sendEmail
, logTaskInFile
и т. д. Если сравнить это решение с решением, использовавшим промисы, то вы обратите внимание на их сходство. Однако версия с async/await упрощает синтаксические сложности. В этом способе нет кучи коллбэков и цепочек .then
/.catch
.
Вот то решение с выводом чисел. Тут есть два способа:
const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
// Решение #1 (с использованием цикла for)
const printNumbers = () => new Promise((resolve) => {
let pr = Promise.resolve(0);
for (let i = 1; i <= 10; i += 1) {
pr = pr.then((val) => {
console.log(val);
return wait(i, Math.random() * 1000);
});
}
resolve(pr);
});
// Решение #2 (с использованием рекурсии)
const printNumbersRecursive = () => {
return Promise.resolve(0).then(function processNextPromise(i) {
if (i === 10) {
return undefined;
}
return wait(i, Math.random() * 1000).then((val) => {
console.log(val);
return processNextPromise(i + 1);
});
});
};
С использованием async-функций решение поставленной задачи упрощается до безобразия:
async function printNumbersUsingAsync() {
for (let i = 0; i < 10; i++) {
await wait(i, Math.random() * 1000);
console.log(i);
}
}
Обработка ошибок
Как было сказано выше, необработанные ошибки обёртываются в неудачный (rejected) промис. Но в async-функциях всё ещё можно использовать конструкцию try-catch
для синхронной обработки ошибок.
async function canRejectOrReturn() {
// Ждём секунду
await new Promise(res => setTimeout(res, 1000));
// Реджектим в 50% случае
if (Math.random() > 0.5) {
throw new Error('Простите, число больше, чем нужно.')
}
return 'Число подошло';
}
canRejectOrReturn()
— это асинхронная функция, которая будет удачно завершатся с 'Число подошло'
, либо неудачно завершаться с Error('Простите, число больше, чем нужно.')
.
async function foo() {
try {
await canRejectOrReturn();
} catch (e) {
return 'Ошибка обработана';
}
}
Поскольку в коде выше ожидается выполнение canRejectOrReturn
, то его собственное неудачное завершение вызовет исполнение блока catch
. Поэтому функция foo
завершится либо с undefined
(т. к. в блоке try
ничего не возвращается), либо с 'Ошибка обработана'
. Поэтому у этой функции не будет неудачного завершения, т. к. try-catch
блок будет обрабатывать ошибку самой функции foo
.
Вот другой пример:
async function foo() {
try {
return canRejectOrReturn();
} catch (e) {
return 'Ошибка обработана';
}
}
Обратите внимание, что в коде выше из foo
возвращается (без ожидания) canRejectOrReturn
. foo
завершится либо с 'число подошло'
, либо с реджектом Простите, число больше, чем нужно.
‘). Блок catch
никогда не будет исполняться.
Это происходит из-за того, что foo
возвращает промис, который передан от canRejectOrReturn
. Следовательно, решение функции foo
становится решением canRejectOrReturn
. Такой код можно представить всего в двух строках:
try {
const promise = canRejectOrReturn();
return promise;
}
Вот что получится, если использовать await
и return
разом:
async function foo() {
try {
return await canRejectOrReturn();
} catch (e) {
return 'Ошибка обработана';
}
}
В коде выше foo
будет удачно завершаться и с 'число подошло'
, и с 'Ошибка обработана'
. В таком коде реджектов не будет. Но в отличие от одного из примеров выше, foo
завершится со значением canRejectOrReturn
, а не с undefined
.
Вы можете убедиться в этом сами, убрав строку return await canRejectOrReturn()
:
try {
const value = await canRejectOrReturn();
return value;
}
// ...
Популярные ошибки и подводные камни
Из-за сложных манипуляций с промисами и async/await концепциями вы можете встретиться с различными тонкостями, что может привести к ошибкам.
Не забывайте await
Частая ошибка заключается в том, что перед промисом забывается ключевое слово await
:
async function foo() {
try {
canRejectOrReturn();
} catch (e) {
return 'Обработка';
}
}
Обратите внимание, здесь не используется ни await
, ни return
. Функция foo
всегда будет завершаться с undefined
(без задержки в 1 секунду). Тем не менее, промис будет выполняться. Если промис будет выдавать ошибку либо реджект, то будет вызываться UnhandledPromiseRejectionWarning
.
async-функции в обратных вызовах
async-функции часто используются в .map
или .filter
в качестве коллбэков. Вот пример — допустим, существует функция fetchPublicReposCount(username)
, которая возвращает количество открытых репозиториев на GitHub. Есть 3 пользователя, чьи показатели нужно взять. Используется такой код:
const url = 'https://api.github.com/users';
// Получает количество открытых репозиториев
const fetchPublicReposCount = async (username) => {
const response = await fetch(`${url}/${username}`);
const json = await response.json();
return json['public_repos'];
}
И для того, чтобы получить количество репозиториев пользователей (['ArfatSalman', 'octocat', 'norvig']
), код должен выглядеть как-то так:
const users = [
'ArfatSalman',
'octocat',
'norvig'
];
const counts = users.map(async username => {
const count = await fetchPublicReposCount(username);
return count;
});
Обратите внимание на слово await
в обратном вызове функции .map
. Можно было бы ожидать, что переменная counts
будет содержать число — количество репозиториев. Но как было сказано ранее, все async-функции возвращают промисы. Следовательно, counts
будет массивом промисов. .map
вызывает анонимной коллбэк для каждого пользователя.
Слишком последовательное использование await
Допустим, есть такой код:
async function fetchAllCounts(users) {
const counts = [];
for (let i = 0; i < users.length; i++) {
const username = users[i];
const count = await fetchPublicReposCount(username);
counts.push(count);
}
return counts;
}
В переменную count
помещается количество репозиториев, потом это количество добавляется в массив counts
. Проблема этого кода в том, что пока с сервера не придут данные первого пользователя, все последующие пользователи будут находиться в ожидании. Получается, что в один момент времени обрабатывается только один пользователь.
Если на обработку одного пользователя будет уходить 300 мс, то на всех пользователей уйдёт почти секунда. В этом случае затрачиваемое время будет линейно зависеть от количества пользователей. Поскольку получение количества репозиториев не зависит друг от друга, то можно распараллелить эти процессы. Тогда пользователи будут обрабатываться одновременно, а не последовательно. Для этого понадобятся .map
и Promise.all
.
async function fetchAllCounts(users) {
const promises = users.map(async username => {
const count = await fetchPublicReposCount(username);
return count;
});
return Promise.all(promises);
}
Promise.all
на входе получает массив промисов и возвращает промис. Возвращаемый промис завершается после окончания всех промисов в массиве либо при первом реджекте. Возможно, все эти промисы не запустятся строго одновременно. Чтобы добиться строгого параллелизма, взгляните на p-map. А если нужно, чтобы async-функции были более адаптивными, посмотрите на Async Iterators.
Перевод статьи «Deeply Understanding JavaScript Async and Await with Examples»
Error handling in async/await causes a lot of confusion. There are
numerous patterns for handling errors in async functions, and even experienced developers sometimes
get it wrong.
Suppose you have an async function run()
. In this article,
I’ll describe 3 different patterns
for handling errors in run()
: try/catch
, Golang-style,
and catch()
on the function call. I’ll also explain why you
rarely need anything but catch()
with async functions.
try/catch
When you’re first getting started with async/await, it is tempting
to use try/catch
around every async operation. That’s because
if you await
on a promise that rejects, JavaScript throws a
catchable error.
run();
async function run() {
try {
await Promise.reject(new Error('Oops!'));
} catch (error) {
error.message; // "Oops!"
}
}
try/catch
also handles synchronous errors.
run();
async function run() {
const v = null;
try {
await Promise.resolve('foo');
v.thisWillThrow;
} catch (error) {
// "TypeError: Cannot read property 'thisWillThrow' of null"
error.message;
}
}
So all you need to do is wrap all your logic in a try/catch
,
right? Not quite. The below code will result in an
unhandled promise rejection. The await
keyword converts promise rejections to
catchable errors, but return
does not.
run();
async function run() {
try {
// Note that this is a `return`, not `await`
return Promise.reject(new Error('Oops!'));
} catch (error) {
// Will **not** run
}
}
You could work around this limitation using return await
.
However, it is easy to forget return await
.
Another disadvantage is that try/catch
is hard to compose.
Once you realize that try/catch
handles sync and async errors,
it is tempting to wrap all your async logic in one try/catch
,
as shown below.
Golang in JS
Another common pattern is using .then()
to convert a promise
that rejects into a promise that fulfills with an error. You can
then use an if (err)
check like in Golang.
run();
async function throwAnError() {
throw new Error('Oops!');
}
async function noError() {
return 42;
}
async function run() {
// The `.then(() => null, err => err)` pattern gives you an
// error if one occurred, or `null` otherwise
let err = await throwAnError().then(() => null, err => err);
if (err != null) {
err.message; // 'Oops'
}
err = await noError().then(() => null, err => err);
err; // null
}
If you need both the error and the value, you can really pretend
to write Golang in JavaScript.
run();
async function throwAnError() {
throw new Error('Oops!');
}
async function noError() {
return 42;
}
async function run() {
// The `.then(v => [null, v], err => [err, null])` pattern
// lets you use array destructuring to get both the error and
// the result
let [err, res] = await throwAnError().
then(v => [null, v], err => [err, null]);
if (err != null) {
err.message; // 'Oops'
}
[err, res] = await noError().
then(v => [null, v], err => [err, null]);
err; // null
res; // 42
}
This pattern can be neater syntactically because declaring a
variable in a try
block with let
scopes the variable to the
try
block.
const getAnswer = async () => 42;
run();
async function run() {
try {
let val = await getAnswer();
} catch (error) {}
// ReferenceError: val is not defined
val;
}
Golang-style error handling doesn’t get rid of the return
quirk.
It just makes missing error checks harder, because you know that
if you don’t have if (err != null)
after an async operation,
something is wrong.
There are two major disadvantages to Golang-style error handling:
- It is extremely repetitive. Typing
if (err != null)
every time you want to do something async puts you on the express lane to carpal tunnel. - It doesn’t help you with synchronous errors in
run()
.
So Golang-style error handling is a neat syntactic shortcut that
should be used sparingly. It doesn’t have much benefit over
using try/catch
.
Using catch()
on the Function Call
Both try/catch
and Golang-style error handling have their uses,
but the best way to ensure you’ve handled all errors in your
run()
function is to use run().catch()
. In other words,
handle errors when calling the function as opposed to handling
each individual error.
run().
catch(function handleError(err) {
err.message; // Oops!
}).
// Handle any errors in `handleError()`. If the error handler
// throws an error, kill the process.
catch(err => { process.nextTick(() => { throw err; }) });
async function run() {
await Promise.reject(new Error('Oops!'));
}
Remember that async functions always return promises. This promise
rejects if any uncaught error occurs in the function.
If your async function body returns a promise that rejects,
the returned promise will reject too.
run().
catch(function handleError(err) {
err.message; // Oops!
}).
// Handle any errors in `handleError()`. If the error handler
// throws an error, kill the process.
catch(err => { process.nextTick(() => { throw err; }) });
async function run() {
// Note that this is `return`, not `await`
return Promise.reject(new Error('Oops!'));
}
Why run().catch()
as opposed to wrapping the entire run()
function body in a try/catch
? For handling errors in the error
handler. What happens if the catch
block in your try/catch
throws an error? The only solution is to nest a try/catch
in
your catch
block, in every single function. .catch()
makes
handling unexpected errors in your error handler cleaner.
Takeaways
In general, errors are either expected or unexpected. In async
functions, try/catch
can help you recover gracefully from
expected errors. But unexpected errors do happen, we all
occasionally end up with a surprise «TypeError: Cannot read
property ‘foo’ of null» sometimes.
You should handle unexpected errors in your async functions in
the calling function. The run()
function shouldn’t be
responsible for handling every possible error, you should
instead do run().catch(handleError)
.
Looking to become fluent in async/await? My new ebook, Mastering Async/Await, is designed to give you an integrated understanding of
async/await fundamentals and how async/await fits in the JavaScript ecosystem in a few hours. Get your copy!
Found a typo or error? Open up a pull request! This post is
available as markdown on Github