JavaScript 循环中调用异步函数的三种方法,及为什么 forEach 无法工作的分析

Posted GoldenaArcher

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaScript 循环中调用异步函数的三种方法,及为什么 forEach 无法工作的分析相关的知识,希望对你有一定的参考价值。

本文主要分析在循环体中怎么调用异步函数,并且满足循环调用异步函数,并在异步函数值返回之后,再处理后续业务的同步需求。

这篇文章是受到和 六卿 在群里讨论问题时启发而写的,主要讨论的问题就只在循环体内进行异步调用。他也写了自己的总结: node 中循环异步的问题[‘解决方案‘]_源于 map 循环和 for 循环对异步事件配合 async、await 的支持

业务分析

根据我的理解,当时讨论的问题是基于这样一个需求:

  1. 首先需要调用一个 API 去获得数据

    获取的数据是一个数组类型,这里就代称为 arr

  2. 会对 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();

当然,上面只是一个最基本的逻辑实现,并没有实现异步操作,现在直接运行的话就会报错。不过基本的逻辑是在这里的:

  1. API 那里获取到值 data
  2. 遍历 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 函数时,它等待了大约几秒钟的时间,随后一下子将所有的结果一起输出。

直接用文字描述可能没有这么直观,那么就打几个时间戳。一个在刚刚进入函数的时候打印出当前时间,一个在循环体内输出值的时候打印出当前时间,更加直观的对比一下:

forEachfor

也就是说,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 结合 awaitmap 去实现:

  • Promise.all 可以接收由 Promise 组成的数组,并且返回一个 Promise。

  • map 的特性与 forEach 相似,区别在于前者会返回一个数组,后者会返回一个 undefined。

    Promise.all 的参数正好又是一个由 Promise 组成的数组;并且,Promise.all 的返回值就是一个 Promise

  • await 是 ES7 推出的语法糖,可以用来等待一个 Promise 的执行完成。

所以结合 Promise.allawaitmap 就可以近似同步地发送多个异步请求。之所以说是 近似,还是因为毕竟是一个迭代,总归需要按序数组中第一个元素开始执行,只不过大多数情况下,数组的迭代与异步操作比起来消耗时间可以小到近乎不计。

实现如下:

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。

其执行流程大概如下:

input
Promise All
iteration 1
iteration 2
iteration 3
...
iteration N
output

串行解决方案

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 去等待上一个异步调用结果返回之后,再去执行下一个异步调用,因此消耗的时间也更多。

其执行流程大概如下:

以上是关于JavaScript 循环中调用异步函数的三种方法,及为什么 forEach 无法工作的分析的主要内容,如果未能解决你的问题,请参考以下文章

异步回调函数-创建进程的三种方式

0147 JavaScript创建对象的三种方式 之 构造函数

JavaScript创建函数的三种方式

js的三种异步方法

面试中被问Spring循环依赖的三种方式!!!

在 JavaScript 的 for 循环中调用异步函数