✨ 告别循环陷阱:精通 JavaScript 中的异步迭代

158 阅读5分钟

✨ 告别循环陷阱:精通 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! 🚀');
}

工作流程解析:

  1. fileNames.map(...) 会同步执行,瞬间创建出一个 Promise 数组 [promise1, promise2, promise3, ...],并且每个 Promise 对应的异步操作(如 fs.readFile)已经开始执行。
  2. await Promise.all(promises) 会暂停 printFiles 函数,直到这个数组中所有的 Promise 都变为 resolved 状态。
  3. 一旦所有任务都完成了,代码才会继续向下执行。

这种方式极大地提高了效率,因为所有 I/O 操作是同时进行的,总耗时取决于耗时最长的那一个任务,而不是所有任务耗时的总和。

总结

最后,我们来个速记总结,帮你下次不再选错:

你的需求是什么?最佳方案备选方案千万别用
按顺序执行(串行)for...ofreduce, 异步生成器forEach
并发执行(并行)Promise.all + map--

掌握了这两个核心场景的解决方案,你在 JavaScript 中处理异步循环将会得心应手,再也不会被 forEach 的“谎言”所迷惑了!