Сильные стороны async/await
Самое важное преимущество, которое получает программист, пользующийся конструкцией async/await, заключается в том, что она даёт возможность писать асинхронный код в стиле, характерном для синхронного кода. Сравним код, написанный с использованием async/await, и код, основанный на промисах.
// async/await
async getBooksByAuthorWithAwait(authorId) {
const books = await bookModel.fetchAll();
return books.filter(b => b.authorId === authorId);
}
// промис
getBooksByAuthorWithPromise(authorId) {
return bookModel.fetchAll()
.then(books => books.filter(b => b.authorId === authorId));
}
Несложно заметить, что async/await-версия примера получилась более понятной, чем его вариант, в котором использован промис. Если не обращать внимания на ключевое слово
await
, этот код будет выглядеть как обычный набор инструкций, выполняемых синхронно — как в привычном JavaScript или в любом другом синхронном языке вроде Python.Привлекательность async/await обеспечивается не только улучшением читабельности кода. Этот механизм, кроме того, пользуется отличной поддержкой браузеров, не требующей каких-либо обходных путей. Так, на сегодняшний день асинхронные функции полностью поддерживают все основные браузеры.
Все основные браузеры поддерживают асинхронные функции (caniuse.com)
Такой уровень поддержки означает, например, что код, использующий async/await, не нужно транспилировать. Кроме того, это облегчает отладку, что, пожалуй, даже более важно, чем отсутствие необходимости в транспиляции.
На следующем рисунке показан процесс отладки асинхронной функции. Здесь, при установке точки останова на первой инструкции функции и при выполнении команды Step Over, когда отладчик доходит до строки, в которой использовано ключевое слово
await
, можно заметить, как отладчик ненадолго приостанавливается, ожидая окончания работы функции bookModel.fetchAll()
, а затем переходит к строке, где вызывается команда .filter()
! Такой отладочный процесс выглядит куда проще, чем отладка промисов. Тут, при отладке аналогичного кода, пришлось бы устанавливать ещё одну точку останова в строке .filter()
.Отладка асинхронной функции. Отладчик дождётся выполнения await-строки и перейдёт на следующую строку после завершения операции
Ещё одна сильная сторона рассматриваемого механизма, которая менее очевидна чем то, что мы уже рассмотрели, заключается в наличии здесь ключевого слова
async
. В нашем случае его использование гарантирует то, что значение, возвращаемое функцией getBooksByAuthorWithAwait()
будет промисом. В результате в коде, вызывающем эту функцию, можно безопасно воспользоваться конструкцией getBooksByAuthorWithAwait().then(...)
или await getBooksByAuthorWithAwait()
. Поразмыслите над следующим примером (учтите, что так делать не рекомендуется):getBooksByAuthorWithPromise(authorId) {
if (!authorId) {
return null;
}
return bookModel.fetchAll()
.then(books => books.filter(b => b.authorId === authorId));
}
}
Здесь функция
getBooksByAuthorWithPromise()
может, если всё нормально, вернуть промис, или, если что-то пошло не так — null
. В результате, если произошла ошибка, здесь нельзя безопасно вызвать .then()
. При объявлении функций с использованием ключевого слова async
ошибки подобного рода невозможны.О неправильном восприятии async/await
В некоторых публикациях конструкцию async/await сравнивают с промисами и говорят о том, что она представляет собой новое поколении эволюции асинхронного программирования на JavaScript. С этим я, при всём уважении к авторам таких публикаций, позволю себе не согласиться. Async/await — это улучшение, но это — не более чем «синтаксический сахар», появление которого не ведёт к полному изменению стиля программирования.
В сущности, асинхронные функции — это промисы. Перед тем, как программист сможет правильно использовать конструкцию async/await, он должен хорошо изучить промисы. Кроме того, в большинстве случаев, работая с асинхронными функциями, нужно использовать и промисы.
Взгляните на функции
getBooksByAuthorWithAwait()
и getBooksByAuthorWithPromises()
из вышеприведённого примера. Обратите внимание на то, что они идентичны не только в плане функционала. У них ещё и совершенно одинаковые интерфейсы.Всё это значит, что, если вызвать напрямую функцию
getBooksByAuthorWithAwait()
, она вернёт промис.На самом деле, суть проблемы, о которой мы тут говорим, заключается в неправильном восприятии новой конструкции, когда создаётся обманчивое ощущение того, что синхронную функцию можно конвертировать в асинхронную благодаря простому использованию ключевых слов
async
и await
и ни о чём больше не задумываться.Подводные камни async/await
Поговорим о наиболее распространённых ошибках, которые можно сделать, пользуясь async/await. В частности — о нерациональном использовании последовательных вызовов асинхронных функций.
Хотя ключевое слово
await
может сделать код похожим на синхронный, пользуясь им, стоит помнить о том, что код это асинхронный, а значит, надо очень внимательно относиться к последовательным вызовом асинхронных функций.async getBooksAndAuthor(authorId) {
const books = await bookModel.fetchAll();
const author = await authorModel.fetch(authorId);
return {
author,
books: books.filter(book => book.authorId === authorId),
};
}
Этот код, с точки зрения логики, кажется правильным. Однако тут имеется серьёзная проблема. Вот как он работает.
- Система вызывает
await bookModel.fetchAll()
и ждёт завершения команды.fetchAll()
. - После получения результата от
bookModel.fetchAll()
будет выполнен вызовawait authorModel.fetch(authorId)
.
Обратите внимание на то, что вызов
authorModel.fetch(authorId)
не зависит от результатов вызова bookModel.fetchAll()
, и, на самом деле, эти две команды можно выполнять параллельно. Однако использование await
приводит к тому, что два этих вызова выполняются последовательно. Общее время последовательного выполнения этих двух команд будет больше, чем время их параллельного выполнения.Вот правильный подход к написанию такого кода:
async getBooksAndAuthor(authorId) {
const bookPromise = bookModel.fetchAll();
const authorPromise = authorModel.fetch(authorId);
const book = await bookPromise;
const author = await authorPromise;
return {
author,
books: books.filter(book => book.authorId === authorId),
};
}
Рассмотрим ещё один пример неправильного использования асинхронных функций. Тут всё ещё хуже, чем в предыдущем примере. Как видите, для того, чтобы асинхронно загрузить список неких элементов, нам надо полагаться на возможности промисов.
async getAuthors(authorIds) {
// Неправильный подход, вызовы будут выполнены последовательно
// const authors = _.map(
// authorIds,
// id => await authorModel.fetch(id));
// Правильный подход
const promises = _.map(authorIds, id => authorModel.fetch(id));
const authors = await Promise.all(promises);
}
Если в двух словах, то, для того, чтобы грамотно пользоваться асинхронными функциями, нужно, как и во времена, когда этой возможности не было, сначала подумать об асинхронном выполнении операций, а потом уже писать код с применением
await
. В сложных случаях, вероятно, легче будет просто напрямую использовать промисы.Обработка ошибок
При использовании промисов выполнение асинхронного кода может завершиться либо так, как ожидается — тогда говорят об успешном разрешении промиса, либо с ошибкой — тогда говорят о том, что промис отклонён. Это даёт нам возможность использовать, соответственно,
.then()
и .catch()
. Однако, обработка ошибок при использовании механизма async/await может оказаться непростым делом.?Конструкция try/catch
Стандартным способом для обработки ошибок при использовании async/await является конструкция try/catch. Я рекомендую пользоваться именно этим подходом. При выполнении await-вызова значение, выдаваемое при отклонении промиса, представляется в виде исключения. Вот пример:
class BookModel {
fetchAll() {
return new Promise((resolve, reject) => {
window.setTimeout(() => { reject({'error': 400}) }, 1000);
});
}
}
// async/await
async getBooksByAuthorWithAwait(authorId) {
try {
const books = await bookModel.fetchAll();
} catch (error) {
console.log(error); // { "error": 400 }
}
Ошибка, перехваченная в блоке
catch
— это как раз и есть значение, получающееся при отклонении промиса. После перехвата исключения мы можем применить несколько подходов для работы с ним:- Можно обработать исключение и вернуть нормальное значение. Если не использовать выражение
return
в блокеcatch
для возврата того, что ожидается после выполнения асинхронной функции, это будет эквивалентно использованию командыreturn undefined
;. - Можно просто передать ошибку в место вызова кода, который дал сбой, и позволить обработать её там. Можно выбросить ошибку напрямую, воспользовавшись командой наподобие
throw error;
, что позволит использовать функциюasync getBooksByAuthorWithAwait()
в цепочке промисов. То есть, вызывать её можно будет, пользуясь конструкциейgetBooksByAuthorWithAwait().then(...).catch(error => ...)
. Кроме того, можно обернуть ошибку в объектError
, что может выглядеть какthrow new Error(error)
. Это позволит, например, при выводе сведений об ошибке в консоль, просмотреть полный стек вызовов. - Ошибку можно представить в виде отклонённого промиса, выглядит это как
return Promise.reject(error)
. В данном случае это эквивалентно командеthrow error
, делать так не рекомендуется.
Вот преимущества применения конструкции try/catch:
- Подобные средства обработки ошибок существуют в программировании уже очень давно, они просты и понятны. Скажем, если у вас есть опыт программирования на других языках, вроде C++ или Java, то вы без проблем поймёте устройство try/catch в JavaScript.
- В один блок try/catch можно помещать несколько await-вызовов, что позволяет обрабатывать все ошибки в одном месте в том случае, если нет необходимости раздельно обрабатывать ошибки на каждом шаге выполнения кода.
Надо отметить, что в механизме try/catch есть один недостаток. Так как try/catch перехватывает любые исключения, возникающие в блоке
try
, в обработчик catch
попадут и те исключения, которые не относятся к промисам. Взгляните на этот пример.class BookModel {
fetchAll() {
cb(); // обратите внимание на то, что функция `cb` не определена, что приведёт к исключению
return fetch('/books');
}
}
try {
bookModel.fetchAll();
} catch(error) {
console.log(error); // Тут будет выдано сообщение об ошибке "cb is not defined"
}
Если выполнить этот код, можно увидеть в консоли сообщение об ошибке
ReferenceError: cb is not defined
. Это сообщение выведено командой console.log()
из блока catch
, а не самим JavaScript. В некоторых случаях такие ошибки приводят к тяжёлым последствиям. Например, если вызов bookModel.fetchAll();
запрятан глубоко в серии вызовов функций и один из вызовов «проглотит» ошибку, такую ошибку будет очень сложно обнаружить.?Возврат функциями двух значений
Источником вдохновения для следующего способа обработки ошибок в асинхронном коде стал язык Go. Он позволяет асинхронным функциям возвращать и ошибку, и результат. Подробнее об этом можно почитать здесь.
Если в двух словах, то асинхронные функции, при таком подходе, можно использовать так:
[err, user] = await to(UserModel.findById(1));
Лично мне это не нравится, так как этот способ обработки ошибок привносит в JavaScript стиль программирования на Go, что выглядит неестественно, хотя, в некоторых случаях, это может оказаться весьма полезным.
?Использование .catch
Последний способ обработки ошибок, о котором мы поговорим, заключается в использовании
.catch()
.Вспомните о том, как работает
await
. А именно, использование этот ключевого слова приводит к тому, что система ждёт до тех пор, пока промис не завершит свою работу. Кроме того, вспомните о том, что команда вида promise.catch()
тоже возвращает промис. Всё это говорит о том, что обрабатывать ошибки асинхронных функций можно так:// books будет равно undefined если произойдёт ошибка,
// так как обработчик catch ничего явно не возвращает
let books = await bookModel.fetchAll()
.catch((error) => { console.log(error); });
Для этого подхода характерны две небольших проблемы:
- Это — смесь промисов и асинхронных функций. Для того чтобы этим пользоваться, надо, как и в других подобных случаях, понимать особенности работы промисов.
- Этот подход не отличается интуитивной понятностью, так как обработка ошибок выполняется в необычном месте.
Итоги
Конструкция async/await, которая появилась в ES7, определённо, является улучшением механизмов асинхронного программирования в JavaScript. Она способна облегчить чтение и отладку кода. Однако, для того, чтобы пользоваться async/await правильно, необходимо глубокое понимание промисов, так как async/await — это всего лишь «синтаксический сахар», в основе которого лежат промисы.
Надеемся, этот материал позволил вам ближе познакомиться с async/await, и то, что вы тут узнали, убережёт вас от некоторых распространённых ошибок, возникающих при использовании этой конструкции.
Уважаемые читатели! Пользуетесь ли вы конструкцией async/await в JavaScript? Если да — просим рассказать о том, как вы обрабатываете ошибки в асинхронном коде.
Комментарии (34)
vintage
20.06.2018 12:29Неправильный подход, вызовы будут выполнены последовательно
const authors = _.map( authorIds, id => await authorModel.fetch(id));
Этот код даже не запустится, ибо синтаксически нельзя использовать await в синхронных функциях. И вообще, что за
_.map
в 2k18?Mixxer
20.06.2018 13:36_.map это скорее всего lodash какой-нибудь, просто при переводе потеряли (либо автор в переводе посчитал это очевидным)
mayorovp
20.06.2018 15:53Не думаю что vintage забыл что такое lodash. Тут вопрос в другом: зачем писать
_.map(authorIds, ...
, когда давно уже можно написатьauthorIds.map(...
?faiwer
20.06.2018 17:16-1Может быть там что-нибудь типа этого подключено (prefer-lodash-method). Поясняют они это так:
When using native functions like forEach and map, it's often better to use the Lodash implementation.
This can be for performance reasons, for implicit care of edge cases (e.g. _.map over a variable that might be undefined), or for use of Lodash's shorthands.
Iqorek
20.06.2018 13:37Есть еще способ обработки исключений и он мне больше нравится, но нужно включать поддержку декораторов:
@onError(e => -100) // invoke callback on error async asyncFunctionCanThrowsError(error) { await ... throw new Error(); } @defaultOnError(-1) // return default value (-1) on error async defaultOnThrow(){ await ... throw new Error(); }
Это не подходит прям для всего, но для простых случаев это более читабельно. Имхо.
Поиграться можно тут jsfiddle.net/jsbot/kw7py5r3justboris
21.06.2018 10:13Очень сомнительный сахар
1) Теряется наглядность в последовательности исполнения. Сначала исполняется код снизу, потом верхняя часть.
2) Нет доступа к переменным из функции
@onError(e => fallbackFetchData(id)) // << где тут взять id? async fetchData(id) { // code }
3) Если обработчик будет из нескольких строк, то выигрыша по числу кода вообще нет
async asyncFunctionCanThrowsError(error) { try { // code } catch(error) { if(someCondition) { // some logic } } }
против
@onError(error => { if(someCondition) { // some logic } }) async asyncFunctionCanThrowsError(error) { // code }
Хорошая попытка использовать декораторы, но работает только для однострочных функций. Если понадобится больше логики, придется переделывать все назад.
Iqorek
21.06.2018 11:241. Это смотря какой подход, у меня подход исключений быть не должно 99.99% времени и в принципе исключение обозначает, что нужно показать юзеру «сервис недоступен» и делать с этим особо нечего. Поэтому код обработки пусть будет, но будет где то в сторонке, что бы не загрязнять основной код.
2. Конкретно это вообще не проблема, имя функции, аргументы, this и саму функцию можно передавать в callback
function onError(callback) { ... descriptor.value = function(...args) { ... return result.catch(e => callback(args, e)); // вот например передача аргументов ... } } @onError((args, errror)=>{}) ...
3. Сделайте именованную функцию, но опять же это не универсальный способ и универсального способа нет, нужно смотреть по обстоятельствам. Общий принцип, все что упрощает код хорошо, все что повышает его читабельность, тоже хорошо и наоборот.
import handleError from 'errorHandler' @onError(handleError) async asyncFunctionCanThrowsError(error) { // code }
justboris
21.06.2018 22:42Вот именно. Наворачиваются и добавляются усложениния, хотя можно просто обойтись try/catch
Iqorek
22.06.2018 15:141. Только одна декларация намерения отлова исключений занимает минимум +3 строки и добавляет еще один отступ коду
try { } catch() { }
это много
2. Код обработки штатной ситуации и код обработки исключения обычно логически никак не связаны, смешивание их в одном месте, усложняет чтение.
Как решить эти проблемы, можно например было бы добавить сахар в язык что то вроде
function foo() { // logic } catch(e) { // error handler }
apapacy
20.06.2018 15:02Можно просто передать ошибку в место вызова кода, который дал сбой, и позволить обработать её там. Можно выбросить ошибку напрямую, воспользовавшись командой наподобие throw error;, что позволит использовать функцию async getBooksByAuthorWithAwait() в цепочке промисов. То есть, вызывать её можно будет, пользуясь конструкцией getBooksByAuthorWithAwait().then(...).catch(error => ...). Кроме того, можно обернуть ошибку в объект Error, что может выглядеть как throw new Error(error). Это позволит, например, при выводе сведений об ошибке в консоль, просмотреть полный стек вызовов.
Ошибку можно представить в виде отклонённого промиса, выглядит это как return Promise.reject(error). В данном случае это эквивалентно команде throw error, делать так не рекомендуется.
Тут автор уже немного перемудрил. Эти оба случая как раз эквивалентны по резултату если вообще не заключать await в try/catch.
sanchezzzhak
20.06.2018 15:06Пробовал использовать в инициализации приложения mongodb, redis соединение к бд, роутеры, старт сервера, вроде и работает но откатил версию в коде назад на async.parallel
https://caolan.github.io/async/docs.html#parallel
Придерживаюсь правила, пока работает не трогай.
Пользуюсь только callback они быстрее, чем промисы работают. На большом количестве вызовов прирост ощутим.apapacy
20.06.2018 15:36Какая версия node.js? Испольуетели Вы трансфорамацию babel? Не должно быть на последних версиях принципиальной разницы. Хотя не так давно ббыли такие разговоры (сам не проверял) что использование try/catch в async функциях замедляло работу на порядок (раз в 20).
Negezor
20.06.2018 15:19+1Вот правильный подход к написанию такого кода:
async getBooksAndAuthor(authorId) { const bookPromise = bookModel.fetchAll(); const authorPromise = authorModel.fetch(authorId); const book = await bookPromise; const author = await authorPromise; return { author, books: books.filter(book => book.authorId === authorId), }; }
Замечу что не обязательно писать именно так, обычно пишут вот такие конструкции, так как они проще:
async getBooksAndAuthor(authorId) { const [book, author] = await Promise.all([ bookModel.fetchAll(), authorModel.fetch(authorId) ]); return { author, books: books.filter(book => book.authorId === authorId), }; }
klvov
21.06.2018 10:45В общем, имхо, с ES происходит то, что в него встраивают механизмы, которые позволяют достигнуть тех же целей, которые при разработке под ОС (или под JVM) достигаются многопоточностью. Но можно достигнуть их и через генераторы с yield и промисы. Следующий этап — наверное, должны начаться проблемы с синхронизацией всего этого хозяйства, и нужны будут аналоги критических секций, семафоров и мьютексов.
mayorovp
21.06.2018 12:36Критические секции не нужны в принципе именно благодаря тому что await действует только на один фрейм вызова: если в блоке кода нет оператора await посередине, то ни один "поток" не может прервать его выполнение.
Соответственно, асинхронные семафоры и мьютексы упрощаются до чего-то такого:
class AsyncMutex { constructor() { this._promise = null; this._resolve = null; } async acquire() { while (this._promise) await this._promise; this._promise = new Promise(resove => this._resolve = resolve); } release() { if (!this._resolve) throw "Mutex is not acquired"; this._resolve(); this._promise = this._resolve = null; } }
pacu
21.06.2018 11:36Можете подсказать(а то уже 2 дня мучаюсь)… как вернуть данные из функции?(сильно не пинайте… только столкнулся с промисами)
async function f() { try { const query1 = budjet.find({}).sort({ $natural: -1 }).limit(1);//mongoose const result1 = await query1.exec(); console.log(result1);// тут показывет нужные данные return result1; } catch (e) { console.error(e); } } console.log(f());// тут Promise { <pending> }
заранее спасибоfaiwer
21.06.2018 11:45f().then(data => console.log(data)); // 1-й вариант console.log(await f()); // 2-й вариант
2-й вариант будет работать только в контексте вышестоящей тоже async-функции.
faiwer
21.06.2018 11:47+1А чтобы не "мучиться ещё 2 дня" вслепую просто почитайте про Promise-ы и Async/await-ы. Вслепую ковыряться в них, надеясь разобраться из какого они теста, без знания теории, что за ними стоит, можно, но очень уж нерационально.
vinogradov_m
21.06.2018 12:01У вас функция объявлена с модификатором async, и она ожидаемо возвращает Promise. Соответственно, либо вы должны вызывать ее в контексте другой асинхронной функции, используя await, либо делать все операции с данными внутри .then
// inside another async function async function yetAnotherFunction() { console.log(await f()); } // particular cause: self-invoking function (async () => { console.log(await f()); })(); // or f().then((it) => { // do everything you want with result, e.g.: console.log(it); });
P.S. пожалуйста, обратите внимание, что у вас функция возвращает либо массив объектов, либо undefined в зависимости от того, произошла ошибка или нет; лучше придерживаться единого формата ответа. Возможно имеет смысл обрабатывать ошибки на уровне выше.
P.P.S. если вам нужна только 1 запись, то лучше использовать findOne вместо find().limit(1)
P.P.P.S. Query в mongoose – это Promise-like объекты, поэтому вы можете использовать await прямо для них ;)
VolCh
По-моему, главная особенность async/away то, что нельзя использовать away в функции, не объявив её async, ну и нельзя использовать вне функций. Как следствие, рано или поздно придётся использовать then в случаях, когда нас интересует возвращаемое значение в синхронном контексте.
uNScope
В синхронном контексте первая в цепочке функция должна быть async, тогда не нужно использовать then. Если это не так, значит скорей всего что-то неправильно в архитектуре приложения.
VolCh
Ну вот есть функция получения количества книг у автора типа async getBookCount(aythorId) {}, нужно его вывести в консоль (для простоты). Каким будет код основного скрипта (то есть в глобальном контексте, вариант — в обработчике onload) страницы? console.log(getBookCount(1)) не сработает ожидаемо, console.log(await getBookCount(1)) вообще не сработает. только что-то вроде getBookCount(1).then(console.log)
Mixxer
Ничего не мешает записать первый вызов из "глобальной" области вот так:
VolCh
return забыли. Что результатом этого вызова будет?
Mixxer
А куда он возвращать то будет, если это вызов в глобальной области? Это лишь пример как запустить функцию.
VolCh
mayorovp
Это бессмысленно. Зачем выводить в консоль Promise?
Лучше вот так делать:
Хотя лично мне все-таки проще написать
asyncFunction().then(console.log);
VolCh
Так я и хотел показать, что без then не получится вывести результат резолвинга промиса (return забыл перед await добавить). Можно всё выше и выше поднимать async, но рано или поздно придётся сделать then, если нас результат интересует.
redyuf
Да, это «colored function» problem.
Проблема эта решается введением новой идиомы, волокон (fibers), которые по ряду причин не хотят пока добавлять в браузеры. Аналогии из других языков: go/goroutines, java/loom.
inoyakaigor
Есть предложение в стандарт top level await который уже (или пока) в stage-2. Если я всё правильно понял, то это как раз про ваш случай
redyuf
Это частный случай, сахар над import. Типа, давайте в коде вместо:
будем писатьЭто удобно для подгрузки динамических ресурсов. Но, например, такое уже не сделать:
Добавлять сахар проще, чем вводить новую идиому в рантайм, это как сделать однопоточный js многопоточным, со всеми вытекающими.
Но проблему разделения мира на синхронную и асинхронную части никак не решить на async/await. Нельзя из синхронной функции вызывать асинхронную и работать с результатом.