Разбираемся с Async/Await в JavaScript на примерах
На конкретных примерах с кодом объясняем концепцию асинхронного программирования с использованием Async/Await в JavaScript.
94К открытий95К показов
Callback — это не что-то замысловатое или особенное, а просто функция, вызов которой отложен на неопределённое время. Благодаря асинхронному характеру JavaScript, обратные вызовы нужны были везде, где результат не может быть получен сразу.
Ниже приведён пример асинхронного чтения файла на Node.js:
Проблемы начинаются, когда нужно выполнить несколько асинхронных операций. Просто представьте себе подобный сценарий:
- Выполняется запрос в БД на некого пользователя
Arfat
. Нужно считать его полеprofile_img_url
и загрузить соответствующее изображение с сервераsomeServer.ru
. - После загрузки изображения необходимо его конвертировать, допустим из PNG в JPEG.
- В случае успешной конвертации нужно отправить письмо на почту пользователя.
- Это событие нужно занести в файл
transformations.log
и указать дату.
Обратите внимание на вложенность обратных вызовов и пирамиду из })
в конце. Подобные случаи принято называть Callback Hell или Pyramid of Doom. Вот основные недостатки:
- Такой код сложно читать.
- В таком коде сложно обрабатывать ошибки и одновременно сохранять его «качество».
Для решения этой проблемы в JavaScript были придуманы промисы (англ. promises). Теперь глубокую вложенность коллбэков можно заменить ключевым словом 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-функций возможно организовать работу с асинхронным кодом в синхронном стиле.
Как видите, знание промисов всё же необходимо для понимания работы async/await.
Синтаксис
Синтаксис состоит из двух ключевых слов: async
и await
. Первое делает функцию асинхронной. Именно в таких функциях разрешается использование await
. Использование await
в любом другом случае вызовет ошибку.
Обратите внимание, что async
вставляется в начале объявления функции, а в случае стрелочной функции — между знаком =
и скобками.
Async-функции могут быть помещены в объект в качестве методов или же просто использоваться в объявлении класса.
Примечание Конструкторы класса и геттеры/сеттеры не могут быть асинхронными.
Семантика и правила выполнения
Async-функции похожи на обычные функции в JavaScript, за исключением нескольких вещей:
Async-функции всегда возвращают промисы
Функция fn
возвращает строку 'hello'
. Т. к. это асинхронная функция, значение строки обёртывается в промис (с помощью конструктора).
Код выше можно переписать и без использования async
:
В таком случае, вместо async
, код вручную возвращает промис.
Тело асинхронной функции всегда обёртывается в новый промис
Если возвращаемое значение является примитивом, async-функция возвращает это значение, обёрнутое в промис. Но если возвращаемое значение и есть объект промиса, его решение возвращается в новом промисе.
Что происходит, когда внутри асинхронной функции возникает какая-нибудь ошибка?
Если ошибка не будет обработана, foo()
вернёт промис с реджектом. В таком случае вместо Promise.resolve
вернётся Promise.reject
, содержащий ошибку.
Суть async-функций в том, что что бы вы не возвращали, на выходе вы всегда будете получать промис.
Асинхронные функции приостанавливаются при каждом await выражении
await
сказывается на выражениях. Если выражение является промисом, то async-функция будет приостановлена до тех пор, пока промис не выполнится. Если же выражение не является промисом, то оно конвертируется в промис через Promise.resolve
и потом завершается.
Как работает 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.
В функции finishMyTask
используется await
для ожидания результатов таких операций, как queryDatabase
, sendEmail
, logTaskInFile
и т. д. Если сравнить это решение с решением, использовавшим промисы, то вы обратите внимание на их сходство. Однако версия с async/await упрощает синтаксические сложности. В этом способе нет кучи коллбэков и цепочек .then
/.catch
.
Вот то решение с выводом чисел. Тут есть два способа:
С использованием async-функций решение поставленной задачи упрощается до безобразия:
Обработка ошибок
Как было сказано выше, необработанные ошибки обёртываются в неудачный (rejected) промис. Но в async-функциях всё ещё можно использовать конструкцию try-catch
для синхронной обработки ошибок.
canRejectOrReturn()
— это асинхронная функция, которая будет удачно завершатся с 'Число подошло'
, либо неудачно завершаться с Error('Простите, число больше, чем нужно.')
.
Поскольку в коде выше ожидается выполнение canRejectOrReturn
, то его собственное неудачное завершение вызовет исполнение блока catch
. Поэтому функция foo
завершится либо с undefined
(т. к. в блоке try
ничего не возвращается), либо с 'Ошибка обработана'
. Поэтому у этой функции не будет неудачного завершения, т. к. try-catch
блок будет обрабатывать ошибку самой функции foo
.
Вот другой пример:
Обратите внимание, что в коде выше из foo
возвращается (без ожидания) canRejectOrReturn
. foo
завершится либо с 'число подошло'
, либо с реджектом Простите, число больше, чем нужно.
‘). Блок catch
никогда не будет исполняться.
Это происходит из-за того, что foo
возвращает промис, который передан от canRejectOrReturn
. Следовательно, решение функции foo
становится решением canRejectOrReturn
. Такой код можно представить всего в двух строках:
Вот что получится, если использовать await
и return
разом:
В коде выше foo
будет удачно завершаться и с 'число подошло'
, и с 'Ошибка обработана'
. В таком коде реджектов не будет. Но в отличие от одного из примеров выше, foo
завершится со значением canRejectOrReturn
, а не с undefined
.
Вы можете убедиться в этом сами, убрав строку return await canRejectOrReturn()
:
Популярные ошибки и подводные камни
Из-за сложных манипуляций с промисами и async/await концепциями вы можете встретиться с различными тонкостями, что может привести к ошибкам.
Не забывайте await
Частая ошибка заключается в том, что перед промисом забывается ключевое слово await
:
Обратите внимание, здесь не используется ни await
, ни return
. Функция foo
всегда будет завершаться с undefined
(без задержки в 1 секунду). Тем не менее, промис будет выполняться. Если промис будет выдавать ошибку либо реджект, то будет вызываться UnhandledPromiseRejectionWarning
.
async-функции в обратных вызовах
async-функции часто используются в .map
или .filter
в качестве коллбэков. Вот пример — допустим, существует функция fetchPublicReposCount(username)
, которая возвращает количество открытых репозиториев на GitHub. Есть 3 пользователя, чьи показатели нужно взять. Используется такой код:
И для того, чтобы получить количество репозиториев пользователей (['ArfatSalman', 'octocat', 'norvig']
), код должен выглядеть как-то так:
Обратите внимание на слово await
в обратном вызове функции .map
. Можно было бы ожидать, что переменная counts
будет содержать число — количество репозиториев. Но как было сказано ранее, все async-функции возвращают промисы. Следовательно, counts
будет массивом промисов. .map
вызывает анонимной коллбэк для каждого пользователя.
Слишком последовательное использование await
Допустим, есть такой код:
В переменную count
помещается количество репозиториев, потом это количество добавляется в массив counts
. Проблема этого кода в том, что пока с сервера не придут данные первого пользователя, все последующие пользователи будут находиться в ожидании. Получается, что в один момент времени обрабатывается только один пользователь.
Если на обработку одного пользователя будет уходить 300 мс, то на всех пользователей уйдёт почти секунда. В этом случае затрачиваемое время будет линейно зависеть от количества пользователей. Поскольку получение количества репозиториев не зависит друг от друга, то можно распараллелить эти процессы. Тогда пользователи будут обрабатываться одновременно, а не последовательно. Для этого понадобятся .map
и Promise.all
.
Promise.all
на входе получает массив промисов и возвращает промис. Возвращаемый промис завершается после окончания всех промисов в массиве либо при первом реджекте. Возможно, все эти промисы не запустятся строго одновременно. Чтобы добиться строгого параллелизма, взгляните на p-map. А если нужно, чтобы async-функции были более адаптивными, посмотрите на Async Iterators.
94К открытий95К показов