Освоить тонкости async и await довольно просто, но сложности возникают, когда вы используете await внутри loops. В этом подробном руководстве я расскажу о типичных ошибках и соображениях, которые следует учитывать при использовании await в структурах loop.

Подготовка сцены

Наш сценарий включает в себя расчет количества автомобилей в коллекции автомобилей. Давайте установим контекст, рассмотрев объект carCollection:

const carCollection = {
  sedan: 42,
  suv: 0,
  truck: 18,
};

Чтобы получить количество каждого автомобиля, используется функция getNumCar:

const getNumCar = car => {
  return carCollection[car];
};

Теперь представим, что carCollection находится на удаленном сервере, и для его извлечения требуется одна секунда. Эта задержка воспроизводится с использованием таймаута, имитирующего интервал в одну секунду между ответами:

const sleep = ms => {
  return new Promise(resolve => setTimeout(resolve, ms));
};

const getNumCar = car => {
  return sleep(1000).then(() => carCollection[car]);
};

Наконец, наша цель заключается в асинхронном получении количества автомобилей с использованием await и getNumCar в асинхронной функции:

const control = async _ => {
  console.log('-Start-');

  const numSedan = await getNumCar('sedan');
  console.log(numSedan);

  const numSUVs = await getNumCar('suv');
  console.log(numSUVs);

  const numTruck = await getNumCar('truck');
  console.log(numTruck);

  console.log('-End-');
};

В выводе консоли будет последовательно отображаться -Start-, затем 42 через одну секунду, затем 0 еще через секунду, 18 через дополнительную секунду и, наконец, -End-.

Взгляд на ожидание в циклах

Теперь наше внимание переключится на тонкости использования await в различных структурах циклов.

Ожидание в цикле for

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

const carsToGet = ['audi', 'volvo', 'ford'];

Чтобы получить количество каждого автомобиля в массиве с помощью цикла for, мы включаем в процесс await:

const forLoop = async _ => {
  console.log('-Start-');

  for (let index = 0; index < carsToGet.length; index++) {
    const car = carsToGet[index];
    const numCar = await getNumCar(car);
    console.log(numCar);
  }

  console.log('-End-');
};

Поскольку await останавливает выполнение до тех пор, пока не будет выполнено ожидаемое обещание, итерации цикла происходят последовательно. В результате вывод отражает ожидаемый порядок:

'Start'
'Apple: 42'
'Grape: 0'
'Pear: 18'
'End'

Такое поведение справедливо для большинства циклов, таких как циклы while и for-of. Однако ситуация меняется при работе с циклами обратного вызова, такими как forEach, map, filter и reduce. Давайте углубимся в то, как await влияет на эти типы циклов.

Ожидание в цикле forEach

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

const forEachLoop = _ => {
  console.log('-Start-');

  carsToGet.forEach(async car => {
    const numCar = await getNumCar(car);
    console.log(numCar);
  });

  console.log('-End-');
};

Неожиданно оператор console.log('-End-') выполняется преждевременно, что приводит к неправильному выводу:

Поскольку forEach не поддерживает асинхронные операции, он не может дождаться завершения await, что приводит к нарушению последовательности.

Ожидание внутри цикла карты

Включение await в цикл map приводит к другой особенности: результатом будет массив промисов из-за асинхронной природы асинхронных функций.

const mapLoop = async _ => {
  console.log('-Start-');

  const numCars = await carsToGet.map(async car => {
    const numCar = await getNumCar(car);
    return numCar;
  });

  console.log(numCars);

  console.log('-End-');
};

Как и ожидалось, console.log из numCars отображает массив обещаний, подчеркивая поведение, основанное на обещаниях:

'-Start-'
'[Promise, Promise, Promise]'
'-End-'

Чтобы получить фактические значения, используйте Promise.all, чтобы дождаться разрешения всех обещаний в массиве:

const mapLoop = async _ => {
  console.log('-Start-');

  const promises = carsToGet.map(async car => {
    const numCar = await getNumCar(car);
    return numCar;
  });

  const numCars = await Promise.all(promises);
  console.log(numCars);

  console.log('-End-');
};

Теперь выходные данные точно отображают разрешенные значения:

'-Start-'
'[27, 0, 14]'
'-End-'

Регулируя возвращаемое значение в промисах, вы можете соответствующим образом манипулировать разрешенными значениями:

const mapLoop = async _ => {
  // ...
  const promises = carsToGet.map(async car => {
    const numCar = await getNumCar(car);
    // Add 100 to each car count before returning
    return numCar + 100;
  });
  // ...
};

Ожидание внутри цикла фильтра

Использование await в цикле filter приводит к другому поведению, особенно по сравнению с обычным использованием:

const filterLoop = async _ => {
  console.log('-Start-');

  const moreThan20 = await carsToGet.filter(async car => {
    const numCar = await getNumCar(car);
    return numCar > 20;
  });

  console.log(moreThan20);

  console.log('-End-');
};

Удивительно, но эта реализация возвращает нефильтрованный массив из-за вмешательства await:

'-Start-'
'Apple, Grape, Pear'
'-End-'

Это происходит потому, что обещания по своей сути правдивы. Таким образом, все элементы массива проходят фильтр. Для решения этой проблемы рекомендуется использовать map с последующей фильтрацией.

Правильное использование await и filter включает в себя трехэтапный процесс:

  1. Используйте map для создания массива обещаний.
  2. Await разрешение массива обещаний.
  3. Filter значения после разрешения.
const filterLoop = async _ => {
  console.log('-Start-')

  const promises = carsToGet.map(car => getNumCar(car))
  const numCars = await Promise.all(promises)

  const moreThan20 = carsToGet.filter((car, index) => {
    const numCar = numCars[index]
    return numCar > 20
  })

  console.log(moreThan20)
  console.log('-End-')
}
-Start
['audi']
-End-

Ожидание в цикле сокращения

Использование await в цикле reduce приводит к интригующим результатам, требующим тщательного управления обещаниями:

// Reduce if there's no await
const reduceLoop = _ => {
  console.log('-Start-')

  const sum = CarsToGet.reduce((sum, car) => {
    const numCar = carCollection[car]
    return sum + numCar
  }, 0)

  console.log(sum)
  console.log('-End-')
}

Всего вы получите 41 машину. (42 + 0 + 18 = 60).

'-Start-'
'60'
'-End-'

Объединение await с reduce приводит к весьма сложным результатам.

// Reduce if we await getNumCar
const reduceLoop = async _ => {
  console.log('-Start-')

  const sum = await carsToGet.reduce(async (sum, car) => {
    const numCar = await getNumCar(car)
    return sum + numCar
  }, 0)

  console.log(sum)
  console.log('-End-')
}
'-Start-'
'[object Promise]18'
'-End-'

Что?! [object Promise]18?!

Разбор этого явления интригует:

  • В начальной итерации sum равно 0. numCar равно 42 (разрешенное значение из getNumCar('audi')). Если сложить 0 + 42, получится 42.
  • В следующей итерации sum становится обещанием. (Почему? Потому что асинхронные функции по своей сути выдают обещания!) numCar равно 0. Поскольку обещание не может быть напрямую добавлено к объекту, JavaScript преобразует его в строку «[object Promise]». Следовательно, «[object Promise]» + 0 равно «[object Promise]0».
  • В третьей итерации sum остается обещанием. numCar равно 18. Объединение «[object Promise]» с 18 дает «[object Promise]18».

Теперь тайна раскрыта!

Это означает, что использование await в обратном вызове reduce действительно возможно, но очень важно сначала дождаться разрешения аккумулятора!

const reduceLoop = async _ => {
  console.log('-Start-')

  const sum = await carsToGet.reduce(async (promisedSum, car) => {
    const sum = await promisedSum
    const numCar = await getNumCar(car)
    return sum + numCar
  }, 0)

  console.log(sum)
  console.log('-End-')
}
'-Start-'
'41'
'-End-'

Однако, как видно из анимации, процесс ожидания всего занимает достаточно много времени. Эта задержка возникает потому, что reduceLoop должен терпеливо ждать завершения promisedSum на каждой итерации.

Существует ускоренный подход для ускорения цикла reduce. (Спасибо Тиму Оксли за это понимание.) Сначала ожидая getNumFruits(), а затем promisedSum, reduceLoop завершается всего за секунду:

const reduceLoop = async _ => {
  console.log('-Start-')

  const sum = await carsToGet.reduce(async (promisedSum, car) => {
    // Heavy-lifting comes first.
    // This triggers all three `getNumCar` promises before waiting for the next interation of the loop.
    const numCar = await getNumCar(car)
    const sum = await promisedSum
    return sum + numCar
  }, 0)

  console.log(sum)
  console.log('-End-')
}

Эта стратегия эффективна, поскольку reduce может выполнить все три обещания getNumCar перед паузой для следующей итерации цикла. Тем не менее, этот метод может вызывать некоторое недоумение из-за необходимости точного упорядочения при ожидании операций.

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

  1. Используйте map для создания массива обещаний.
  2. Ждем разрешения массива обещаний.
  3. Выполните операцию reduce над разрешенными значениями.
const reduceLoop = async _ => {
  console.log('-Start-')

  const promises = carsToGet.map(getNumCar)
  const numCars = await Promise.all(promises)
  const sum = numCars.reduce((sum, car) => sum + car)

  console.log(sum)
  console.log('-End-')
}

Эта версия отличается читабельностью и понятностью. Более того, он выполняет задачу подсчета общего количества автомобилей за секунду.

'-Start-'
'60'
'-End-'

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

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