浅析js中的异步与事件循环

我们知道,在多线程语言中,比如oc,swift中,想要进行异步编程,可以新开一个子线程,将一些耗时任务放到子线程中执行,执行结束之后再与主线程通信。js是单线程的,那么它是怎么做到异步的呢?

要回答这个问题需要对js中的事件循环(Event Loop)有所了解。js的运行环境(浏览器/Node)提供了一个事件循环机制(Event Loop)允许代码非阻塞的运行。

在说事件循环之前先讲下什么是调用栈(Call stack): 相关解释可以参考下MDN文档:developer.mozilla.org/zh-CN/docs/...

大致意思函数的调用栈就是追踪函数的一种机制,能够帮助我们理解哪些函数正在被执行,执行的函数又执行了什么其他的函数。它能帮助我们记录当前程序的所在位置。

从名字也能看的出来它采用了栈这种数据结构,符合LIFO(后进先出)原则

一开始,我们得到一个空空如也的调用栈。随后,每当有函数被调用都会自动地添加进调用栈,执行完函数体中的代码后,调用栈又会自动地移除这个函数。最后,我们又得到了一个空空如也的调用栈

ok,了解了这些基本概念,我们可以看下js的基本架构:

  • 堆(heap): 对象在堆中分配内存
  • 栈(stack): 函数调用形成了由若干帧组成的栈
  • WebAPI:浏览器提供。他们不是js语言的一部分,为我们的js代码提供额外的能力。比如:定位、DOM、网络IO、定时器等

举个例子:

js 复制代码
function main() {
  console.log("A");
  setTimeout(() => {
    console.log("B");
  }, 0);
  console.log("C");
}
main();

其运行顺序如下:

  1. 调用main函数,将其压入调用栈(Call Stack),并执行main函数的第一行代码,打印A。log执行完毕就出栈
  2. 执行setTimeout,将其压入调用栈。其回调函数为exec(),setTimeout使用浏览器API实现延迟回调,一旦将计时器的控制权交给浏览器,该栈帧就会出栈
  3. 将log C压入栈。同时可以看到浏览器API正在执行计时器
  4. log C执行完毕之后出栈,这时候浏览器执行完定时器,将回调函数添加到消息队列,所以调用栈此时是空的。
  5. 回调函数被压入调用栈并执行,C被打印。

这就是js的事件循环,简单来说,事件循环是一种运行机制,保证了js作为一个单线程语言,能够进行异步操作。

这里我们就能回答,为什么js是单线程,却能进行异步操作?答案就是:js语言本身是单线程,异步行为并不是js语言本身的一部分,而是建立在js的运行环境(浏览器或者node)中,访问浏览器/node api实现的。浏览器执行完耗时任务之后,然后将对应的回调函数放到一个队列中依次执行。

从上面的示例中,我们可以看到回调函数会被放到一个队列当中,然后依次放入到调用栈中执行。但事件循环并不是只维护一个队列,实际上是有两个任务队列:

  • 宏任务队列(macrotask queue): setTimeout,setInterval,I/O操作(如文件读写、网络请求等,DOM监听,UI Rendering,requestAnimationFrame等

  • 微任务队列(microtask queue) : Promise的回调(.then .catch .finally),queueMicrotask(), process.nextTick(Node.js 环境),MutationObserver 等。

当调用栈为空时会执行任务队列里的事件(如果有的话),而微任务总是优先于宏任务执行的。 也就是说当有微任务的时候,宏任务队列中的任务都需要等待微任务执行完毕。而且在每一次循环开始时,都会检查微任务是否存在。即使在执行宏任务中添加了微任务,在调用栈为空时也会先执行微任务,而不是执行宏任务队列中的下一个(如果有的话)。

举个例子,也是经常出现的一个面试题:

js 复制代码
setTimeout(function set1 () {
  console.log("setTimeout 1");
  new Promise(function (resolve) {
    resolve();
  }).then(function set1Then() {
    new Promise(function set1Promise (resolve) {
      resolve();
    }).then(function () {
      console.log("then 4");
    });
    console.log("then 2");
  });
});

new Promise(function (resolve) {
  console.log("Promise 1");
  resolve();
}).then(function () {
  console.log("then1");
});

setTimeout(function set2 () {
  console.log("setTimeout 2");
});

console.log(2);

queueMicrotask(() => {
  console.log("queueMicrotask");
});

new Promise(function (resolve) {
  resolve();
}).then(function () {
  console.log("then 3");
});

我们来分析一下上面这段代码的打印顺序,首先调用栈为空,

  1. 我们遇到setTimeout函数,调用WebAPI计时,其回调函数会在指定时间放到宏任务队列,现在是[set1]

  2. 初始化一个Promise对象,其构造函数执行,打印 Promise 1,并调用reslove,reslove会调用then回调,将then回调放到微任务队列,微任务队列里的任务有 [then1]

  3. 遇到第二个setTimeout,跟步骤一同样,回调函数放到宏任务队列,目前宏任务队列[set1,set2]

  4. 执行console.log(2); 打印2,目前打印结果有:[Promise 1,2]

  5. 遇到一个微任务queueMicrotask,微任务队列: [then1,queueMicrotask]

  6. 初始化Promise,跟第二步类似,将then 3添加到微任务队列:[then1,queueMicrotask,then 3]

  7. 调用栈已经为空,可以执行任务队列,这时候优先执行微任务队列,所以会依次打印then1,queueMicrotask,then 3,这时候打印的有:[Promise 1,2,then1,queueMicrotask,then 3]

  8. 微任务已经为空,可以执行宏队列[set1,set2],set1比较特殊,先打印setTimeout 1,然后初始化一个Promise,该Promise直接reslove,将其then回调set1Then添加到微任务队列,此时:

    打印有[Promise 1,2,then1,queueMicrotask,then 3,setTimeout 1]

    微任务队列有:[set1Then]

    宏任务队列有:[set2]

  9. 再执行下一次循环时,依然会先查看是否有微任务,所以依旧会先执行set1Then,该方法会初始化一个Promise,将其then回调添加到微任务队列,此时微任务队列有[then4],但此时set1Then函数并没有完成,也就是还没有出栈,所以会继续往下执行,打印then2然后出栈,

    此时打印有:[Promise 1,2,then1,queueMicrotask,then 3,setTimeout 1,then2]

    微任务队列:[then4]

    宏任务队列:[set2]

  10. 先执行微任务,后执行宏任务,一次打印then4,setTimeout 2。此时所有任务都执行完毕,打印顺序为:[Promise 1,2,then1,queueMicrotask,then 3,setTimeout 1,then2,then4,setTimeout 2]

接下来在看另一个常用的api:

js 复制代码
setTimeout(handler, timeout);

setTimeout接受两个参数,一个回调函数,一个可选的时间值(可选,默认为0,单位是毫秒)。那么该回调函数一定是在指定的时间之后执行吗?设置为0是立即执行吗?

通过上面我们可以很轻易的回答:setTimeout的回调函数并不一定会在指定的时间之后执行,有可能会需要更长的时间。即使传递为0也不一定会立即执行。

该回调函数能否执行取决于

  1. 当前的调用栈是否有正在执行的任务
  2. 当前的任务队列是否有其他需要优先执行的任务

所以setTimeout的时间值其实是最小延迟时间,具体的执行时间还要看当前调用栈以及任务队列。

总结:

  1. js中异步是通过调用运行环境(浏览器/Node)中的api来实现的,浏览器/Node在适当的时候调用传入的回调函数
  2. 事件循环是js异步的基础,负责监控调用栈和任务队列。循环调度事件推入到调用栈执行。
  3. 任务队列分为微任务队列宏任务队列,微任务总是优先于宏任务执行,在每次执行宏任务的时候会先查看是否有微任务存在

推荐阅读:

  1. developer.mozilla.org/zh-CN/docs/...
  2. developer.mozilla.org/zh-CN/docs/...
  3. developer.mozilla.org/zh-CN/docs/...
  4. developer.mozilla.org/zh-CN/docs/...
  5. nodejs.org/en/learn/as...
  6. www.youtube.com/watch?v=8aG...
  7. medium.com/front-end-w...
  8. developer.mozilla.org/zh-CN/docs/...
相关推荐
腾讯TNTWeb前端团队1 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰5 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪5 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪5 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy6 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom6 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom6 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom7 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom7 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom7 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试