Асинхронный JavaScript – Callbacks, Promises и Async/Await

Синхронный против асинхронного

Сперва давайте рассмотрим два термина — синхронный и асинхронный.

По умолчанию JavaScript является синхронным однопоточным языком программирования. Это означает, что инструкции могут выполняться только одна за другой, а не параллельно. Рассмотрим небольшой фрагмент кода ниже:

Приведенный выше код довольно прост — он суммирует два числа, а затем записывает сумму в консоль браузера. Интерпретатор выполняет эти инструкции одну за другой в указанном порядке, пока не будет выполнено.

Но этот метод сопряжен с недостатками. Скажем, мы хотели получить большое количество данных из базы данных, а затем отобразить их в нашем интерфейсе. Когда интерпретатор достигает инструкции, которая извлекает эти данные, выполнение остальной части кода блокируется до тех пор, пока данные не будут извлечены и возвращены.

Но вы можете сказать, что данные, которые нужно извлечь, не так уж велики, и это не займет заметного времени. Представьте, что вам нужно получить данные из нескольких таблиц и еще сделать преобразования над данными. Эта усугубленная задержка не похожа на то, с чем пользователи хотели бы столкнуться.

К счастью для нас, проблемы с синхронным JavaScript были решены путем введения асинхронного JavaScript.

Думайте об асинхронном коде как о коде, выполнение которого может начаться сейчас и закончить его чуть позже. Когда JavaScript работает асинхронно, инструкции не обязательно выполняются одна за другой, как мы видели ранее.

Чтобы правильно реализовать это асинхронное поведение, разработчики использовали несколько различных решений на протяжении многих лет. Каждое решение улучшает предыдущее, что делает код более оптимизированным и понятным, если он усложняется.

Чтобы лучше понять асинхронную природу JavaScript, мы рассмотрим функции обратного вызова (callback functions), промисы (promises), асинхронность (async) и ожидание (await).

Что такое обратные вызовы в JavaScript?

Callback (функция обратного вызова) — это функция, которая передается внутри другой функции, а затем вызывается в этой функции для выполнения задачи.

Сбивает с толку? 🙂 Давайте разберем его на практике.

Приведенный выше фрагмент представляет собой небольшую программу, которая выводит данные в консоли. Но здесь есть что-то новое. Интерпретатор выполнит первую инструкцию, затем вторую, но пропустит третью и выполнит последнюю.

setTimeout — это функция JavaScript, которая принимает два параметра.

  • Первый параметр — это другая функция,
  • а второй — время, по истечении которого эта функция должна выполняться в миллисекундах.

Теперь вы видите определение обратных вызовов, вступающих в игру.

Функция внутри setTimeout в этом случае должна запускаться через две секунды (2000 миллисекунд). Представьте, что она переносится для выполнения в какую-то отдельную часть браузера, в то время как другие инструкции продолжают выполняться. Через две секунды возвращаются результаты функции.

Вот почему, если мы запустим приведенный выше фрагмент в нашей программе, мы получим это:

Вы видите, что последняя инструкция регистрируется до того, как функция setTimeout возвращает свой результат. Скажем, мы использовали этот метод для извлечения данных из базы данных. Пока пользователь ожидает возврата результатов вызовом базы данных, выполнение потока не будет прервано.

Этот метод был очень эффективным, но только до определенного момента. Иногда разработчикам приходится делать несколько обращений к разным источникам в своем коде. Чтобы сделать эти вызовы, обратные вызовы вкладывают друг в друга до тех пор, пока их не станет очень трудно читать или поддерживать. Это называется Callback Hell

Чтобы исправить эту проблему, были введены обещания (promises).

Что такое обещания (promises) в JavaScript?

Мы постоянно слышим, как люди дают обещания. Твой двоюродный брат, который обещал прислать тебе деньги, ребенок, обещающий больше не трогать банку с печеньем без разрешения… но обещания в JavaScript немного отличаются.

Promise в нашем контексте — это то, что требует некоторого времени для выполнения. Есть два возможных результата обещания:

  • Мы либо запускаем и разрешаем (resolve) promise, либо
  • В строке происходит какая-то ошибка, и обещание отклоняется (reject)

Появились promises, чтобы решить проблемы функций обратного вызова.

Promise принимает две функции в качестве параметров:

  • resolve() — это успех.
  • reject() — при возникновении ошибки.

Давайте посмотрим на обещания в действии:

Приведенный выше код представляет собой promise, заключенный в запрос к некоторому endpoint. Обещание вступает в силу в момент выполнения resolve() или reject().

Например, после вызова endpoint, если запрос будет успешным, мы разрешим обещание и продолжим делать с ответом все, что захотим. Но если есть ошибка, обещание будет отклонено.

Промисы — это удобный способ исправить проблемы, вызванные адом обратных вызовов, в методе, известном как цепочка промисов. Вы можете использовать этот метод для последовательного получения данных из нескольких конечных точек, но с меньшим количеством кода и более простыми методами.

Но есть еще лучший способ! Возможно, вам знаком следующий метод, так как это предпочтительный способ обработки данных и вызовов API в JavaScript.

Что такое Async и Await в JavaScript?

Дело в том, что объединение обещаний вместе, как и обратных вызовов, может стать довольно громоздким и запутанным.

Вот почему были созданы Async и Await.

Чтобы определить асинхронную функцию, вы делаете следующее:

Обратите внимание, что вызов асинхронной функции всегда возвращает обещание. Взгляните на это:

Запустив приведенное выше в консоли браузера, мы видим, что asyncFunc возвращает обещание.

Давайте действительно разберем код. Рассмотрим небольшой фрагмент ниже:

Ключевое слово async — это то, что мы используем для определения асинхронных функций.

Но как насчет await? Await останавливает JavaScript от назначения fetch переменной ответа до тех пор, пока обещание не будет разрешено. Как только обещание было разрешено, результаты метода выборки теперь могут быть назначены переменной ответа.

То же самое происходит в строке 3: Метод json возвращает обещание и мы можем использовать await все еще, чтобы отложить назначение до тех пор, пока обещание не будет разрешено.

Блокировать код или не блокировать код

Когда говорится про «зависание», вы должны думать, что реализация Async и Await каким-то образом блокирует выполнение кода. Потому что что, если наш запрос займет слишком много времени?

Факт в том, что это не так. Код внутри асинхронной функции является блокирующим, но это никак не влияет на выполнение программы. Выполнение нашего кода такое же асинхронное, как и всегда. Чтобы показать это, рассмотрим следующий пример:

В консоли нашего браузера вывод приведенного выше кода будет выглядеть примерно так:

Вы видите, что пока мы вызывали asyncFunc, наш код продолжал работать до тех пор, пока функция не возвращала результаты.

Async функции

Обычные функции возвращают результат с помощью ключевого слова return:

Асинхронные функции, напротив, возвращают Promise:

Вы можете сделать любую функцию асинхронной с помощью ключевого слова async, и существует много функций, которые возвращают Promise вместо результата.
Например, чтобы прочитать файл из файловой системы, вы можете использовать fs.promises, вариант функций fs, возвращающих промисы:

Или конвертируйте изображение в jpeg с помощью библиотеки Sharp, которая также возвращает Promise:

Или сделайте сетевой запрос с помощью fetch:

Как использовать Promise

Асинхронная функция по-прежнему имеет return значение, и обещание содержит этот результат. Чтобы получить доступ к значению, прикрепите обратный вызов с помощью функции then(). Этот обратный вызов будет вызван с результатом функции.

Рассмотрим пример получения содержимого файла после readFile:

Точно так же, чтобы получить результат нашей простой асинхронной функции, используйте then():

Преимущества Promises

Но зачем усложнять вызов функции промисами? Обычная функция просто возвращает значение, которое можно использовать в следующей строке, без каких-либо обратных вызовов.
Преимущество возврата обещания вместо значения заключается в том, что результат может быть не готов к моменту возврата функции (к моменту выполнения return). Обратный вызов может быть вызван намного позже, но функция должна вернуться немедленно. Это расширяет возможности функции.

Во многих случаях синхронный результат невозможен. Например, выполнение сетевого запроса занимает целую вечность по сравнению с вызовом функции. С промисами не имеет значения, занимает ли что-то много времени или дает результат сразу. В обоих случаях функция then() будет вызвана, когда результат будет готов.

Схема «Асинхронная функция возвращает обещание»

Использование стандартизированных промисов также позволяет создавать на их основе другие конструкции. Как мы видели, ключевое слово async заставляет функцию возвращать Promise вместо значения.

Резюме
Асинхронные функции возвращают обещания, которые являются значениями, доступными когда-нибудь в будущем.

Ключевое слово await

Using Promises with callbacks requires changes to the code structure and it makes the code harder to read.
Instead of a flat structure of synchronous function calls:

Использование Promises с обратными вызовами (callbacks) требует изменений в структуре кода и затрудняет чтение кода.
Вместо плоской структуры синхронных вызовов функций:

Для Promises необходимо вызывать callbacks:


Примечание
Promises поддерживают плоскую структуру, когда они вызываются последовательно, это их главное преимущество перед традиционными обратными вызовами. Например, ряд асинхронных вызовов может обрабатывать объект:

Это почти плоская структура, но в конце все еще необходим обратный вызов. Ключевое слово await устраняет необходимость в этом.


Чтобы решить эту проблему, не теряя преимуществ промисов, асинхронные функции могут использовать ключевое слово await. Он останавливает функцию до тех пор, пока обещание не будет готово, и возвращает значение результата.

Приведенный выше код, использующий обратные вызовы, может вместо этого использовать await, что приводит к более знакомой структуре:

Ключевое слово await, ожидающее асинхронных результатов, делает код почти синхронным, но со всеми преимуществами Promises.
Обратите внимание, что await останавливает выполнение функции, что кажется невозможным в Javascript. Но под капотом он по-прежнему использует обратные вызовы then(), и, поскольку асинхронные функции возвращают промисы, им не нужно немедленно предоставлять результат. Это позволяет остановить функцию без существенных изменений в том, как работает язык.

Примеры Async функции

Асинхронные функции (Async function) с await являются мощным инструментом. Они делают сложный и асинхронный рабочий процесс простым и знакомым, скрывая все сложности результатов, получаемых позже.
Автоматизация браузера является ярким примером. Проект Puppeteer позволяет запускать Chromium и управлять им с помощью протокола DevTools.
Сделать скриншот веб-страницы — это всего несколько строк:

Этот код скрывает большую сложность. Он запускает браузер, затем отправляет ему команды, все асинхронно, поскольку браузер — это отдельный процесс.
Но конечным результатом является буфер изображения, содержащий снимок экрана.

Примечание
Приведенный выше код оставляет браузер запущенным, если во время выполнения возникает ошибка.

Другой пример — взаимодействие с базами данных. Удаленной службе всегда требуются сетевые вызовы, а это означает асинхронные результаты.
Этот код, используемый в функции AWS Lambda, обновляет изображение аватара пользователя:

Он обращается к сервису AWS S3 для перемещения объектов и к базе данных DynamoDB для чтения и изменения данных. Оба они являются удаленными сервисами, но все сложности скрыты за await.

Резюме
Ключевое слово await останавливает функцию до тех пор, пока не станет доступен будущий результат.

  • Основная суть async/await: выполнять асинхронные операции синхронным образом.
  • Ключевое слово await можно использовать только в асинхронных функциях, иначе будет выдано сообщение об ошибке.

Chaining Promises (Цепочка обещаний)

Мы видели, что когда асинхронная функция возвращает значение, оно будет заключено в Promise, а ключевое слово await извлекает из него значение. Но что происходит, когда асинхронная функция возвращает обещание? Означает ли это, что вам нужно использовать два await?

Рассмотрим следующий код:

Строго следуя процессу, f1() возвращает Promise<2>, а f2() возвращает Promise<Promise<2>>, поэтому значением результата будет Promise<2> вместо 2.

Но это не то, что происходит. Когда асинхронная функция возвращает обещание, она возвращает его без добавления другого слоя. Не имеет значения, возвращает ли он значение или промис, это всегда будет промис, и он всегда будет разрешаться с окончательным значением, а не с другим промисом.

То же самое работает и для цепочек Promises. Обратный вызов .then() также заключен в Promise, если его еще нет, поэтому вы можете легко связать их в цепочку:

Функция getUser возвращает Promise<User>, затем getPermissionsForUser получает объект пользователя (разрешенное значение), а затем возвращает набор permission.
Следующий вызов checkPermission получает permissions, и т.д.
Полезным аналогом является то, как работает функция flatMap для массива. Неважно, возвращает он значение или массив, конечным результатом всегда будет массив со значениями. Это map, за которой следует flat.

Когда я не уверен, что возвращает цепочка Promises, я мысленно перевожу промисы в массивы, где каждая асинхронная функция возвращает плоский массив со своим результатом, а await получает первый элемент:

Затем цепочка Promises становится серией flatMap:

Это устраняет большинство сложностей, связанных с асинхронностью, и об этом намного проще рассуждать.

Демонстрационные примеры

Использования Promise, Async, Await

Код примера:

Результат выполнения:

Пример использования promise, await, async в цикле for loop

Код

Результат:

Демонстрация работы кода:

0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x