Асинхронный 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')
}

Консоль показывает «Старт».  Через одну секунду записывается 27. Еще через секунду записывается 0. Еще через секунду записывается 14 и «Конец».

С этим мы можем начать рассматривать 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 приостановит выполнение до тех пор, пока ожидаемое обещание не будет разрешено. Это означает awaits в цикле for должны выполняться последовательно.

Результат такой, какой вы ожидаете.

'Start'
'Apple: 27'
'Grape: 0'
'Pear: 14'
'End'

Консоль показывает «Старт».  Через одну секунду записывается 27. Еще через секунду записывается 0. Еще через секунду записывается 14 и «Конец».

Такое поведение работает с большинством циклов (например, 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'

Консоль сразу регистрирует «Начало» и «Конец».  Спустя одну секунду он регистрирует 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'

Консоль регистрирует «Пуск», «[Promise, Promise, Promise]', и 'Конец' немедленно

С 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'

Консоль регистрирует «Пуск».  Через секунду он записывает '[27, 0, 14] и конец'

Вы можете манипулировать значением, которое вы возвращаете в своих обещаниях, если хотите. Разрешенные значения будут значениями, которые вы возвращаете.

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'

Консоль регистрирует «Пуск», «['apple', 'grape', 'pear']', и 'Конец' немедленно

Вот почему это происходит.

Когда вы используете await в filter обратный вызов, обратный вызов всегда обещание. Поскольку промисы всегда правдивы, все элементы массива проходят фильтр. Пишу await в filter похоже на написание этого кода:

// Everything passes the filter...
const filtered = array.filter(true)

Есть три шага для использования await а также filter правильно:

  1. Использовать map вернуть массив обещаний
  2. await массив обещаний
  3. 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

Консоль показывает «Старт».  Спустя секунду консоль записывает '['apple']' и конец'

Подождите с уменьшением

В этом случае предположим, что вы хотите узнать общее количество фруктов в 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'

Консоль сразу регистрирует «Начало», «41» и «Конец»

Когда вы используете 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 'и 'Конец'

Какая?! [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'

Консоль регистрирует «Пуск».  Три секунды спустя он записывает «41» и «Конец».

Но… как видно из гифки, на это уходит довольно много времени. 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')
}

Консоль регистрирует «Пуск».  Через секунду он записывает «41» и «Конец».

Это работает, потому что reduce может стрелять всеми тремя getNumFruit обещания перед ожиданием следующей итерации цикла. Однако этот метод немного сбивает с толку, так как вы должны быть осторожны с порядком, в котором вы await вещи.

Самый простой (и самый эффективный) способ использования await в сокращении:

  1. Использовать map вернуть массив обещаний
  2. await массив обещаний
  3. 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')
}

Эта версия проста для чтения и понимания, и для подсчета общего количества фруктов требуется одна секунда.

Консоль регистрирует «Пуск».  Через секунду он записывает «41» и «Конец».

Ключевые выводы

  1. Если вы хотите выполнить await последовательные вызовы, используйте цикл for (или любой цикл без обратного вызова).
  2. Никогда не используйте await с forEach. Вместо этого используйте цикл for (или любой цикл без обратного вызова).
  3. Не await внутри filter а также reduce. Всегда await массив обещаний с mapтогда filter или же reduce соответственно.

Спасибо за чтение. Эта статья изначально была размещена на мой блог. Подписаться на моя рассылка если вы хотите больше статей, которые помогут вам стать лучшим разработчиком внешнего интерфейса.

Похожие записи

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *