JavaScript 循环中调用异步函数的三种方法,及为什么 forEach 无法工作的分析
本文主要分析在循环体中怎么调用异步函数,并且满足循环调用异步函数,并在异步函数值返回之后,再处理后续业务的同步需求。
这篇文章是受到和 六卿 在群里讨论问题时启发而写的,主要讨论的问题就只在循环体内进行异步调用。他也写了自己的总结: node 中循环异步的问题[‘解决方案‘]_源于 map 循环和 for 循环对异步事件配合 async、await 的支持。
业务分析
根据我的理解,当时讨论的问题是基于这样一个需求:
首先需要调用一个 API 去获得数据
获取的数据是一个数组类型,这里就代称为
arr
会对
arr
进行遍历,在遍历的过程中继续调用其他的 API 去获得数据,并且对数据进行一些操作
整体的业务逻辑和需求,模拟大概是这个样子的:
const map = new Map([
[1, 'one'],
[2, 'two'],
[3, 'three'],
[4, 'four'],
[5, 'five'],
]);
// 用 setTimeout 模拟异步的 api 调用
// timeout 会获得一个数组类型的数据,随后会有另外的 api 根据数组内的数据,再一次去进行异步调用,获取其他数据
const timeout = () =>
new Promise((resolve) => setTimeout(() => resolve([1, 2, 3, 4, 5])), 1000);
// 循环体内调用的数据
const getEl = (key) =>
new Promise((resolve) => setTimeout(() => resolve(map.get(key)), 1000));
const getData = () => {
const data = timeout();
let str = [];
// 这里没有处理异步操作,所以会有语法错误
data.forEach((el) => {
const elVal = getEl(el);
str.push(elVal);
});
// 最后输出结果应该是 ['one', 'two', ...] 这样一个包含 异步调用后返回值 的数组
console.log(str);
};
getData();
当然,上面只是一个最基本的逻辑实现,并没有实现异步操作,现在直接运行的话就会报错。不过基本的逻辑是在这里的:
- API 那里获取到值
data
- 遍历
data
,在遍历中继续调用 API 取值并进行操作。
初版的问题
最初的方案其实就是比较平铺直叙,用 async/await
配合的方式去获取数据:
// 其他函数没有改动,只修改了 getData 这一部分
const getData = async () => {
// 使用 await 语法糖
const data = await timeout();
let str = [];
// 加上 async 和 await 去等待异步调用
data.forEach(async (el) => {
const elVal = await getEl(el);
str.push(elVal);
// 可以正常输出
console.log(elVal);
});
// 返回值却是一个空数组
console.log(str);
};
getData();
输出结果却不尽如人意,在命令行中输出的顺序是这样的:
[]
one
two
three
four
five
可以看到,异步的数据获取是在输出数组之后发生的,这也代表 forEach 内的异步调用的顺序,不如预期所想。
解决方案
改为 for
循环体 是 六卿 在自己的总结内提出的解决方案;这里再提出了两个不使用 for
循环体 的解决方案。
传统的 for 循环
一个解决方案就是将 forEach
/map
替换成传统的 for (let i = 0; i < arr.lengt; i++)
这样的传统写法,如:
const getData2 = async () => {
const data = await timeout();
let str = [];
for (let i = 0; i < data.length; i++) {
const element = await getEl(data[i]);
console.log(element);
str.push(element);
}
console.log(str);
};
getData2();
最终的输出结果为:
one
two
three
four
five
[ 'one', 'two', 'three', 'four', 'five' ]
数组的输出结果在 API 调用结果之后,也就意味着数据可以正常地被渲染或是处理。
不使用 for 循环的解决方案
所以异步的代码只能使用传统的 for 循环吗?
也不尽然,只是解决方法无法基于 forEach
去实现而已。
分析 forEach 为什么不工作
在输出的时候我发现了一些微妙的异常,例如说使用 for 循环时,每一行的输出都是有一定间隔事件的——毕竟 await 应该会“锁”住运行,一直到数据接收之后才会进行下一步的调用。但是使用 forEach 函数时,它等待了大约几秒钟的时间,随后一下子将所有的结果一起输出。
直接用文字描述可能没有这么直观,那么就打几个时间戳。一个在刚刚进入函数的时候打印出当前时间,一个在循环体内输出值的时候打印出当前时间,更加直观的对比一下:
forEach | for |
---|---|
也就是说,forEach 的循环调用并没有 await 里面的异步操作。所以,当 forEach 中的同步代码执行完毕之后,异步代码才开始执行,这也是为什么 forEach 的代码先输出了一个空的数组之后,才在控制台上打印异步调用中获取的值。
异步调用的复习资料在这里:[万字详解]JavaScript 中的异步模式及 Promise 使用
那么,函数最上方已经声明了 async 关键字,forEach 中也使用了 await 去等数。而且,明明 await timeout()
工作了,为什么就只有 forEach 没有工作?
那是因为,forEach 整个函数没有使用 await 进行等待,整个 forEach 是同步执行的。forEach 的实现是基于内部的回调函数执行,因此,当进入循环之后,函数内部会去调用传进来的回调函数。当回调函数是异步时,回调函数就会被放入时间循环机制中,forEach 内部会继续去执行同步代码,也就是继续循环。
很可惜的是,基于历史原因——forEach 函数是 ES5 时代的函数,Promise 等异步操作的支持是 ES6 以后才有的支持——直接使用 forEach 是没有办法实现在循环体内调用异步函数的方法。
但是,都 2021 年了,这也不代表没有解决方案。
并行解决方案
如果数据彼此之间没有依赖关系,其实个人更建议使用这种方式,相对而言效率会更高一些。
实现的方式是 Promise.all
结合 await
和 map
去实现:
-
Promise.all
可以接收由 Promise 组成的数组,并且返回一个 Promise。 -
map
的特性与 forEach 相似,区别在于前者会返回一个数组,后者会返回一个 undefined。Promise.all
的参数正好又是一个由 Promise 组成的数组;并且,Promise.all
的返回值就是一个 Promise -
await
是 ES7 推出的语法糖,可以用来等待一个 Promise 的执行完成。
所以结合 Promise.all
,await
和 map
就可以近似同步地发送多个异步请求。之所以说是 近似,还是因为毕竟是一个迭代,总归需要按序数组中第一个元素开始执行,只不过大多数情况下,数组的迭代与异步操作比起来消耗时间可以小到近乎不计。
实现如下:
const getData = async () => {
const data = await timeout();
const curr = new Date();
console.log(curr);
let str = [];
// 使用 Promise.all 去等待内部所有的 Promise 执行完毕
await Promise.all(data.map((el) => getEl(el))).then((val) => {
str = val;
console.log(new Date() - curr);
return val;
});
console.log(str);
};
getData();
效果截图:
可以看出,与最初使用传统的 for 循环相比,使用 Promise.all
能够有效的提升性能。当有多个较为耗时的异步任务,并且彼此之间没有依赖关系的时候,为了能够提升用户体验,最好还是使用 Promise.all
去调用。
这是因为 await
等待的是所有的 Promise 执行完毕的结果,即锁住的是 Promise.all
,而内部的 map
依旧是同步执行的。所以对于循环体内的异步函数来说,它不需要等待上一个迭代完成,再去执行下一个迭代——await
这个语法糖会等待 Promise 执行完毕再去执行下一个 Promise。
其执行流程大概如下:
串行解决方案
for await...of
是基于对 iterable(可迭代) 的实现,这种实现比较适合用于有依赖关系的内容。如较大文件的加载,可以通过在阅读到某一个点的时候触发下一段文件的加载,以达到提升用户体验感的效果。
使用案例如下:
const getData3 = async () => {
const data = await timeout();
const curr = new Date();
console.log(curr);
let str = [];
for await (el of data) {
const element = await getEl(el);
console.log(element, new Date() - curr);
str.push(element);
}
console.log(str);
};
getData3();
效果如下:
因为使用了 await
去等待上一个异步调用结果返回之后,再去执行下一个异步调用,因此消耗的时间也更多。
其执行流程大概如下:
总结
整体来说,在循环体内调用异步函数有以下三种方法:
-
传统
for
循环最传统的解决方案
-
Promise.all
,await
和map
的结合可以近似同步地并行调用循环体内的 API,如果需要在数组之中循环调用 API,并且 API 之间彼此没有什么关联,那么使用这个方案可以极大的提升用户体验感
-
for await...of
for of
的异步支持版本,可以串行调用 API,在不使用其他关键字的情况下与传统的for
循环 效果一样但是,因为
for of
是基于迭代器实现的,这也就代表着可以通过重写迭代器去实现一些特殊的业务场景,如:-
视频渐进式下载
-
页面内容分段加载
这种效果在小说网站中用的还挺多的,为了防盗,部分小说网站在 VIP 章节中都是用图片代替文字。
-
…
-