Конструкция async/await появилась в стандарте ES7. Её можно считать замечательным улучшением в сфере асинхронного программирования на JavaScript. Она позволяет писать код, который выглядит как синхронный, но используется для решения асинхронных задач и не блокирует главный поток. Несмотря на то, что async/await — это отличная новая возможность языка, пользоваться ей правильно не так уж и просто. Материал, перевод которого мы публикуем сегодня, посвящён разностороннему исследованию async/await и рассказу о том, как использовать этот механизм правильно и эффективно.
Сильные стороны 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? Если да — просим рассказать о том, как вы обрабатываете ошибки в асинхронном коде.
Автор: ru_vds