浅析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/...
相关推荐
我是苏苏15 分钟前
C# Main函数中调用异步方法
前端·javascript·c#
转角羊儿26 分钟前
uni-app文章列表制作⑧
前端·javascript·uni-app
大G哥33 分钟前
python 数据类型----可变数据类型
linux·服务器·开发语言·前端·python
hong_zc1 小时前
初始 html
前端·html
小小吱1 小时前
HTML动画
前端·html
Bio Coder1 小时前
学习用 Javascript、HTML、CSS 以及 Node.js 开发一个 uTools 插件,学习计划及其周期
javascript·学习·html·开发·utools
糊涂涂是个小盆友1 小时前
前端 - 使用uniapp+vue搭建前端项目(app端)
前端·vue.js·uni-app
浮华似水2 小时前
Javascirpt时区——脱坑指南
前端
王二端茶倒水2 小时前
大龄程序员兼职跑外卖第五周之亲身感悟
前端·后端·程序员
_oP_i2 小时前
Web 与 Unity 之间的交互
前端·unity·交互