✨ 告别循环陷阱:精通 JavaScript 中的异步迭代
前言
在 JavaScript 的世界里,我们每天都在和两件事打交道:循环和异步。无论是遍历从后端获取的用户列表,还是批量调用 API 进行数据处理,将这两者结合起来——在循环中执行异步逻辑——几乎是每个开发者的家常便饭。
但你是否也曾掉进过 forEach + async/await 的“陷阱”?明明想让异步任务按顺序“排队”执行,结果却“乱作一团”,得到了意想不到的输出。
别担心,你不是一个人!本文将带你彻底搞懂如何在循环中优雅地处理异步操作,无论是按顺序执行(串行)还是并发执行(并行) ,都能轻松拿捏!
场景一:当我们需要“排队”执行(串行)
想象一个场景:我们有一个文件名列表,需要按顺序读取文件内容并打印出来。顺序很重要,必须处理完第一个再处理第二个。
❌ 错误的示范:forEach 的“谎言”
很多同学下意识会写出这样的代码:
JavaScript
// 一个模拟 API 调用的函数
function someAPICall(param) {
return new Promise((resolve, reject)=>{
setTimeout(()=>{
resolve("Resolved -> " + param)
}, param);
})
}
async function processArray(items) {
items.forEach(async (i) => {
const res = await someAPICall(i);
console.log(res);
});
console.log('forEach loop has finished executing.');
}
processArray(['3000', '1000', '4000', '2000']);
我们期望的输出是按照数组顺序,依次等待 3s, 1s, 4s, 2s 后打印结果:
Bash
# 期望的输出
Resolved -> 3000
Resolved -> 1000
Resolved -> 4000
Resolved -> 2000
forEach loop has finished executing.
但实际的输出却是这样的:
Bash
# 实际的输出
forEach loop has finished executing.
Resolved -> 1000
Resolved -> 2000
Resolved -> 3000
Resolved -> 4000
问题出在哪里?
forEach 方法并不会“等待”其回调函数中的 await 执行完成。它会立即遍历整个数组,触发所有的异步操作,然后就直接结束了。它不会暂停,因此无法保证执行顺序,只会根据每个 Promise 的解析时间来决定输出顺序。
核心原因:
forEach不是为异步回调设计的。它的回调函数的返回值会被忽略,自然也就无法await整个循环的完成。
✅ 正确的姿势 1:经典的 for...of 循环
要实现串行,最直观、最易读的方法就是使用 for...of 循环。
JavaScript
async function printFiles() {
const fileNames = ['picard.txt', 'kirk.txt', 'geordy.txt'];
for (const file of fileNames) {
// await 会真正暂停循环,直到 Promise resolve
const contents = await fs.readFile(file, 'utf8');
console.log(contents);
}
console.log('All files have been processed in order. ✨');
}
for...of 循环与 async/await 是天作之合。await 会暂停整个循环的执行,直到当前的异步操作完成,然后再进入下一次迭代。这完美地满足了我们按顺序执行的需求。
✅ 正确的姿势 2:函数式编程的优雅 reduce
如果你是函数式编程的爱好者,Array.prototype.reduce 也能巧妙地实现串行 Promise 调用。
JavaScript
function testPromise(time) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Processing ${time}`);
resolve(time);
}, time);
});
}
const times = [3000, 2000, 1000, 4000];
times.reduce((accumulatorPromise, nextTime) => {
return accumulatorPromise.then(() => {
// .then() 链确保了前一个 Promise 完成后,再执行下一个
return testPromise(nextTime);
});
}, Promise.resolve()) // 初始值是一个已 resolve 的 Promise
.then(() => {
console.log("All Promises Resolved in sequence! ✨");
});
这种方式通过 then 链将 Promise 串联起来,一个接一个地执行,非常精妙。
✅ 正确的姿势 3:现代的异步生成器 (async function*)
对于更复杂的流式数据处理,异步生成器提供了一种更现代、更强大的选择。
JavaScript
// 假设 readFile 返回一个 Promise
async function* readFiles(files) {
for (const file of files) {
// yield await 会暂停并等待 Promise 完成,然后产出结果
yield await readFile(file);
}
}
// 使用
(async () => {
for await (const content of readFiles(['a.txt', 'b.txt'])) {
console.log(content);
}
})();
Node 10+ 和现代浏览器都已支持此特性,它在处理需要按需、顺序执行的异步任务时非常有用。
场景二:让任务“并肩作战”(并行)
现在,换个需求。我们不在乎文件内容的打印顺序,只希望用最快的时间完成所有文件的读取。这时,就应该让所有异步任务“并肩作战”,也就是并行执行。
✅ 正确的姿势:Promise.all + map
Promise.all 是为并行执行而生的利器。我们可以结合 map 方法,将数组中的每一项都映射成一个 Promise,然后交给 Promise.all 来统一处理。
JavaScript
async function printFiles() {
const fileNames = ['picard.txt', 'kirk.txt', 'geordy.txt', 'ryker.txt'];
// 1. map() 会立即遍历数组,为每个文件启动一个读取操作,并返回一个 Promise 数组
const promises = fileNames.map(async (file) => {
const contents = await fs.readFile(file, 'utf8');
console.log(contents);
return contents; // 可以选择返回结果
});
// 2. Promise.all() 等待数组中所有的 Promise 都完成
await Promise.all(promises);
console.log('All files have been processed in parallel! 🚀');
}
工作流程解析:
fileNames.map(...)会同步执行,瞬间创建出一个 Promise 数组[promise1, promise2, promise3, ...],并且每个 Promise 对应的异步操作(如fs.readFile)已经开始执行。await Promise.all(promises)会暂停printFiles函数,直到这个数组中所有的 Promise 都变为 resolved 状态。- 一旦所有任务都完成了,代码才会继续向下执行。
这种方式极大地提高了效率,因为所有 I/O 操作是同时进行的,总耗时取决于耗时最长的那一个任务,而不是所有任务耗时的总和。
总结
最后,我们来个速记总结,帮你下次不再选错:
| 你的需求是什么? | 最佳方案 | 备选方案 | 千万别用 |
|---|---|---|---|
| 按顺序执行(串行) | for...of | reduce, 异步生成器 | forEach |
| 并发执行(并行) | Promise.all + map | - | - |
掌握了这两个核心场景的解决方案,你在 JavaScript 中处理异步循环将会得心应手,再也不会被 forEach 的“谎言”所迷惑了!