Асинхронный JavaScript и ожидание в циклах | Зелл Лью
Базовый async
а также await
просто. Все становится немного сложнее, когда вы пытаетесь использовать await
в петлях.
В этой статье я хочу поделиться некоторыми подводными камнями, на которые следует обратить внимание, если вы собираетесь использовать await
в петлях.
Прежде чем вы начнете
Я собираюсь предположить, что вы знаете, как использовать async
а также await
. Если нет, прочтите предыдущая статья для ознакомления перед продолжением.
Подготовка примера
Для этой статьи предположим, что вы хотите получить количество фруктов из корзины с фруктами.
const fruitBasket = {
apple: 27,
grape: 0,
pear: 14
};
Вы хотите получить номер каждого фрукта из корзины фруктов. Чтобы получить номер фрукта, вы можете использовать getNumFruit
функция.
const getNumFruit = fruit => {
return fruitBasket[fruit]
}
const numApples = getNumFruit('apple')
console.log(numApples) // 27
Теперь, скажем, fruitBasket
живет на удаленном сервере. Доступ к нему занимает одну секунду. Мы можем смоделировать эту задержку в одну секунду с помощью тайм-аута. (Пожалуйста, обратитесь к предыдущая статья если у вас есть проблемы с пониманием кода тайм-аута).
const sleep = ms => {
return new Promise(resolve => setTimeout(resolve, ms))
}
const getNumFruit = fruit => {
return sleep(1000).then(v => fruitBasket[fruit])
}
getNumFruit('apple')
.then(num => console.log(num)) // 27
Наконец, допустим, вы хотите использовать await
а также getNumFruit
чтобы получить количество каждого фрукта в асинхронной функции.
const control = async _ => {
console.log('Start')
const numApples = await getNumFruit('apple')
console.log(numApples)
const numGrapes = await getNumFruit('grape')
console.log(numGrapes)
const numPears = await getNumFruit('pear')
console.log(numPears)
console.log('End')
}
С этим мы можем начать рассматривать await
в петлях.
Ожидание в цикле for
Допустим, у нас есть набор фруктов, которые мы хотим достать из корзины с фруктами.
const fruitsToGet = ['apple', 'grape', 'pear']
Мы собираемся пройтись по этому массиву.
const forLoop = async _ => {
console.log('Start')
for (let index = 0; index < fruitsToGet.length; index++) {
// Get num of each fruit
}
console.log('End')
}
В цикле for мы будем использовать getNumFruit
чтобы получить номер каждого фрукта. Мы также запишем номер в консоль.
С getNumFruit
возвращает обещание, мы можем await
разрешенное значение перед его регистрацией.
const forLoop = async _ => {
console.log('Start')
for (let index = 0; index < fruitsToGet.length; index++) {
const fruit = fruitsToGet[index]
const numFruit = await getNumFruit(fruit)
console.log(numFruit)
}
console.log('End')
}
Когда вы используете await
, вы ожидаете, что JavaScript приостановит выполнение до тех пор, пока ожидаемое обещание не будет разрешено. Это означает await
s в цикле for должны выполняться последовательно.
Результат такой, какой вы ожидаете.
'Start'
'Apple: 27'
'Grape: 0'
'Pear: 14'
'End'
Такое поведение работает с большинством циклов (например, while
а также for-of
петли)…
Но это не будет работать с циклами, требующими обратного вызова. Примеры таких циклов, требующих отката, включают: forEach
, map
, filter
а также reduce
. Мы посмотрим, как await
влияет forEach
, map
а также filter
в следующих нескольких разделах.
Ожидание в цикле forEach
Мы сделаем то же самое, что и в примере с циклом for. Во-первых, давайте пройдемся по массиву фруктов.
const forEachLoop = _ => {
console.log('Start')
fruitsToGet.forEach(fruit => {
// Send a promise for each fruit
})
console.log('End')
}
Далее попробуем получить количество фруктов с помощью getNumFruit
. (Обратите внимание на async
ключевое слово в функции обратного вызова. Нам это нужно async
ключевое слово, потому что await
находится в функции обратного вызова).
const forEachLoop = _ => {
console.log('Start')
fruitsToGet.forEach(async fruit => {
const numFruit = await getNumFruit(fruit)
console.log(numFruit)
})
console.log('End')
}
Вы можете ожидать, что консоль будет выглядеть так:
'Start'
'27'
'0'
'14'
'End'
Но реальный результат другой. JavaScript переходит к вызову console.log('End')
до того, как обещания в цикле forEach будут разрешены.
Консоль логируется в таком порядке:
'Start'
'End'
'27'
'0'
'14'
JavaScript делает это, потому что forEach
не поддерживает обещания. Он не может поддерживать async
а также await
. Ты не может использовать await
в forEach
.
Подождите с картой
Если вы используете await
в map
, map
всегда будет возвращать массив обещаний. Это связано с тем, что асинхронные функции всегда возвращают обещания.
const mapLoop = async _ => {
console.log('Start')
const numFruits = await fruitsToGet.map(async fruit => {
const numFruit = await getNumFruit(fruit)
return numFruit
})
console.log(numFruits)
console.log('End')
}
'Start'
'[Promise, Promise, Promise]'
'End'
С map
всегда возвращайте обещания (если вы используете await
), вам нужно дождаться разрешения массива обещаний. Вы можете сделать это с await Promise.all(arrayOfPromises)
.
const mapLoop = async _ => {
console.log('Start')
const promises = fruitsToGet.map(async fruit => {
const numFruit = await getNumFruit(fruit)
return numFruit
})
const numFruits = await Promise.all(promises)
console.log(numFruits)
console.log('End')
}
Вот что вы получаете:
'Start'
'[27, 0, 14]'
'End'
Вы можете манипулировать значением, которое вы возвращаете в своих обещаниях, если хотите. Разрешенные значения будут значениями, которые вы возвращаете.
const mapLoop = async _ => {
// ...
const promises = fruitsToGet.map(async fruit => {
const numFruit = await getNumFruit(fruit)
// Adds onn fruits before returning
return numFruit + 100
})
// ...
}
'Start'
'[127, 100, 114]'
'End'
Подождите с фильтром
Когда вы используете filter
, вы хотите отфильтровать массив с определенным результатом. Допустим, вы хотите создать массив с более чем 20 фруктами.
Если вы используете filter
обычно (без ожидания) вы будете использовать его так:
// Filter if there's no await
const filterLoop = _ => {
console.log('Start')
const moreThan20 = await fruitsToGet.filter(fruit => {
const numFruit = fruitBasket[fruit]
return numFruit > 20
})
console.log(moreThan20)
console.log('End')
}
Вы ожидаете moreThan20
содержать только яблоки, потому что яблок 27, а винограда 0 и груш 14.
'Start'
['apple']
'End'
await
в filter
не работает так же. На самом деле он вообще не работает. Вы получаете нефильтрованный массив обратно…
const filterLoop = _ => {
console.log('Start')
const moreThan20 = await fruitsToGet.filter(async fruit => {
const numFruit = getNumFruit(fruit)
return numFruit > 20
})
console.log(moreThan20)
console.log('End')
}
'Start'
['apple', 'grape', 'pear']
'End'
Вот почему это происходит.
Когда вы используете await
в filter
обратный вызов, обратный вызов всегда обещание. Поскольку промисы всегда правдивы, все элементы массива проходят фильтр. Пишу await
в filter
похоже на написание этого кода:
// Everything passes the filter...
const filtered = array.filter(true)
Есть три шага для использования await
а также filter
правильно:
- Использовать
map
вернуть массив обещаний await
массив обещанийfilter
разрешенные значения
const filterLoop = async _ => {
console.log('Start')
const promises = await fruitsToGet.map(fruit => getNumFruit(fruit))
const numFruits = await Promise.all(promises)
const moreThan20 = fruitsToGet.filter((fruit, index) => {
const numFruit = numFruits[index]
return numFruit > 20
})
console.log(moreThan20)
console.log('End')
}
Start
['apple']
End
Подождите с уменьшением
В этом случае предположим, что вы хотите узнать общее количество фруктов в FruitBastet. Как правило, вы можете использовать reduce
для перебора массива и суммирования числа.
// Reduce if there's no await
const reduceLoop = _ => {
console.log('Start')
const sum = fruitsToGet.reduce((sum, fruit) => {
const numFruit = fruitBasket[fruit]
return sum + numFruit
}, 0)
console.log(sum)
console.log('End')
}
Вы получите в общей сложности 41 фрукт. (27 + 0 + 14 = 41).
'Start'
'41'
'End'
Когда вы используете await
с уменьшением результаты становятся чрезвычайно беспорядочными.
// Reduce if we await getNumFruit
const reduceLoop = async _ => {
console.log('Start')
const sum = await fruitsToGet.reduce(async (sum, fruit) => {
const numFruit = await getNumFruit(fruit)
return sum + numFruit
}, 0)
console.log(sum)
console.log('End')
}
'Start'
'[object Promise]14'
'End'
Какая?! [object Promise]14
?!
Разбирать это интересно.
- В первой итерации
sum
является0
.numFruit
равно 27 (разрешенное значение изgetNumFruit('apple')
).0 + 27
27. - Во второй итерации
sum
это обещание. (Почему? Потому что асинхронные функции всегда возвращают промисы!)numFruit
равно 0. Промис не может быть добавлен к объекту обычным образом, поэтому JavaScript преобразует его в[object Promise]
нить.[object Promise] + 0
является[object Promise]0
- В третьей итерации
sum
также является обещанием.numFruit
является14
.[object Promise] + 14
является[object Promise]14
.
Тайна раскрыта!
Это означает, что вы можете использовать await
в reduce
обратный вызов, но вы должны помнить await
Аккумулятор в первую очередь!
const reduceLoop = async _ => {
console.log('Start')
const sum = await fruitsToGet.reduce(async (promisedSum, fruit) => {
const sum = await promisedSum
const numFruit = await getNumFruit(fruit)
return sum + numFruit
}, 0)
console.log(sum)
console.log('End')
}
'Start'
'41'
'End'
Но… как видно из гифки, на это уходит довольно много времени. await
все. Это происходит потому, что reduceLoop
необходимо дождаться promisedSum
выполняться для каждой итерации.
Есть способ ускорить цикл сокращения. (Я узнал об этом благодаря Тим Оксли). если ты await getNumFruits()
сначала перед await promisedSum
, reduceLoop
занимает всего одну секунду:
const reduceLoop = async _ => {
console.log('Start')
const sum = await fruitsToGet.reduce(async (promisedSum, fruit) => {
// Heavy-lifting comes first.
// This triggers all three `getNumFruit` promises before waiting for the next interation of the loop.
const numFruit = await getNumFruit(fruit)
const sum = await promisedSum
return sum + numFruit
}, 0)
console.log(sum)
console.log('End')
}
Это работает, потому что reduce
может стрелять всеми тремя getNumFruit
обещания перед ожиданием следующей итерации цикла. Однако этот метод немного сбивает с толку, так как вы должны быть осторожны с порядком, в котором вы await
вещи.
Самый простой (и самый эффективный) способ использования await
в сокращении:
- Использовать
map
вернуть массив обещаний await
массив обещанийreduce
разрешенные значения
const reduceLoop = async _ => {
console.log('Start')
const promises = fruitsToGet.map(getNumFruit)
const numFruits = await Promise.all(promises)
const sum = numFruits.reduce((sum, fruit) => sum + fruit)
console.log(sum)
console.log('End')
}
Эта версия проста для чтения и понимания, и для подсчета общего количества фруктов требуется одна секунда.
Ключевые выводы
- Если вы хотите выполнить
await
последовательные вызовы, используйте цикл for (или любой цикл без обратного вызова). - Никогда не используйте
await
сforEach
. Вместо этого используйте цикл for (или любой цикл без обратного вызова). - Не
await
внутриfilter
а такжеreduce
. Всегдаawait
массив обещаний сmap
тогдаfilter
или жеreduce
соответственно.
Спасибо за чтение. Эта статья изначально была размещена на мой блог. Подписаться на моя рассылка если вы хотите больше статей, которые помогут вам стать лучшим разработчиком внешнего интерфейса.