Почему ваш async/await молчит в forEach? Разбираемся с первого взгляда
Вы когда-нибудь писали что-то вроде этого кода, надеясь, что он выполнит асинхронные операции по порядку?
const users = ['alice', 'bob', 'charlie'];
users.forEach(async (user) => {
const data = await fetch(`https://api.example.com/users/${user}`);
console.log(await data.json());
});
console.log('Готово!');
И получали "Готово!" раньше, чем загружались данные? Знакомо? Вы не одиноки. Это один из самых популярных вопросов на Stack Overflow с тысячами просмотров. Проблема в том, что forEach — это синхронный метод. Он просто запускает коллбэки, но не ждёт их завершения. Ваши async-функции стартуют, но forEach уже мчится дальше, как спринтер, которому плевать на финиш остальных.
В этой статье мы не просто покажем, как это исправить. Мы разберём почему так происходит, и дадим вам три рабочих решения с примерами. Вы узнаете, когда использовать Promise.all, а когда — старый добрый for...of. Поехали!
Анатомия проблемы: forEach и обещания
Давайте заглянем под капот. Метод forEach в JavaScript устроен так:
Array.prototype.forEach = function(callback) {
for (let i = 0; i < this.length; i++) {
callback(this[i], i, this);
}
};
Видите? Он просто вызывает callback для каждого элемента, но ничего не возвращает. Даже если коллбэк возвращает Promise, forEach его игнорирует. Он не ждёт, не собирает результаты — он просто запускает и забывает.
Когда вы пишете async (user) => { ... }, ваша функция возвращает Promise. Но forEach не знает, что с ним делать. В итоге все запросы запускаются параллельно (если это fetch), но вы не можете дождаться их завершения. Код после forEach выполняется мгновенно.
Реальный пример: как это выглядит на практике
Представьте, что вы обрабатываете массив ID товаров и для каждого нужно получить цену из базы данных:
const productIds = [101, 102, 103];
async function getPrice(id) {
// Имитация запроса к БД
return new Promise(resolve => {
setTimeout(() => {
resolve(`Цена для ${id}: $${Math.floor(Math.random() * 100)}`);
}, 1000);
});
}
productIds.forEach(async (id) => {
const price = await getPrice(id);
console.log(price);
});
console.log('Все цены загружены!'); // Выполнится сразу, а не через 3 секунды
Результат в консоли:
Все цены загружены!
Цена для 101: $42
Цена для 102: $78
Цена для 103: $15
Согласитесь, это не то, что мы ожидали. Хорошая новость: есть простые и элегантные решения.
Решение №1: for...of — последовательное выполнение
Самый простой способ заставить async/await работать в цикле — использовать for...of. В отличие от forEach, for...of работает с async/await нативно, потому что вы управляете потоком выполнения внутри блока.
const productIds = [101, 102, 103];
async function getPrice(id) {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Цена для ${id}: $${Math.floor(Math.random() * 100)}`);
}, 1000);
});
}
async function loadAllPrices() {
for (const id of productIds) {
const price = await getPrice(id);
console.log(price);
}
console.log('Все цены загружены!');
}
loadAllPrices();
Теперь результат будет таким:
Цена для 101: $42
(через 1 секунду)
Цена для 102: $78
(через 1 секунду)
Цена для 103: $15
(через 1 секунду)
Все цены загружены!
Плюсы: простота, читаемость, предсказуемый порядок.
Минусы: медленно — каждый запрос ждёт завершения предыдущего. Если у вас 1000 элементов, это займет 1000 секунд.
Решение №2: Promise.all — параллельное выполнение
Если порядок не важен, а важна скорость — используйте Promise.all в связке с map. Это запустит все запросы одновременно и дождётся их всех.
const productIds = [101, 102, 103];
async function getPrice(id) {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Цена для ${id}: $${Math.floor(Math.random() * 100)}`);
}, 1000);
});
}
async function loadAllPrices() {
const promises = productIds.map(id => getPrice(id)); // Создаём массив промисов
const prices = await Promise.all(promises); // Ждём все сразу
prices.forEach(price => console.log(price));
console.log('Все цены загружены!');
}
loadAllPrices();
Результат (все три цены появятся одновременно через ~1 секунду):
Цена для 101: $42
Цена для 102: $78
Цена для 103: $15
Все цены загружены!
Плюсы: максимальная скорость, идеально для независимых запросов.
Минусы: если один промис упадёт с ошибкой — упадёт весь Promise.all. Для обработки ошибок используйте Promise.allSettled.
Бонус: Promise.allSettled для отказоустойчивости
Если вы хотите, чтобы один неудачный запрос не убивал весь процесс:
const results = await Promise.allSettled(
productIds.map(id => getPrice(id))
);
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log(result.value);
} else {
console.error('Ошибка:', result.reason);
}
});
Решение №3: Reduce с аккумулятором промисов
Это более продвинутый метод, который даёт вам контроль над последовательностью, но при этом позволяет обрабатывать результаты по мере выполнения. Подходит, когда нужно выполнять запросы строго по порядку, но с гибкостью.
const productIds = [101, 102, 103];
async function getPrice(id) {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Цена для ${id}: $${Math.floor(Math.random() * 100)}`);
}, 1000);
});
}
productIds.reduce(async (previousPromise, id) => {
await previousPromise; // Ждём завершения предыдущего
const price = await getPrice(id);
console.log(price);
}, Promise.resolve());
Этот код работает как цепочка: каждый следующий запрос начинается только после завершения предыдущего. Promise.resolve() — это стартовый промис, который сразу выполняется.
Плюсы: последовательное выполнение + возможность добавить логику между шагами.
Минусы: менее читаемо