Недавно мы опубликовали материал «Промисы в ES6: паттерны и анти-паттерны». Он вызвал серьёзный интерес аудитории, в комментариях к нему наши читатели рассуждали об особенностях написания асинхронного кода в современных JS-проектах. Кстати, советуем почитать их комментарии — найдёте там много интересного.
По совету пользователя ilnuribat мы добавили к материалу опрос, целью которого было выяснить популярность промисов, коллбэков и конструкций async / await. По состоянию на 9-е сентября промисы и async / await получили примерно по 43% голосов, с небольшим перевесом async / await, коллбэкам досталось 14%. Главный вывод, который можно сделать, проанализировав результаты опроса и комментарии, заключается в том, что важны все имеющиеся технологии, однако, всё больше программистов тяготеют к async / await. Поэтому сегодня мы решили опубликовать перевод статьи про переход на async / await, которая является продолжением материала о промисах.
Коллбэки, промисы, async / await
На прошлой неделе я писал о промисах, возможности JS, которая появилась в ES6. Промисы были отличным способом вырваться из ада коллбэков. Однако сейчас, когда в Node.js (с версии 7.6.) появилась поддержка async / await
, у меня сложилось восприятие промисов как чего-то вроде временного подручного средства. Надо сказать, что async / await
можно пользоваться и в браузерном коде благодаря транспиляторам вроде babel.
Хочу сказать, что в этом материале я буду применять самые свежие возможности JS, в том числе — шаблонные литералы и стрелочные функции. Посмотреть список новшеств ES6 можно здесь.
Почему async / await — это замечательно?
До недавних пор асинхронный код в JavaScript, в лучшем случае, выглядел неуклюжим. Для разработчиков, перешедших в JavaScript с таких языков, как Python, Ruby, Java, да практически с любых других, коллбэки и промисы казались неоправданно усложнёнными конструкциями, которые подвержены ошибкам и совершенно сбивают программиста с толку.
Проблема заключалась в том, что для программиста нет особой разницы между синхронной и асинхронной логикой. Есть масса проблем, касающихся производительности и оптимизации, о которых программисту необходимо думать, когда он занимается написанием асинхронного кода, но вот совершенно различный синтаксис — это уже чересчур.
Вот три примера, реализующих одну и ту же логику. Первый использует обычные синхронные функции, второй — коллбэки, третий — промисы. Каждый решает одну и ту же задачу: загрузку сведений о самой популярной статье на HackerNews.
Вот гипотетический пример синхронной версии:
// Примечание: этот код не работает!
let hn = require('@datafire/hacker_news').create();
let storyIDs = hn.getStories({storyType: 'top'});
let topStory = hn.getItem({itemID: storyIDs[0]});
console.log(`Top story: ${topStory.title} - ${topStory.url}`);
Тут всё предельно просто — ничего нового для любого, кто писал на JS. В коде выполняются три шага: получить список идентификаторов материалов, загрузить сведения о самом популярном и вывести результат.
Всё это хорошо, но в JavaScript нельзя блокировать цикл событий. Поэтому, если идентификаторы статей и сведения о популярной статье поступают из файла, приходят в виде ответа на сетевой запрос, если их читают из базы данных, или если они попадают в программу в результате выполнения любой ресурсоёмкой операции ввода-вывода, соответствующие команды всегда следует делать асинхронными, используя коллбэки или промисы (именно поэтому вышеприведённый код и не будет работать, на самом деле наш клиент для HackerNews основан на промисах).
Вот — та же логика, реализованная на коллбэках (пример, опять же, гипотетический):
// Примечание: этот код не работает!
let hn = require('@datafire/hacker_news').create();
hn.getStories({storyType: 'top'}, (err, storyIDs) => {
if (err) throw err;
hn.getItem({itemID: storyIDs[0]}, (err, topStory) => {
if (err) throw err;
console.log(`Top story: ${topStory.title} - ${topStory.url}`);
})
})
Да уж. Теперь фрагменты кода, реализующие необходимый нам функционал, вложены друг в друга и мы должны их выравнивать по горизонтали. Если бы тут было 20 шагов вместо трёх, то для выравнивания последнего понадобилось бы 40 пробелов! И, если понадобится добавить новый шаг где-нибудь в середине, пришлось бы заново выравнивать всё то, что находится ниже него. Это приводит к появлению огромных и бесполезных различий между разными состояниями файла в Git. Кроме того, обратите внимание на то, что мы должны обрабатывать ошибки на каждом шаге всей этой структуры. Сгруппировать набор операций в одном блоке try / catch
не получится.
Попробуем теперь сделать то же самое, воспользовавшись промисами:
let hn = require('@datafire/hacker_news').create();
Promise.resolve()
.then(_ => hn.getStories({storyType: 'top'}))
.then(storyIDs => hn.getItem({itemID: storyIDs[0]))
.then(topStory => console.log(`Top story: ${topStory.title} - ${topStory.url}`))
Так, это уже смотрится получше. Все три шага одинаково выровнены по горизонтали и добавление нового шага где-нибудь посередине не сложнее вставки новой строки. В результате можно сказать, что синтаксис промисов слегка многословен из-за необходимости использовать Promise.resolve()
и из-за всех присутствующих здесь конструкций .then()
.
Теперь, разобравшись с обычными функциями, коллбэками и промисами, посмотрим как сделать то же самое с помощью конструкции async / await
:
let hn = require('@datafire/hacker_news').create();
(async () => {
let storyIDs = await hn.getStories({storyType: 'top'});
let topStory = await hn.getItem({itemID: storyIDs[0]});
console.log(`Top story: ${topStory.title} - ${topStory.url}`);
})();
Вот это уже гораздо лучше! Выглядит то, что у нас получилось, как синхронный код, за исключением того, что тут используется ключевое слово await
. Кроме того, мы поместили код в анонимную функцию, объявленную с ключевым словом async
для того, чтобы этот фрагмент кода лучше подходил для дальнейшей работы с ним.
Тут надо сказать, что методы hn.getStories()
и hn.getItem()
устроены так, что они возвращают промисы. При их выполнении, цикл событий не блокируется. Благодаря async / await
, впервые в истории JS, мы смогли писать асинхронный код, используя обычный декларативный синтаксис!
Переход на async / await
Итак, как же приступить к использованию async / await
в своих проектах? Если вы уже работаете с промисами, значит вы готовы к переходу на новую технологию. Любая функция, которая возвращает промис, может быть вызвана с использованием ключевого слова await
, что приведёт к тому, что она вернёт результат разрешения промиса. Однако, если вы собираетесь переходить на async / await
с коллбэков, вам понадобится сначала преобразовать их в промисы.
▍Переход на async / await с промисов
Если вы один из тех, кто оказался в первых рядах разработчиков, принявших промисы, и в вашем коде, для реализации асинхронной логики, используются цепочки .then()
, переход на async / await
затруднений не вызовет: нужно лишь переписать каждую конструкцию .then()
с использованием await
.
Кроме того, блок .catch()
надо заменить на стандартные блоки try / catch
. Как видите, наконец-то мы можем использовать один и тот же подход для обработки ошибок в синхронном и асинхронном контекстах!
Важно отметить ещё и то, что ключевое слово await
нельзя использовать на верхнем уровне модулей. Оно должно использоваться внутри функций, объявленных с ключевым словом async
.
let hn = require('@datafire/hacker_news').create();
// Старый код с промисами:
Promise.resolve()
.then(_ => hn.getStories({storyType: 'top'}))
.then(storyIDs => hn.getItem({itemID: storyIDs[0]))
.then(topStory => console.log(topStory))
.catch(e => console.error(e))
// Новый код с async / await:
(async () => {
try {
let storyIDs = await hn.getStories({storyType: 'top'});
let topStory = await hn.getItem({itemID: storyIDs[0]});
console.log(topStory);
} catch (e) {
console.error(e);
}
})();
▍Переход на async / await с коллбэков
Если в вашем коде всё ещё применяются функции обратного вызова, лучший способ перехода на async / await
заключается в предварительном преобразовании коллбэков в промисы. Затем, используя вышеописанную методику, код, использующий промисы, переписывают с использованием async / await
. О том, как преобразовывать коллбэки в промисы, можно почитать здесь.
Паттерны и подводные камни
Конечно, новые технологии — это всегда и новые проблемы. Вот несколько полезных шаблонов и типовых ошибок, с которыми вы можете столкнуться, переводя свой код на async / await
.
▍Циклы
Ещё с тех времён, когда я только начинал писать на JS, передача функций в качестве аргументов для других функций была одной из моих любимых возможностей. Конечно, коллбэки — это беспорядок, но я, например, предпочитал использовать Array.forEach
вместо обычного цикла for
:
const BEATLES = ['john', 'paul', 'george', 'ringo'];
// Обычный цикл for:
for (let i = 0; i < BEATLES.length; ++i) {
console.log(BEATLES[i]);
}
// Метод Array.forEach:
BEATLES.forEach(beatle => console.log(beatle))
Однако, при использовании await
метод Array.forEach
правильно работать не будет, так как он рассчитан на выполнение синхронных операций:
let hn = require('@datafire/hacker_news').create();
(async () => {
let storyIDs = await hn.getStories({storyType: 'top'});
storyIDs.forEach(async itemID => {
let details = await hn.getItem({itemID});
console.log(details);
});
console.log('done!'); // Ошибка! Эта команда будет исполнена до того, как все вызовы getItem() будут завершены.
})();
В этом примере forEach
запускает кучу одновременных асинхронных обращений к getItem()
и немедленно возвращает управление, не ожидая результатов, поэтому первым, что будет выведено на экран, окажется строка «done!».
Если вам нужно дождаться результатов асинхронных операций, это значит, что понадобится либо обычный цикл for
(который будет выполнять операции последовательно), либо конструкция Promise.all
(она будет выполнять операции параллельно):
let hn = require('@datafire/hacker_news').create();
(async () => {
let storyIDs = await hn.getStories({storyType: 'top'});
// Использование цикла for (последовательное выполнение операций)
for (let i = 0; i < storyIDs.length; ++i) {
let details = await hn.getItem({itemID: storyIDs[i]});
console.log(details);
}
// Использование Promise.all (параллельное выполнение операций)
let detailSet = await Promise.all(storyIDs.map(itemID => hn.getItem({itemID})));
detailSet.forEach(console.log);
})();
▍Оптимизация
При использовании async / await
вам больше не нужно думать о том, что пишете вы асинхронный код. Это прекрасно, но тут кроется и самая опасная ловушка новой технологии. Дело в том, что при таком подходе можно забыть о мелочах, которые способны оказать огромное влияние на производительность.
Рассмотрим пример. Предположим, мы хотим получить сведения о двух пользователях Hacker News и сравнить их карму. Вот обычная реализация:
let hn = require('@datafire/hacker_news').create();
(async () => {
let user1 = await hn.getUser({username: 'sama'});
let user2 = await hn.getUser({username: 'pg'});
let [more, less] = [user1, user2].sort((a, b) => b.karma - a.karma);
console.log(`${more.id} has more karma (${more.karma}) than ${less.id} (${less.karma})`);
})();
Код это вполне рабочий, но второй вызов getUser()
не будет выполнен до тех пор, пока не завершится первый. Вызовы независимы, их можно выполнить параллельно. Поэтому ниже приведено более удачное решение:
let hn = require('@datafire/hacker_news').create();
(async () => {
let users = await Promise.all([
hn.getUser({username: 'sama'}),
hn.getUser({username: 'pg'}),
]);
let [more, less] = users.sort((a, b) => b.karma - a.karma);
console.log(`${more.id} has more karma (${more.karma}) than ${less.id} (${less.karma})`);
})();
Тут стоит отметить, что прежде чем пользоваться этим методом, стоит удостовериться в том, что желаемого можно достичь за счёт параллельного выполнения команд. Во многих случаях асинхронные операции должны выполняться последовательно.
Итоги
Надеюсь, мне удалось показать вам, какие замечательные новшества внесла конструкция async / await
в разработку асинхронного кода на JavaScript. Возможность описывать асинхронные конструкции, используя тот же синтаксис, что и синхронные — это стандарт современного программирования. А то, что теперь та же возможность доступна и в JavaScript — огромный шаг вперёд для всех, кто пишет на этом языке.
Уважаемые читатели! Мы знаем, по результатам опроса из предыдущей публикации, что многие из вас пользуются async / await. Поэтому просим поделиться опытом.
Автор: ru_vds