Eventloop
要说Eventloop,就不得不提浏览器进程和JavaScript单线程的三两事。
浏览器的工作原理,这里不提了,细抠的话很复杂,这里只说浏览器进程的组成。
浏览器进程
简单了解下浏览器进程,但这不是本文重点,本文重点是: 浏览器渲染进程(内核),也叫 浏览器内核进程
- Browser进程: 浏览器的主进程,负责协调、主控,只有一个
- 第三方插件进程: 每种类型的插件对应一个进程,仅当使用该插件时才创建
- GPU进程:最多一个,用于3D绘制
- 浏览器渲染进程(内核):默认每个Tab页面一个进程,互不影响,控制页面渲染,脚本执行,事件处理等(有时候会优化,如多个空白tab会合并成一个进程)
以下是本文重点
浏览器内核进程中主要有5大常驻线程,他们各自有分工:
- GUI渲染线程:负责页面渲染
- JS引擎线程:主线程,执行JavaScript脚本
- 事件触发线程: 顾名思义
- 定时器线程:顾名思义
- 异步http请求线程: 顾名思义
大家都知道JavaScript是单线程脚本语言,为啥是单线程,有一定的历史原因,也有专业性的考量,感兴趣的话推荐阅读 阮一峰 老师的 JavaScript 运行机制详解:再谈Event Loop
上面列出来的5大常驻线程,其中GUI渲染线程和JS引擎线程是“相爱相杀”的,JS引擎线程在执行过程中会阻塞GUI渲染线程进行页面渲染,导致页面卡顿。
如果你说感觉不到,那只能说卡顿时间长短的问题,可以试试这个,先在浏览器打开看看,再把while(true) {}
注释掉刷新试试。
test.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script>
while(true) {}
</script>
</head>
<body>
这是测试GUI线程渲染结果显示的内容,很简单,就一行文字而已。。。
</body>
</html>
这就是为什么在聊到页面优化的时候,有一项是: 把没有上下文依赖的外部脚本引入放到</body>上一行的原因。
除了GUI渲染线程和JS引擎线程之外,剩下的3个线程:事件触发线程、定时器线程、异步http请求线程,都是用来处理异步逻辑的。
任务队列(消息队列)
除了这些线程之外,还有一个消息队列(任务队列),任务对列不是线程,而是一个存储结构。
下面说的内容可能会有偏差,因为我查询到的有各个版本的说法,难以总结,大致有个概念就行
任务对列分为2种类型:
- 宏任务队列: Task Queue(MacroTask Queue),可以有多个
- 微任务队列: MicroTask Queue
异步代码会根据类型分别丢到 宏任务队列 和 微任务队列 中处理,下面是一些划分(左边是高优先级):
- 宏任务: setTimeout => setInterval => setImmediate(nodeJS) => I/O => UI Rendering
- 微任务: process.nextTick(nodeJS) => Promise(Promise.then) => mutationObserver
EventLoop(事件循环)
当JavaScrip会创建一个类似于while(true)的循环,不停地执行,每当JS主线程执行完栈中同步代码后,就会去任务队列中读取,被读取到主线程栈中的代码一定是已经执行完成的异步代码,如果异步代码还没有完成,怎么继续等待,直到完成后才会被读取到主进程中执行,因为JavaScript单线程特性决定了它不可能等待不知道什么时候能完成的任务。
其实上面都是概念性的内容,其实本文的核心是想说一说 宏任务和微任务 的执行顺序。
案例
在面试题中经常会看到类似于这样的代码,来判断输出的顺序,这里就借这个案例来抠一抠:
先别慌思考和运行,看看示例代码块下面的规则,然后再去思考,写出顺序。
如果你都觉得这是小case,那么浏览器Tab右上角有个“叉号”, 请点一点;
如果你错了,那么看看规则下方的分析吧。。。
console.log('1');
setTimeout(function() {
console.log('2');
Promise.resolve().then(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
Promise.resolve().then(function() {
console.log('14');
})
})
}, 100)
Promise.resolve().then(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
Promise.resolve().then(function() {
console.log('15');
})
})
Promise.resolve().then(function() {
console.log('100');
})
setTimeout(function() {
console.log('9');
Promise.resolve().then(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
}, 0)
console.log('13')
有以下几个规则:
- JavaScript在执行时,遇到同步代码就直接执行,遇到异步代码会分类:
- 宏任务丢到宏任务队列;
- 微任务丢到微任务队列
- 宏任务优先于微任务执行(你可能会对这个规则有疑惑,不急,往下看)
每个宏任务执行完后执行渲染(这是扩展的,放这里纯粹是防止忘记) - 继续执行下一个宏任务
- 重点:每个宏任务执行完后,不会立即进入渲染或进入下一个宏任务,而是把在当前宏任务执行过程中产生的所有微任务执行完后才会进入下一步
执行结果为:
1 // console.log('1')
7
13 // console.log('13')
6
8
100
15
// 以下为第二个setTimeout
9
11
10
12
// 以下为第一个setTimeout
2
4
3
5
14
分析
-
console.log('1')
和console.log('13')
是同步代码,输出:1 => 13
很好理解,但是7
为什么会在13
之前输出?
原因: 虽然 console.log('7')
是 new Promise的回调函数内的,但这里并不意味着它就是异步的,可以想象一下这样一个过程: new Promise(callback),而Promise的构造函数是这样的:
function Promise (cb) {
let resolve = function (data) { xxx }
typeof cb === 'function' && cb(resolve)
}
那么new Promise内部就是同步执行的,起码按照目前的表现是可以这么猜测和解释的。
所以 输出:1 => 7 => 13
就可以理解了。
接下来是
6 => 8 => 100
的输出的分析
疑惑: 规则里不是说了先执行宏任务吗?为什么不是先执行setTimeout里的代码?而是执行微任务里的Promise.then?
其实这问题我也疑惑了挺久的,弄得一直搞不清到底谁先谁后,后来想到了原因。
原因: 因为代码执行的上下文就是被当做一个最外层的宏任务,而Promise.resolve().then(function() {console.log('6');})
、Promise.xxxx.then(function() {console.log('8');})
以及Promise.resolve().then(function() {console.log('100');})
外部没有被属于宏任务的代码包裹,就说明属于顶层宏任务的微任务,所以会先执行。再看看 15 和 8 都在一个Promise.then的代码块里,为什么会在 100 之后输出?
原因:
- 实际上 console.log('8') 和 Promise.resolve()确实是同步执行的
- 但遇到了 .then,发现这是一个微任务,所以又把 console.log('15') 丢到了微任务队列中
- 虽然这是一个异步,但根据规则同一个宏任务中的微任务会被等待到执行完,所以 console.log('15')是一定会被在下一个宏任务之前被执行的。
但我们不要忘记了,队列的数据结构是 先进先出 的,console.log('8') 和 console.log('100')由于被先丢到微任务队列中,所以会先执行,console.log('15') 就会后执行。
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
Promise.resolve().then(function() {
console.log('15');
})
})
综合以上分析那么下面两个定时器分别按顺序输出:
9 => 11 =>10 =>12和
2 => 4 =>3 => 5 =>14
就顺理成章了还有个问题,不是 2 => 4 =>3 => 5 =>14 应该被先输出吗,为什么反了?
原因: 示例代码里其实有一个坑。。。两个定时器的延迟时间不同,第一个定时器是 100ms,而第二个定时器是 0ms,是立即执行的。
新的疑惑: 虽然两个定时器的延迟时间不同,但是第一个定时器被先添加到宏任务队列中,按照队列先进先出规则,岂不是自己打脸了?
原因: 这些异步代码是通过EventLoop不停地轮询队列,然后添加到主线程中执行的。
但是有一个前提,这个前提在上面介绍EventLoop已经加粗显示了:
被读取到主线程栈中的代码一定是已经执行完成的异步代码
注意已经执行完成这6个字,因为第二个定时器先执行完,所以EventLoop先发现了它,所以它被先取到主线程中执行。
这样疑问就解决了。。。