JavaScript中的EventLoop 机制


前言

让我们先来看一个简单的例子:

Q: 请写出下列JS代码的运行结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log(1);
setTimeout(function () {
console.log(2);
})
var promise = new Promise(
function (resolve, reject) {
console.log(3);
resolve();
}
)
promise.then(function () {
console.log(4);
})
console.log(5);

——题目来自SAST2020应用开发部Web部分招新题。

我们通过浏览器控制台可以很容易得出以下运行结果。

1
2
3
4
5
1
3
5
4
2

作为当时批改前端部分的我,我曾问那些写出运行结果的新同学为什么答案是这个顺序,结果并没有人可以答出个所以然来。当然了,作为刚接触web的同学来说,上来就要你谈JavaScript的事件循环机制确实难了一点。

但是这的确是一个在前端面试中非常常见的一个问题。

相关的问题还有:

  • 请你谈一下浏览器的渲染原理。
  • 请你聊一下JavaScript的运行机制。
  • 请你说一说node的事件循环和浏览器的事件循环区别是什么。
  • 什么是宏任务、微任务?为什么需要引入这样的设定?

这里就简要的讲一下我的一些理解。


事件循环

JavaScript的单线程性

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。

那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。

这和我们之前聊过的浏览器渲染原理有关系。

浏览器渲染原理:

用户请求的HTML数据通过浏览器的网络层到达渲染引擎后,浏览器的渲染工作开始。

这里我们以Webkit引擎渲染的流程为例,其余引擎大体上相似:

浏览器渲染原理图(webkit)

渲染流程有四个主要步骤:

  1. 解析HTML生成DOM树 - 渲染引擎首先解析HTML文档,生成DOM树
  2. 构建Render树 - 接下来不管是内联式,外联式还是嵌入式引入的CSS样式会被解析生成CSSOM树,根据DOM树与CSSOM树生成另外一棵用于渲染的树-渲染树(Render tree),
  3. 布局Render树 - 然后对渲染树的每个节点进行布局处理,确定其在屏幕上的显示位置
  4. 绘制Render树 - 最后遍历渲染树并用UI后端层将每一个节点绘制出来。

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动、数据传输以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。

比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来基本上也不会改变。

当然,为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM,只能做一些计算性质的工作。所以,这个新标准并没有改变JavaScript单线程的本质。

执行栈和事件队列

因为JavaScript的单线程性,所以它的函数调用是通过一个执行栈来实现的。

执行栈: 同步代码的执行,按照顺序添加到执行栈中。

下面是一个简单的例子展现JS执行栈的过程:

1
2
3
4
5
6
7
8
function a() {
b();
console.log('a');
}
function b() {
console.log('b')
}
a();

preview

  1. 执行函数 a()先入栈
  2. a()中先执行函数 b() 函数b() 入栈
  3. 执行函数b(), console.log('b') 入栈
  4. 输出 b, console.log('b')出栈
  5. 函数b() 执行完成,出栈
  6. console.log('a') 入栈,执行,输出 a, 出栈
  7. 函数a 执行完成,出栈。

但是我们的JS中并不都是顺序执行的脚本,有一些异步执行的代码,例如setTimeout(), setInterval() …

或者当我们遇到一些执行时间特别长的代码,我们也需要对其进行异步,防止阻塞JS线程。

事件队列: 异步代码的执行,遇到异步事件不会等待它返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步事件返回结果,将它放到事件队列中,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。

简单理解,就是JS在执行中,非异步的代码将放入执行栈中,先入后出,如果遇到异步的代码,则放入事件队列中挂起,等待回调,先入先出。

所谓的事件循环(EventLoop),其实就是异步事件被放入事件队列(Task Queen/Message Queen)中后又经过回调加入主线程中的这样一个循环过程。

事件循环:

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。

宏任务和微任务

在ES6中,引入了宏任务和微任务的概念。不同的任务被分为:宏任务和微任务

页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。

宏任务:

  • script(整体代码)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI交互事件

微任务:

  • Promise().then(回调)
  • MutationObserver(html5 新特性)

此时的事件循环机制将对宏任务和微任务进行区分,运行机制如下:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

使用较为形象的语言来说就是:

微任务是跟屁虫,一直跟在当前的宏任务后面,当前宏任务执行完成后,按照微任务队列执行所有微任务,再开始下一个宏任务。

回到最开始的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log(1);
setTimeout(function () {
console.log(2);
})
var promise = new Promise(
function (resolve, reject) {
console.log(3);
resolve();
}
)
promise.then(function () {
console.log(4);
})
console.log(5);

setTimeout()和Promise()都是异步事件,但是他们也有顺序。

前者是宏任务,后者是微任务。

值得注意的一点是,new 一个Promise对象是瞬间执行的,异步的是promise.then的部分。

下图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

image-20201207170413285

下面是一段代码的执行动画:

preview

下面在招新题上来一个复杂一点的扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
console.log(1);
setInterval(function () {
console.log(100);
}, 1000);

var promise = new Promise(
function (resolve, reject) {
console.log(3);
resolve();
var promise2 = new Promise(
function (resolve, reject) {
console.log(10);
resolve();
console.log(11);
}
);
promise2.then(function () {
console.log(6);
});
}
)
promise.then(function () {
console.log(4);
});
setTimeout(function () {
console.log(2);
}, 500);
promise.then(function () {
console.log(57);
});
console.log(5);

请你不通过控制台运行,直接写出执行结果吧!


[参考资料]: