Глубокое погружение в асинхронные JavaScript функции
JavaScript функции async
и await
– то, что важно понимать web-разработчику в 2019 году. В статье примеры кода и детальное погружение в тему.
Вначале были обратные вызовы.
Обратный вызов – функция, которая выполняется позднее.
Из-за асинхронной природы языка JavaScript обратные вызовы часто используются там, где результаты недоступны сразу.
Так выглядит асинхронное чтение файла в Node.js:
fs.readFile(__filename, 'utf-8', (err, data) => { if (err) { throw err; } console.log(data); });
Проблемы возникают, когда асинхронная операция не одна. Вот сценарий, где каждая операция асинхронная:
- Делаем запрос в базу данных для пользователя
Arfat
. - Считываем
profile_img_url
и получаем изображение сsomeServer.com
. - Далее преобразуем изображение в другой формат: PNG в JPEG.
- Если преобразование получилось, отправляем пользователю электронное письмо.
- И записываем эту задачу в наш файл
transfors.log
с отметкой времени.
Код выглядит так:
Обратите внимание на вложенность обратных вызовов и лестницу из })
в конце. Это ласково называется Ад обратных вызовов или Пирамида Судьбы (Pyramid of Doom). Главные недостатки:
- Код становится труднее читать, потому что читать приходится слева направо.
- Обработка ошибок сложна и часто приводит к ужасному коду.
Для решения этой проблемы боги JavaScript JS создали Promise. Теперь вместо вложенности обратных вызовов получаем цепочку.
Пример:
Поток стал привычным – сверху вниз, а не слева направо, как в обратных вызовах, что плюс. Тем не менее, с 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 function myFn() { // await ... } // со стрелочной функцией const myFn = async () => { // await ... } function myFn() { // await fn(); (Синтаксическая ошибка, поскольку нет async) }
Видите async
в начале объявления функции? Если функция стрелочная, async
ставится после знака =
и перед скобками.
Асинхронные функции используются и как методы объектов или в объявлениях класса. Это иллюстрируют JavaScript примеры:
// как метод объекта const obj = { async getName() { return fetch('https://www.example.com'); } } // в классе class Obj { async getResource() { return fetch('https://www.example.com'); } }
Примечание: конструкторы классов, геттеры и сеттеры не могут быть асинхронными.
Семантика и выполнение
Асинхронные функции – обычные функции JavaScript с такими отличиями:
Асинхронные JavaScript функции всегда возвращают Promise.
async function fn() { return 'привет'; } fn().then(console.log) // привет
Функция fn
возвращает 'привет'
. Поскольку использовали async
, возвращаемое значение 'привет'
оборачивается в Promise посредством Promise.resolve
.
Теперь посмотрим на эквивалентное альтернативное представление без использования async
:
function fn() { return Promise.resolve('привет'); } fn().then(console.log); // привет
В этом случае вручную возвращаем Promise вместо использования async
.
Точнее сказать, возвращаемое значение асинхронной функции JavaScript всегда оборачивается в Promise.resolve
.
Для примитивов Promise.resolve
возвращает обёрнутое в Promise значение. Но для объектов Promise возвращается тот же объект без оборачивания.
// для примитивных значений const p = Promise.resolve('hello') p instanceof Promise; // true // p возвращается как есть Promise.resolve(p) === p; // true
Что происходит, когда бросаем ошибку внутри асинхронной функции?
Например:
async function foo() { throw Error('bar'); } foo().catch(console.log);
foo()
вернёт отклонённый (rejected
) Promise, если ошибку не перехватили. Вместо Promise.resolve
Promise.reject
оборачивает и возвращает ошибку. Смотрите раздел Обработка ошибок дальше.
В результате, что бы мы ни возвращали, всегда получаем Promise из асинхронной функции.
Асинхронные функции останавливаются на каждом await <выражение>
.
await
действует на выражение. Если выражение – Promise, выполнение асинхронной функции останавливается до получения результата Promise. Если выражение – другое значение, происходит преобразование в Promise с помощью Promise.resolve
и выполнение resolve
.
// функция, вызывающая задержку // и получаем случайное значение const delayAndGetRandom = (ms) => { return new Promise(resolve => setTimeout( () => { const val = Math.trunc(Math.random() * 100); resolve(val); }, ms )); }; async function fn() { const a = await 9; const b = await delayAndGetRandom(1000); const c = await 5; await delayAndGetRandom(1000); return a + b * c; } // Выполнить fn fn().then(console.log);
Теперь рассмотрим функцию 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
для решения гипотетической задачи, поставленной в начале статьи:
Создаём асинхронную функцию finishMyTask
и используем await
для ожидания результата таких операций, как queryDatabase
, sendEmail
и logTaskInFile
.
Если сравним с первым решением на базе Promise, обнаружим, что это примерно та же строчка кода. Тем не менее, async/await
упростил синтаксис. Отсутствуют множественные обратные вызовы и .then
/.catch
.
Теперь решим задачу с числами, приведенную выше. Вот две реализации:
const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms)); // Реализация Один (С использованием цикла for) const printNumbers = () => new Promise((resolve) => { let pr = Promise.resolve(0); for (let i = 1; i <= 10; i += 1) { pr = pr.then((val) => { console.log(val); return wait(i, Math.random() * 1000); }); } resolve(pr); }); // Реализация Два(С использованием рекурсии) const printNumbersRecursive = () => { return Promise.resolve(0).then(function processNextPromise(i) { if (i === 10) { return undefined; } return wait(i, Math.random() * 1000).then((val) => { console.log(val); return processNextPromise(i + 1); }); }); };
Если хотите, запустите код самостоятельно в консоли repl.it.
Использование асинхронной функции с самого начала упростило бы задачу намного.
async function printNumbersUsingAsync() { for (let i = 0; i < 10; i++) { await wait(i, Math.random() * 1000); console.log(i); } }
Обработка ошибок
Помните, что необработанная Error()
оборачивается в отклонённый Promise? Несмотря на это, допускается использование try-catch
в асинхронных функциях для синхронной обработки ошибок. Начнём с этой служебной функции:
async function canRejectOrReturn() { // подождать одну секунду await new Promise(res => setTimeout(res, 1000)); // Отклонить с вероятностью ~ 50% if (Math.random() > 0.5) { throw new Error('Извините, слишком большое число.') } return 'идеальное число'; }
canRejectOrReturn()
– асинхронная функция, которая либо выполняется с результатом 'идеальное число'
, либо отклоняется с Error('Извините, слишком большое число')
.
Смотрите пример кода:
async function foo() { try { await canRejectOrReturn(); } catch (e) { return 'ошибка перехвачена'; } }
Поскольку ожидаем canRejectOrReturn
, его собственное отклонение превращается в ошибку, и блок catch
выполняется. То есть, foo
завершится либо с результатом undefined
(потому что ничего не возвращаем в try
), либо с 'ошибка перехвачена'
. Отклонения не произойдёт, так как использовали блок try-catch
для обработки ошибки внутри функции foo
.
Ещё один пример:
async function foo() { try { return canRejectOrReturn(); } catch (e) { return 'ошибка перехвачена'; } }
На этот раз возвращаем (а не ожидаем) canRejectOrReturn
из foo
. foo
либо выполнится с результатом 'идеальное число'
, либо отклонится с Error('Извините, слишком большое число')
. Блок catch
не будет выполнен.
Почему так? Просто возвращаем Promise, который вернул canRejectOrReturn
. Следовательно, выполнение foo
становится выполнением canRejectOrReturn
. Разделим return canRejectOrReturn()
на две строки для большей ясности. Обратите внимание на отсутствие await
в первой строке:
try { const promise = canRejectOrReturn(); return promise; }
И посмотрим, как использовать await
и return
вместе:
async function foo() { try { return await canRejectOrReturn(); } catch (e) { return 'ошибка перехвачена'; } }
В этом случае foo
завершится либо с результатом 'идеальное число'
, либо с 'ошибка перехвачена'
. Здесь нет отклонения. Это как первый пример, только с await
. За исключением того, что получаем значение, которое создаёт canRejectOrReturn
, а не undefined
.
Прервём return await canRejectOrReturn();
, чтобы увидеть эффект:
try { const value = await canRejectOrReturn(); return value; } // ...
Распространённые ошибки и подводные камни
Отсутствие await
Иногда забываем добавить ключевое слово await
перед Promise или вернуть его. Вот пример:
async function foo() { try { canRejectOrReturn(); } catch (e) { return 'caught'; } }
Обратите внимание, что не используется await
или return
. foo
всегда завершается с результатом undefined
без ожидания 1 секунду. Тем не менее, Promise начинает выполнение. Это запустит побочные эффекты. Если появится ошибка или отклонение, будет выдано UnhandledPromiseRejectionWarning
.
Асинхронные функции в обратных вызовах
Часто используем асинхронные функции в .map
или .filter
в качестве обратных вызовов. Рассмотрим пример. Предположим, функция fetchPublicReposCount(username)
извлекает количество общедоступных GitHub-репозиториев пользователя. Три пользователя для обработки. Посмотрим код:
const url = 'https://api.github.com/users'; // функция для получения количества репозиториев const fetchPublicReposCount = async (username) => { const response = await fetch(`${url}/${username}`); const json = await response.json(); return json['public_repos']; }
Хотим получить количество репозиториев ['ArfatSalman', 'octocat', 'norvig']
. Сделаем так:
const users = [ 'ArfatSalman', 'octocat', 'norvig' ]; const counts = users.map(async username => { const count = await fetchPublicReposCount(username); return count; });
Обратите внимание на async
в обратном вызове .map
. Ожидаем, что переменная counts
будет содержать количество репов. Но асинхронные функции возвращают Promise. Следовательно, counts
на самом деле – массив из Promise. .map
запускает анонимный обратный вызов для каждого username
, и при каждом вызове возвращается Promise, который .map
хранит в результирующем массиве.
Слишком последовательное использование await
Смотрите на такое решение:
async function fetchAllCounts(users) { const counts = []; for (let i = 0; i < users.length; i++) { const username = users[i]; const count = await fetchPublicReposCount(username); counts.push(count); } return counts; }
Вручную получаем каждое количество и добавляем в массив counts
. Проблема этого кода в том, что пока не будет получено количество для первого пользователя, следующее не запустится. За один раз выбирается только одно количество репов.
Если для одной выборки требуется 300 мс, то fetchAllCounts
будет занимать ~ 900 мс для 3 пользователей. Как видим, время линейно растёт с увеличением количества пользователей. Поскольку выборка репов не взаимозависимая, распараллелим операцию.
Получаем пользователей одновременно, а не последовательно с использованием .map
и Promise.all
.
async function fetchAllCounts(users) { const promises = users.map(async username => { const count = await fetchPublicReposCount(username); return count; }); return Promise.all(promises); }
Promise.all
принимает массив Promise на входе и возвращает Promise на выходе. Конечный Promise получает массив результатов всех Promise или становится rejected
при первом отклонении. Для частичного параллелизма смотрите p-map.
Заключение
С введением асинхронных итераторов асинхронные функции получат ещё большее распространение. Тем, кто изучает программирование JavaScript, важно понимание этих концепций. Надеемся, что статья прольёт свет на await
и async
.