Как избежать async/await ада

Эта статья сама по себе хороша тем, что показывает случай async/await неразберихи в JavaScript. Конечно же, можно всё решить и другими методами, но тут именно наглядный пример того, как это избежать и не перегружать свой код совершенно ненужными решениями. Тем, кому интересно, советую почитать полное понимание синхронного и асинхронного JavaScript с Async/Await

Перевод статьи How to escape async/await hell

async/await освободил нас от оков колбэк ада, но люди уже начали этим злоупотреблять, ведя к рождению async/await “ада”.

В это статье, вы попытаетесь понять то, что такое этот самый async/await “ад” и я также поделюсь с вами несколькими советами о том, как его избежать.

Что такое async/await ад

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

Пример async/await ада

Предположим, что вы написали скрипт заказа пиццы и напитков. Этот скрипт, к примеру, может выглядеть примерно так:

(async () => {
const pizzaData = await getPizzaData() // асинхронный запрос
const drinkData = await getDrinkData() // асинхронный запрос
const chosenPizza = choosePizza() // синхронный вызов
const chosenDrink = chooseDrink() // синхронный вызов
await addPizzaToCart(chosenPizza) // асинхронный запрос
await addDrinkToCart(chosenDrink) // асинхронный запрос
orderItems() // асинхронный вызов
})()

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

Объяснение.

Мы обернули наш код в асинхронную IIFE (немедленно вызываемую функцию или просто ифи). Дальше всё будет происходить в таком порядке:

1. Мы получаем список пицц.
2. Получаем список напитков.
3. Выбираем пиццу из списка.
4. Выбираем напиток из списка.
5. Добавляем выбранную пиццу в корзину.
6. Добавляем выбранный напиток в корзину.
7. Заказываем товары из корзины.

Так что же не так?

Как я указывал раньше, всё это выполняется один за другим. Тут нет какой-либо согласованности. Посмотрите внимательно: зачем нам ожидать получения списка пицц, перед тем, как пытаться получить список напитков? Нам нужно постараться получить два списка вместе. Однако, когда нам нужно выбрать пиццу, нам нужно сначала получить список самих пицц. То же самое и с напитками.

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

Вот ещё один пример плохой реализации

Этот кусок js кода получит товары из корзины и разместит запрос на их заказ.

async function orderItems() {
const items = await getCartItems() // асинхронный запрос
const noOfItems = items.length
for(var i = 0; i < noOfItems; i++) {
await sendRequest(items[i]) // асинхронный запрос
}
}

В этом случае, циклу надо ожидать завершения sendRequest() функции, перед началом следующей итерации. Хотя, нам это и не нужно в принципе. Мы хотим отправить все запросы как можно быстрее и затем уже мы можем подожать их завершения.

Я думаю, что теперь вы стали ближе к пониманию того, что такое async/await ад и как сильно он влияет на производительность программы. А теперь я хочу задать вам вопрос.

Что если мы забудем поставить await?

Если вы забудете поставить await, во время вызова асинхронной функции, то функция начнет свою работу. Это означает то, что await необязательна для выполнения функции. Асинхронная функция вернёт промис, который вы сможете использовать позднее.

(async () => {
const value = doSomeAsyncTask()
console.log(value) // нерешенный промис
})()

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

Одним интересным свойством промисов является то, что вы можете получить промис на одной строке и ждать его выполнения уже на другой. Это ключ к побегу из async/await ада.

(async () => {
const promise = doSomeAsyncTask()
const value = await promise
console.log(value) // актуальное значение
})()

Как вы видите, doSomeAsyncTask() возвращает промис. Именно на этом моменте doSomeAsyncTask() и начинает своё выполнение. Чтобы получить готовое значение промиса, мы используем await и это скажет JavaScript о том, что не надо сразу же выполнять следующую строку кода, а вместо этого надо подождать решения промиса и уже затем приступать к выполнению следующей строки.

Как уйти от async/await ада?

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

Найдите зависимые выражения

В нашем первом примере, мы выбирали пиццу и напиток. Там мы уже решили, что перед выбором пиццы, нам нужно сначала получить список пицц. А перед добавлением пиццы в корзину, нам нужно выбрать саму пиццу. В общем, всё логично, тут мы можем сказать, что эти три шага зависимы друг от друга. Мы не можем сделать одно, перед тем, как сделать другое. Как-то так.

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

Следовательно, мы уже нашли зависимые выражения, ну и те, которые таковыми не являются.

Группируйте зависимые выражения в асинхронные функции

Как мы видели, выбор пиццы влечет за собой зависимые задачи, такие как: получение списка пицц, выбор пиццы и добавление выбранной пиццы в корзину. Нам следует сгруппировать эти процессы в одну асинхронную функцию. Таким образом, мы получим две разные асинхронные функции, selectPizza() и selectDrink().

Выполняйте эти асинхронные функции одновременно

Затем мы воспользуемся циклом событий, чтобы запустить эти асинхронные неблокируемые функции одновременно. Два общеизвестных подхода для этого — возвращение ранних промисов и метод promise.all.

Пофиксим всё!

Следуя этим трем шагам, давайте применим их на наших примерах.

async function selectPizza() {
const pizzaData = await getPizzaData() // асинхронный запрос
const chosenPizza = choosePizza() // синхронный вызов
await addPizzaToCart(chosenPizza) // асинхронный вызов
}

async function selectDrink() {
const drinkData = await getDrinkData() // асинхронный запрос
const chosenDrink = chooseDrink() // синхронный вызов
await addDrinkToCart(chosenDrink) // асинхронный вызов
}

(async () => {
const pizzaPromise = selectPizza()
const drinkPromise = selectDrink()
await pizzaPromise
await drinkPromise
orderItems() // асинхронный запрос
})()

// Хоть я и предпочитаю такой способ

Promise.all([selectPizza(), selectDrink()]).then(orderItems) // асинхронный запрос

Теперь мы сгруппировали выражения в два промиса. Внутри функции, каждое выражение зависит от выполнения предыдущего. Далее, мы одновременно выполнили две функции selectPizza() и selectDrink().

Во втором примере, нам нужно справиться с неизвестным количеством промисов. Сделать это супер легко: мы просто создаем массив и кидаем в него промисы. Далее, используем Promise.all(), который уже ждёт выполнения всех промисов.

async function orderItems() {
const items = await getCartItems() // асинхронный запрос
const noOfItems = items.length
const promises = []
for(var i = 0; i < noOfItems; i++) {
const orderPromise = sendRequest(items[i]) // асинхронный запрос
promises.push(orderPromise) // синхронный вызов
}
await Promise.all(promises) // асинхронный запрос
}
// Хоть я и предпочитаю такой способ
async function orderItems() {
const items = await getCartItems() // асинхронный запрос
const promises = items.map((item) => sendRequest(item))
await Promise.all(promises) // асинхронный запрос
}