Механизм async/await
, представленный ES7, является фантастическим улучшением асинхронного программирования с использованием JavaScript. Он предоставил возможность использовать код, написанный в синхронном стиле, для асинхронного доступа к ресурсам, при котором не блокируется основной поток. Однако, применение этого механизма — задача непростая. В этой статье мы рассмотрим async / wait с разных точек зрения и покажем, как использовать его правильно и эффективно.
Что хорошего в async/await
Важнейшим преимуществом async/await
является синхронный стиль программирования. Давайте посмотрим на следующий пример:
// async/await async getBooksByAuthorWithAwait(authorId) { const books = await bookModel.fetchAll(); return books.filter(b => b.authorId === authorId); } // promise getBooksByAuthorWithPromise(authorId) { return bookModel.fetchAll() .then(books => books.filter(b => b.authorId === authorId)); }
Очевидно, что вариант сasync/await
читать и понимать проще, чем версию того же кода с промисами (promise). Если убрать ключевое слово await
, то код станет выглядеть так, как будто написан на любом другом синхронном языке программированя, такие как Python.
И еще из приятного — это не только читаемость: async/await
по умолчанию поддерживается всеми основными современными браузерами.
Встроенная поддержка означает, что вам не нужно транспилировать код. Что еще более важно, это облегчает отладку. Если установить контрольную точку (breakpoint) в точке входа функции, то после выполнения строки сawait
отладчик (debugger) ненадолго подвиснет на врем, которое требуетсяbookModel.fetchAll()
для выполнения своей работы, а затем перейдет к следующей строке .filter
Это намного проще, чем в ситуации с промисами, в которой вам пришлось бы настраивать другую контрольную точку в строке с .filter
Другим менее очевидным преимуществом моно назвать ключевое слово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 / wait может ввести в заблуждение
Автор некоторых статей сравнивают async / await с промисами и утверждают, что это следующее поколение в эволюции асинхронного программирования JavaScript, — при всем моем уважении, с этой точкой зрения я не согласен. Async / await — это улучшение, но в то же время не стоит считать его чем то более значительным, чем синтаксический сахар, потому что стиль программирования он кардинально не меняет.
По сути, асинхронные функции по-прежнему остаются промисами. Вы должны понять, как работают промисы, прежде чем сможете научиться правильно использовать асинхронные функции , и что еще хуже, большую часть времени вы должны использовать промисы вместе с асинхронными функциями.
Рассмотрим функции getBooksByAuthorWithAwait()
и getBooksByAuthorWithPromises()
в приведенном выше примере. Обратите внимание, что они не только идентичны функционально, но и имеют точно такой же интерфейс!
Это означает, что getBooksByAuthorWithAwait()
вернет промис, если вы вызовете эту функцию напрямую.
Ну, это не обязательно плохо. Только название 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()
.- Затем будет вызван метод
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) { // WRONG, this will cause sequential calls // const authors = _.map( // authorIds, // id => await authorModel.fetch(id)); // CORRECT const promises = _.map(authorIds, id => authorModel.fetch(id)); const authors = await Promise.all(promises); }
Короче говоря, вам все равно нужно предусмотреть асинхронный порядок исполнения кода, а затем попытаться написать код последовательно с await
. В сложных случаях лучшим вариантом будет использовать промисы напрямую.
Обработка ошибок
При использовании промисов функция async может возвращать два значения: разрешенное значение и отклоненное значение. И мы можем использовать .then()
для обычного случая и .catch()
для случая с ошибкой. Однако обработвать ошибки в случае с async/await
— дело сложное.
try…catch
Самый стандартный способ, его же я обычно рекомендую — использовать конструкцию 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 } }
Error в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
:
- Простой, традиционный способ. Если у вас есть опыт работы с другими языками, такими как Java или C ++, вам не составит труда понять данную концепцию.
- Дает возможность поместить несколько вызовов
await
в один блокtry...catch
для обработки ошибок в одном месте, если обработка ошибок на каждом шаге не требуется.
В этом подходе есть и один недостаток. Так как try...catch
поймает любое исключение в блоке, то будут выброшены ошибки, которые в обычных случаях промисами не отлавливаются. Для того, чтобы понять эту идею, взгляните на пример:
class BookModel { fetchAll() { cb(); // note `cb` is undefined and will result an exception return fetch('/books'); } } try { bookModel.fetchAll(); } catch(error) { console.log(error); // This will print "cb is not defined" }
Запустите этот код и вы получите ошибку ReferenceError: cb is not defined
в консоли, черного цвета. Ошибка выводилась с помощью console.log(),
но не самим JavaScript. Иногда это может быть фатальным: если BookModel
заключен глубоко в ряд вызовов функций, и один из вызовов проглатывает ошибку, тогда будет очень сложно найти неопределенную ошибку, подобную этой.
Функция возвращает оба значения
Другой способ обработки ошибок практикуется в языке Go. Он позволяет async-функции возвращать как ошибку, так и результат. За более подробным описанием направляю вас в эту статью.
Короче говоря, вы можете использовать async-функцию следующим образом:
[err, user] = await to(UserModel.findById(1));
Лично мне не нравится этот подход, поскольку он привносит стиль Go в JavaScript, который кажется неестественным, но в некоторых случаях это может оказаться весьма полезным.
Использование .catch
И последний способ вызова ошибок, которым мы поделимся здесь, — продолжить использование .catch()
.
Вспомните функциональность await
: эта функция будет ждать, пока промис завершит свою работу. Также, пожалуйста, помните, что promise.catch()
тоже вернет промис! Поэтому мы можем написать обработку ошибок следующим образом:
// books === undefined if error happens, // since nothing returned in the catch statement let books = await bookModel.fetchAll() .catch((error) => { console.log(error); });
В этом подходе есть две незначительные проблемы:
- Это смесь промисов и асинхронных функций. Вы все еще должны понимать, каков механизм работы промисов, чтобы суметь прочитать этот код.
- Обработка ошибок идет перед основным кодом, что идет вразрез с интуицией.
Вывод
Ключевые слова async/await
, введенные ES7, безусловно, значительно упростили асинхронное программирование JavaScript. Это помогает делать код более легким для чтения и отладки. Однако, чтобы правильно использовать их, нужно полностью понимать промисы, так как они представляют собой всего лишь синтаксический сахар, в основе которого лежат всё те же промисы.
Перевод статьи Charlee Li: JavaScript async/await: The Good Part, Pitfalls and How to Use