今天研究了一下JS中的异步迭代。
0x00 TL;DR
Array.prototype.forEach()
可以传入异步回调函数,但是不能await
整个循环。Promise.all()
和Array.prototype.map()
结合使用可以await
整个循环,Promise.resolve()
是并行的。await Array.prototype.reduce()
和for...of
结合使用可以await
整个循环,Promise.resolve()
是顺序执行的。
0x01 为什么不行?
考虑下面这段程序。
const numbers = [1, 2, 3, 4, 5];
numbers.forEach(async value => {
await someRandomWork();
console.log(value);
})
console.log('finished!');
看起来,forEach()
在遍历到数组中每个值时都会await
,直到遍历完整个数组。但事实并非如此。实际的输出是这样:
finished!
1
2
3
4
5
很明显,console.log()
跑到了最前面。不过仔细观察,可以发现await
和console.log()
并不在同一层。要知道,await
是“shallow”的,里面的await
只会suspend它所在的函数,不会对外层产生效果。JavaScript for impatient programmers中提到:
If we are inside an async function and want to pause it via await, we must do so directly within that function; we can’t use it inside a nested function, such as a callback. That is, pausing is shallow.
如果想在async
函数内部“暂停”它,只能在函数的最顶层使用await
,否则就没有效果。
看到这里,解决方案也就呼之欲出了。只要让Promise
与后续语句成为同一层,让它们之间产生关联,就能await整个loop过程。
0x02 方案一:Promise.all()
Promise.all()
可以将多个Promise
合并成一个,这正好是我们想要的。因为需要一个Promise[]
,此时forEach()
就不太实用了,map()
明显是一个更好的选择。将代码改写成以下形式:
async function mapAsync() {
await Promise.all(numbers.map(async i => {
await someRandomWork();
console.log(await getCurrentTime(), i);
}))
console.log('mapAsync finished!')
}
运行,得到以下结果:
00:00:03.941 1
00:00:03.941 2
00:00:03.941 3
00:00:03.941 4
00:00:03.941 5
mapAsync finished!
为了方便观察,我在输出中增加了一个timer;同时,在someRandomWork()
中进行一次for (let i = 0; i < 2e9; i++)
来模拟一段时间的阻塞。此时,console.log()
和Promise.all()
的await
来到了同一层,整个迭代的所有Promise
都被resolve之后,Promise.all()
才被resolve,从时间码也能看出过程是并行的。mapAsync finished!
的输出也在其他的输出之后,符合预期。
0x03 方案二:reduce()
既然可以通过map()
和Promise.all()
让所有Promise
并行resolve,有没有办法让它们被顺序resolve呢?当然有。说到顺序,很自然就想到了reduce()
。把代码改写成以下形式:
async function reduceAsync() {
await numbers.reduce(async (promise, i) => {
await promise;
await someRandomWork();
console.log(await getCurrentTime(), i);
}, Promise.resolve());
console.log('reduceAsync finished!')
}
reduce()
的第二个参数Promise.resolve()
帮助我们启动这个美妙的过程。回到异步回调函数,await promise;
确保上一个Promise
fulfilled之后才接着进行后续的过程,直到遍历数组中的每一个元素。而传入async函数的reduce()
本身最后返回一个Promise
,把这个链式反应带到最外层。最后,运行的结果如下:
00:00:01.020 1
00:00:01.962 2
00:00:02.590 3
00:00:03.217 4
00:00:03.844 5
reduceAsync finished!
通过时间码可以看出,使用reduce()
的情况下,Promise
是被顺序resolve的。
0x04 方案三:for...of
上面两个方案都是用了高阶函数。实际上,for...of
语句也可以当作“支持异步的.forEach()
”来使用。使用以下代码:
async function forOfAsync() {
for (const i of numbers) {
await someRandomWork();
console.log(await getCurrentTime(), i);
}
console.log('forOfAsync finished!')
}
运行后可以得到以下结果:
00:00:00.992 1
00:00:01.932 2
00:00:02.560 3
00:00:03.187 4
00:00:03.814 5
forOfAsync finished!
可以看出结果和reduce()
是类似的。如果用Babel编译上述JavaScript代码,会发现for...of
被转换成了for
语句,可见普通的循环在此处也是可行的。这也很合理,毕竟这里的await
并没有被函数包裹。
for (var _i = 0, _numbers = numbers; _i < _numbers.length; _i++) {
const i = _numbers[_i];
await someRandomWork();
}
console.log("finished!");
0x05 for...of
和for await...of
如果用for await...of
替换上面代码中的for...of
,会发现运行结果没有什么变化。原因是,虽然带了“await”,for await...of
并不是用在这个场景中的。MDN这样介绍:
When afor await...of
loop iterates over an iterable, it first gets the iterable's[@@asyncIterator]()
method and calls it, which returns an async iterator. If the @asyncIterator method does not exist, it then looks for an[@@iterator]()
method, which returns a sync iterator.
for await...of
可以迭代async iterable,这是它和for...of
在功能上最直接的区别。而且,当迭代sync iterable,并且迭代变量是async variable时,for await...of
会返回resolved values,而for...of
只会返回一系列Promise。下面这个例子展示了它们在面对sync iterable时的区别(完整代码):
const numberPromises = [
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3),
Promise.resolve(4),
Promise.resolve(5)
]
async function forOfAsync() {
for (const i of numberPromises) {
await someRandomWork();
console.log(await getCurrentTime(), i);
}
console.log('forOfAsync finished!')
}
async function forAwaitOfAsync() {
for await (const i of numberPromises) {
await someRandomWork();
console.log(await getCurrentTime(), i);
}
console.log('forAwaitOfAsync finished!')
}
async function main() {
console.log(`\nfor...of test`);
await forOfAsync();
console.log(`\nfor await...of test`);
await forAwaitOfAsync();
}
main();
// expected output:
//
// for...of test
// 00:00:00.988 Promise { 1 }
// 00:00:01.933 Promise { 2 }
// 00:00:02.562 Promise { 3 }
// 00:00:03.189 Promise { 4 }
// 00:00:03.816 Promise { 5 }
// forOfAsync finished!
// for await...of test
// 00:00:00.627 1
// 00:00:01.254 2
// 00:00:01.882 3
// 00:00:02.509 4
// 00:00:03.136 5
// forAwaitOfAsync finished!
此外,MDN上的这个例子展示了for await...of
迭代async iterable的行为。
0x06 如果你在意性能的话
你可能会注意到Promise.all()
比顺序执行的方案稍微慢一点。如果你在意性能的话,可以试试这个map和for...of的例子。在笔者的环境(M1,Node.js v16.17.0-arm64)下,这个例子展现出了相当有趣的差异。
参考链接: