Разбираемся с Async/Await в JavaScript на примерах
На конкретных примерах с кодом объясняем концепцию асинхронного программирования с использованием Async/Await в JavaScript.
95К открытий97К показов
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.
95К открытий97К показов



