Ванильный код
June 2, 2022

Подводные камни Async/Await в циклах

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


forEach

Если в итоге вы запомните из этой статьи только одну вещь, то пусть это будет тот факт, что async / await не работает в Array.prototype.forEach

Давайте рассмотрим пример, чтобы понять, почему:

const urls = [
  'https://testurl.travellerlogs.com/todos/1',
  'https://testurl.travellerlogs.com/todos/2',
  'https://testurl.travellerlogs.com/todos/3',
];

async function getTodos() {
  await urls.forEach(async (url, idx) => { 
    const todo = await fetch(url);
    console.log(`Получили Todo ${idx+1}:`, todo);
  });
  
  console.log('Закончили!');
}

getTodos();

Если выполнить этот код, то мы получим следующее:

Закончили!
Получили Todo 2, Response: { ··· }
Получили Todo 1, Response: { ··· }
Получили Todo 3, Response: { ··· }

Проблема #1

Приведенный выше код будет успешно выполнен. Однако обратите внимание, что Закончили! вывелось в консоль в первую очередь, несмотря на то, что мы использовали await перед urls.forEach. Первая проблема заключается в том, что вы не можете ожидать выполнения всего цикла при использовании forEach.

Проблема #2

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

По обеим этим причинам на forEach не следует полагаться, если вы используете async/await.


Promise.all

Давайте решим проблему ожидания завершения всего цикла. Поскольку операция ожидания создает promise под капотом, мы можем использовать Promise.all для ожидания завершения всех запросов, которые были запущены во время цикла:

const urls = [
  'https://testurl.travellerlogs.com/todos/1',
  'https://testurl.travellerlogs.com/todos/2',
  'https://testurl.travellerlogs.com/todos/3',
];

async function getTodos() {
  const promises = urls.map(async (url, idx) => 
    console.log(`Получили Todo ${idx+1}:`, await fetch(url))
  );

  await Promise.all(promises);
  
  console.log('Закончили!');
}

getTodos();

Если выполнить этот код, то мы получим следующее:

Получили Todo 1, Response: { ··· }
Получили Todo 2, Response: { ··· }
Получили Todo 3, Response: { ··· }
Закончили!

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

Как я говорила ранее, Promise.all выполняет все данные ему promises параллельно. Но он не будет ждать завершения первого запроса, прежде чем выполнить второй или третий запрос. Для большинства целей это нормально и эффективно. Но, если вам действительно нужно, чтобы каждый запрос выполнялся по порядку, Promise.all не решит проблему.


for...of

Теперь мы знаем, что forEach вообще не уважает async / await, а Promise.all работает только в том случае, если порядок выполнения не имеет значения. Давайте рассмотрим решение, которое подходит для обоих случаев.

Цикл for...of выполняется в том порядке, в котором можно было бы ожидать — ожидает завершения каждой предыдущей операции, прежде чем переходить к следующей:

const urls = [
  'https://testurl.travellerlogs.com/todos/1',
  'https://testurl.travellerlogs.com/todos/2',
  'https://testurl.travellerlogs.com/todos/3',
];

async function getTodos() {  
  for (const [idx, url] of urls.entries()) {
    const todo = await fetch(url);
    console.log(`Получили Todo ${idx+1}:`, todo);
  }
  
  console.log('Закончили!');
}

getTodos();

Если выполнить этот код, то мы получим следующее:

Получили Todo 1, Response: { ··· }
Получили Todo 2, Response: { ··· }
Получили Todo 3, Response: { ··· }
Закончили!

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

Если вам не нужен доступ к индексу, код становится еще более кратким:

for (const url of urls) { ··· }

Одним из основных недостатков использования цикла for...of является то, что он плохо работает по сравнению с другими вариантами цикла в Javascript. Однако, производительность, при использовании с асинхронными вызовами, не является основной задачей. Обычно я использую for...of, если асинхронный порядок выполнения имеет значение.

На этом сегодня все, не забывайте подписываться на мой телеграм канал!