Глубокое погружение в асинхронные JavaScript функции

5
8131
Добавить в избранное

JavaScript функции async и await – то, что важно понимать web-разработчику в 2019 году. В статье примеры кода и детальное погружение в тему.

Вначале были обратные вызовы.

Обратный вызов – функция, которая выполняется позднее.

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

Так выглядит асинхронное чтение файла в Node.js:

Проблемы возникают, когда асинхронная операция не одна. Вот сценарий, где каждая операция асинхронная:

  • Делаем запрос в базу данных для пользователя Arfat.
  • Считываем profile_img_url и получаем изображение с someServer.com.
  • Далее преобразуем изображение в другой формат: PNG в JPEG.
  • Если преобразование получилось, отправляем пользователю электронное письмо.
  • И записываем эту задачу в наш файл transfors.log с отметкой времени.

Код выглядит так:

Глубокое погружение в асинхронные JavaScript функции

Обратите внимание на вложенность обратных вызовов и лестницу из }) в конце. Это ласково называется Ад обратных вызовов или Пирамида Судьбы (Pyramid of Doom). Главные недостатки:

  • Код становится труднее читать, потому что читать приходится слева направо.
  • Обработка ошибок сложна и часто приводит к ужасному коду.

Для решения этой проблемы боги JavaScript JS создали Promise. Теперь вместо вложенности обратных вызовов получаем цепочку.

Пример:

Глубокое погружение в асинхронные JavaScript функции

Поток стал привычным – сверху вниз, а не слева направо, как в обратных вызовах, что плюс. Тем не менее, с Promise по-прежнему проблемы:

  • Нуждаемся в обратном вызове для каждого .then.
  • Вместо try/catch приходится использовать .catch для обработки ошибок.
  • Организация циклов с множественными Promise в последовательности бросает вызов.

Для демонстрации последнего пункта примем этот вызов!

Задача

Предположим, цикл for выводит от 0 до 10 с произвольными интервалами (от 0 до n секунд). Требуется изменить поведение с использованием Promise так, чтобы числа печатались последовательно от 0 до 10. Например, если 0 отображается за 6 секунд, а 1 – за две секунды, то 1 ждёт печати 0 и так далее.

Само собой разумеется, не используйте JavaScript функции async и await или sort. Решение будет к концу.

Асинхронные JavaScript функции

После ES2017(ES8) JavaScript основы языка дополнились асинхронными функциями, которые упростили работу с Promise.

  • Асинхронные функции JavaScript работают поверх Promise.
  • Это не диаметрально другая концепция.
  • Функции рассматриваются как альтернативный способ написания кода на основе Promise.
  • С использованием async и await избегаем создания цепочки Promise.
  • В итоге получаем асинхронное выполнение при сохранении нормального синхронного подхода.

Следовательно, требуется понимание Promise для осознания концепции async/await.

Синтаксис

Здесь применяются два ключевых слова – async и await. async используется, чтобы сделать функцию асинхронной. Это разблокирует использование await внутри этих функций. Использование await в другом случае – синтаксическая ошибка.

Видите async в начале объявления функции? Если функция стрелочная, async ставится после знака = и перед скобками.

Асинхронные функции используются и как методы объектов или в объявлениях класса. Это иллюстрируют JavaScript примеры:

Примечание: конструкторы классов, геттеры и сеттеры не могут быть асинхронными.

Семантика и выполнение

Асинхронные функции – обычные функции JavaScript с такими отличиями:

Асинхронные JavaScript функции всегда возвращают Promise.

Функция fn возвращает 'привет'. Поскольку использовали async, возвращаемое значение 'привет' оборачивается в Promise посредством Promise.resolve.

Теперь посмотрим на эквивалентное альтернативное представление без использования async:

В этом случае вручную возвращаем Promise вместо использования async.

Точнее сказать, возвращаемое значение асинхронной функции JavaScript всегда оборачивается в Promise.resolve.

Для примитивов Promise.resolve возвращает обёрнутое в Promise значение. Но для объектов Promise возвращается тот же объект без оборачивания.

Что происходит, когда бросаем ошибку внутри асинхронной функции?

Например:

foo() вернёт отклонённый (rejected) Promise, если ошибку не перехватили. Вместо Promise.resolve Promise.reject оборачивает и возвращает ошибку. Смотрите раздел Обработка ошибок дальше.

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

Асинхронные функции останавливаются на каждом await <выражение>.

await действует на выражение. Если выражение – Promise, выполнение асинхронной функции останавливается до получения результата Promise. Если выражение – другое значение, происходит преобразование в Promise с помощью Promise.resolve и выполнение resolve.

Теперь рассмотрим функцию fn построчно:

  • Когда выполняется fn, первой отработает строка const a = await 9;. Она внутри преобразуется в const a = await Promise.resolve(9);.
  • Поскольку используем await, fn делает паузу, пока переменная a не получит значение. В этом случае Promise назначит ей результат 9.
  • delayAndGetRandom(1000) заставляет fn приостанавливаться до тех пор, пока не выполнится функция delayAndGetRandom, что происходит через 1 секунду. Таким образом, fn делает паузу на 1 секунду.
  • Кроме того, delayAndGetRandom резолвится со случайным значением. Что бы ни передавалось в функцию resolve, значение присваивается переменной b.
  • c получает значение 5 аналогичным образом, и снова задержка на 1 секунду из-за await delayAndGetRandom(1000). В этом случае не используем конечное значение.
  • Наконец, вычисляем результат a + b * c, который обёрнут в Promise с использованием Promise.resolve. Эта обёртка возвращается.

Решение

Воспользуемся async/await для решения гипотетической задачи, поставленной в начале статьи:

Глубокое погружение в асинхронные JavaScript функции

Создаём асинхронную функцию finishMyTask и используем await для ожидания результата таких операций, как queryDatabase, sendEmail и logTaskInFile.

Если сравним с первым решением на базе Promise, обнаружим, что это примерно та же строчка кода. Тем не менее, async/await упростил синтаксис. Отсутствуют множественные обратные вызовы и .then/.catch.

Теперь решим задачу с числами, приведенную выше. Вот две реализации:

Если хотите, запустите код самостоятельно в консоли repl.it.

Использование асинхронной функции с самого начала упростило бы задачу намного.

Обработка ошибок

Помните, что необработанная Error() оборачивается в отклонённый Promise? Несмотря на это, допускается использование try-catch в асинхронных функциях для синхронной обработки ошибок. Начнём с этой служебной функции:

canRejectOrReturn() – асинхронная функция, которая либо выполняется с результатом 'идеальное число', либо отклоняется с Error('Извините, слишком большое число').

Смотрите пример кода:

Поскольку ожидаем canRejectOrReturn, его собственное отклонение превращается в ошибку, и блок catch выполняется. То есть, foo завершится либо с результатом undefined (потому что ничего не возвращаем в try), либо с 'ошибка перехвачена'. Отклонения не произойдёт, так как использовали блок try-catch для обработки ошибки внутри функции foo.

Ещё один пример:

На этот раз возвращаем (а не ожидаем) canRejectOrReturn из foo. foo либо выполнится с результатом 'идеальное число', либо отклонится с Error('Извините, слишком большое число'). Блок catch не будет выполнен.

Почему так? Просто возвращаем Promise, который вернул canRejectOrReturn. Следовательно, выполнение foo становится выполнением canRejectOrReturn. Разделим return canRejectOrReturn() на две строки для большей ясности. Обратите внимание на отсутствие await в первой строке:

И посмотрим, как использовать await и return вместе:

В этом случае foo завершится либо с результатом 'идеальное число', либо с 'ошибка перехвачена'. Здесь нет отклонения. Это как первый пример, только с await. За исключением того, что получаем значение, которое создаёт canRejectOrReturn, а не undefined.

Прервём return await canRejectOrReturn();, чтобы увидеть эффект:

Распространённые ошибки и подводные камни

Отсутствие await

Иногда забываем добавить ключевое слово await перед Promise или вернуть его. Вот пример:

Обратите внимание, что не используется await или return. foo всегда завершается с результатом undefined без ожидания 1 секунду. Тем не менее, Promise начинает выполнение. Это запустит побочные эффекты. Если появится ошибка или отклонение, будет выдано UnhandledPromiseRejectionWarning.

Асинхронные функции в обратных вызовах

Часто используем асинхронные функции в .map или .filter в качестве обратных вызовов. Рассмотрим пример. Предположим, функция fetchPublicReposCount(username) извлекает количество общедоступных GitHub-репозиториев пользователя. Три пользователя для обработки. Посмотрим код:

Хотим получить количество репозиториев ['ArfatSalman', 'octocat', 'norvig']. Сделаем так:

Обратите внимание на async в обратном вызове .map. Ожидаем, что переменная counts будет содержать количество репов. Но асинхронные функции возвращают Promise. Следовательно, counts на самом деле – массив из Promise. .map запускает анонимный обратный вызов для каждого username, и при каждом вызове возвращается Promise, который .map хранит в результирующем массиве.

Слишком последовательное использование await

Смотрите на такое решение:

Вручную получаем каждое количество и добавляем в массив counts. Проблема этого кода в том, что пока не будет получено количество для первого пользователя, следующее не запустится. За один раз выбирается только одно количество репов.

Если для одной выборки требуется 300 мс, то fetchAllCounts будет занимать ~ 900 мс для 3 пользователей. Как видим, время линейно растёт с увеличением количества пользователей. Поскольку выборка репов не взаимозависимая, распараллелим операцию.

Получаем пользователей одновременно, а не последовательно с использованием .map и Promise.all.

Promise.all принимает массив Promise на входе и возвращает Promise на выходе. Конечный Promise получает массив результатов всех Promise или становится rejected при первом отклонении. Для частичного параллелизма смотрите p-map.

Заключение

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

Оригинал

А с какими проблемами в асинхронном программировании сталкивались вы?

Интересуетесь фронтендом?

Подпишитесь на нашу рассылку, чтобы получать больше интересных материалов:

И не беспокойтесь, мы тоже не любим спам. Отписаться можно в любое время.




Комментариев: 5

  1. GERASIM GERASIMOV

    Привет. Как красиво извне(снаружи) остановить выполняющуюся асинхронную функцию?
    Напр. я запускаю асинхронну функцию, в которой через For of запускается await функция читающая данные юзеров с сервера, и таких юзеров тысячи.
    Даже если пользователь ушёл со страницы, async функция продолжает выполнятся

  2. function fetchAllCounts(users) {
    return Promise.all(users.map(fetchPublicReposCount));
    }

    Ну или хотя бы await во внутренней функции не писать, не нужен он.

    1. Вы правы, можно и так. Спасибо за внимательность! Здесь написали иначе для наглядности. Надеемся, не сильно многословно получилось 🙂

  3. Monohromniy Qvant

    Сейчас бы в каждую строку константу пихать…

    1. Иди изучать парадигмы программирования

Добавить комментарий