浏览器事件循环机制

一、 前言

没话说还是要整个前言,不然队形就乱了。
好吧,本文就根据自己看了两天的事件循环机制以及宏任务微任务进行深入浅出的讲解
本文会由浅入深,以一个小白的角度理解事件循环机制这个js中高级面试中常考的问题

7.22补充:更改了标题,事件循环机制根据宿主不同分为浏览器的事件循环机制和node事件循环机制

二、什么是事件循环机制

由于是在面试中被问到这个问题,而我确实之前没有看过,于是在学习看文档包括我在整理这篇博客的时候都在想,如何简要的描述这个过程比较麻烦的概念
我觉得可能可以这样说(自己总结,并非来自官方文档或者大牛):

javascript 主线程 执行完成后,不断地去 任务队列 中查找需要执行的 回调函数 ,并加入到主线程中执行的过程就叫事件循环机制

2.1 简单demo

既然是由浅入深,就从一个最简单的例子开始解释吧

1
2
3
4
5
console.log("start");
setTimeout(function callback() {
console.log("timeout");
}, 0)
console.log("end");

这段简单的代码,就算不清楚事件循环机制也知道输出顺序为 start, end, timeout,如果问原因,可能会说,因为setTimeout是延时函数,所以执行的慢。
实际并不是这样

2.1.1 几个概念

解释代码之前先解释几个概念,如图
事件循环基本概念

  1. call stack
    也叫调用栈,栈这种数据结构是先进后出,在主线程执行的代码过程中遇到函数就创建这个函数的执行上下文并推入栈,执行完后在从栈中弹出。
  2. Web Api
    很多文章中不会提到这个概念,我觉得有必要提一下,所谓的web api就是浏览器或者其他宿主环境提供的一些方法,比如浏览器提供的setTimeout以及ajax等方法, 也就是下面会提到的 宏任务
  3. callback queue
    回调函数队列或者说任务队列(task queue),回调函数,比如setTimeout执行完后会将回调函数放到callback queue中,由于队列是先进先出,因此最先进队的最先出队

开始正式解释:
在这段代码执行过程中,call stack是主角,在所有代码开始执行之前会往栈中压入一个main函数,用来执行这些代码,具体过程如下

  1. 第一步执行代码第一句console.log("start"),这是个函数调用,因此将其压入call stack,并执行它,打印 start 没有返回值,因此执行完后出栈。
  2. 接着遇到了setTimeout(callback, 0)这段代码,由于是异步函数,因此浏览器会创建一个定时器,用于在指定毫秒后执行,这部分是浏览器负责,因此这段代码也出栈
  3. 和第一步类似,先入栈,打印完后出栈。

代码执行结束了,哎,好像忘了那个定时器,不过浏览器帮我们记着呢,在0毫秒后,浏览器会将回调函数加入到任务队列中。接下来是Event Loop这个事件环的主场,事件环会不停的检测call stack,如果调用栈执行结束,就会从任务队列中取第一个回调函数,压入call stack中并执行,执行结束后会打印 timeout,至此,调用栈为空,任务队列也为空,函数执行结束。

上述过程就是事件循环机制。

好像有点懂了,不过这个例子太简单了,来个稍微复杂一点的吧

2.2 最经典的面试题

1
2
3
4
5
6
7
8
9
10
11
console.log("start");
setTimeout(function callback() {
console.log("timeout");
}, 0)
new Promise(resolve => {
console.log("resolve");
resolve(1);
}).then(res => {
console.log(res);
})
console.log("end");

可能看过道题目的,也知道正确的输出结果,解释一下其中的玄机
首先还是解释两个概念

2.2.1 宏任务

ES6 规范中,宏任务macrotask 称为 task,是由宿主发起的,如script(整体代码),setTimeout、 setInterval ,I/O、 UI交互事件 、postMessage、 MessageChannel、 setImmediate(Node.js 环境)

2.2.2 微任务

ES6 规范中,microtask 称为 jobs,是由JavaScript自身发起,如Promise、MutaionObserver、Object.observe(已废弃;Proxy 对象替代)、process.nextTick(Node.js)
上述代码执行过程如下:

  1. call stack中压入 console.log("start"),执行后打印 start, 随后弹出
  2. call stack中压入 setTimeout(callback, 0),浏览器会创建定时器,监控何时将其放入任务队列中,执行完后出栈
  3. call stack中压入 new Promise().then() 这段代码,遇到调用 console.log("resolve"), 于是将其压入call stack中,执行后弹出,然后遇到resolve()函数,由于promise是微任务,因此会被加入到微任务队列(micor queue)中,执行后出栈
  4. call stack中压入 console.log("end"), 控制台打印 end, 随后出栈

此时调用栈为空,主线程接下来会去微任务队列中取任务(微任务是ES6中声明的, 是js的亲儿子,所以优先照顾),也就是执行then回调函数,将 console.log(res), 将这行代码压入call stack中,打印 1, 执行后弹出
此时call stack又空了,事件环开始发挥作用,事件环会去任务队列(callback queue)中取任务,如果此时定时器已经结束,则将callback函数压入栈,打印 timeout, call stack, 两个队列都为空,代码执行结束

2.3 加入dom操作

我们知道了宏任务微任务的优先级,那么dom操作的优先级呢?
首先是结论

微任务 > DOM渲染 > 宏任务

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>dom渲染顺序</title>
</head>
<body>
<h3 id="app"></h3>
<script>
console.log("start");
setTimeout(function callback() {
alert("timeout");
}, 0)
new Promise(resolve => {
resolve();
}).then(res => {
alert("resolve");
})
document.getElementById("app").innerHTML = "轮到我渲染了吗";
console.log("end");
</script>
</body>
</html>

不在逐行分析,首先是逐行压入call stack中执行。遇到setTimeout压入栈,遇到dom操作,执行完并不是立即执行,因为主线程现在忙碌,无法处理页面渲染问题,然后将primise放入微任务队列

这里有点问题,好像引擎线程和渲染线程是互斥的,后面研究了在来修改!!!

当call stack为空时,首先去微任务队列中取任务,将then回调压入call stack中执行,然后会进行页面渲染,也就是将id为app的内容替换为 轮到我渲染了吗?,然后事件环会去任务队列中取宏任务setTimeout的回调函数并执行
因此执行顺序为:
控制台打印 start, end, 弹出 resolve, 页面看到id为app的内容更新, 弹出 timeout

三、总结

引用一张图

事件循环过程
引用一段总结:原文
Event Loop中,每一次循环称为tick,每一次tick的任务如下:

  1. 执行栈选择最先进入队列的宏任务(一般都是script),执行其同步代码直至结束;
  2. 检查是否存在微任务,有则会执行至微任务队列为空;
  3. 如果宿主为浏览器,可能会渲染页面;
  4. 开始下一轮tick,执行宏任务中的异步代码(setTimeout等回调)。