一.浏览器的渲染主线程与事件循环
浏览器的渲染主线程是浏览器中十分繁忙的一个线程,它主要负责解析html,构建dom树,解析css,执行js代码,对页面进行布局渲染等操作,所以需要事件循环来合理调度任务,事件循环简单来说就是对要做的任务排队
事件循环可以简单用这个图片来概括一下
下面简单描述一整个事件循环的过程
1.在最开始的时候渲染主线程会进入一个无限循环,比如chrome的源码中使用了一个for(;;)
2.每一次循环会检查消息队列中是否有任务存在,如果有任务存在就取出第一个任务放到渲染主线程中执行,如果消息队列为空渲染主线程就进入休眠状态
3.其他线程包括渲染主线程都可以向消息队列中添加任务,按队列先进先出的方式添加,如果此时渲染主线程在休眠就唤醒渲染主线程然后执行任务
二.异步的方式使得渲染主线程永不阻塞
因为渲染主线程只有一个,所以js是一个单线程的语言,如果所有的js代码运行都是同步的,那么就会导致渲染主线程的堵塞
举个例子,比如下面这个图
如果js是同步运行的话,比如这里调用了一个计时器,渲染主线程要等到计时器计时结束之后才能从消息队列中取出下一个任务放入渲染主线程执行,这显然是不合理的,会导致堵塞渲染主线程
所以需要异步来解决这个问题
可以看到在setTimeout调用后渲染主线程不会管这个计时线程会计时多久,而是先把消息队列中的任务一个一个拿出来执行,在前面的任务完成后会把计时器中传递的回调函数包装成任务(任务本质上其实是一个c++的结构体)加入消息队列的末尾,这样就不会导致阻塞了
ps.这里稍稍解释一下任务是结构体这句话
来看下w3c的说法还是挺复杂的

三.微任务队列与其他任务队列
直接说结论,任务没有优先级,但是任务队列也就是消息队列有优先级
- 每一个任务都有一个任务类型,同一个类型的任务必须在一个任务队列,不同类型的任务可以在不同的任务队列中,每种浏览器会根据自己的实际情况对不同的任务队列赋予不同的优先级
- 有一种特殊的队列叫微队列,微队列优先级最高
这里对一些常见队列类型进行说明(按优先级递减顺序)
- 微队列:优先级最高
- 用户交互队列(例如用户点击事件等):高
- 网络队列(发送网络请求):中
- 延时队列(计时器等):低
ps.常见的微任务有Promise,Object.observe,async/await,MutationObserver,nodejs下的process.nextTick
这里需要注意的是async函数的内部代码和普通函数一样是同步执行的,await相当于等待,等一轮微队列结束再执行,await表达式后面跟着的代码可以看成放在pormise.then()中
验证一下这些队列的优先级
<body>
<button id="begin">开始</button>
<button id="add">添加交互事件</button>
<script>
const begin = document.getElementById('begin')
const add = document.getElementById('add')
//设定死循环时间
function delay(time){
const start = Date.now()
while(Date.now()-start < time){}
}
//添加延时队列
function addDelay(){
console.log('添加延时队列');
setTimeout(()=>{
console.log('执行延时队列');
},100)
delay(3000)
}
//添加网络队列
function addNet(){
console.log('添加网络队列');
fetch('https://api.github.com/users/ruanyf').then((res)=>{
console.log('执行网络队列');
})
delay(3000)
}
//添加交互队列
function addBoth(){
console.log('添加交互队列');
add.onclick = ()=>{
console.log('执行交互队列');
}
delay(3000)
}
//添加微队列
function addMicro(){
console.log('添加微队列');
Promise.resolve().then(()=>{
console.log('执行微队列');
})
}
begin.onclick = ()=>{
addDelay()
addNet()
addBoth()
addMicro()
console.log('finished');
}
</script>
</body>
输出结果是这样的:

四.来看个题吧
function a(){
console.log(1);
Promise.resolve().then(()=>{
console.log(2);
})
}
setTimeout(()=>{
console.log(3);
Promise.resolve().then(a)
},0)
setTimeout(()=>{
console.log(4);
},1)
fun1()
Promise.resolve().then(()=>{
console.log(5);
})
console.log(6);
async function fun1(){
console.log(7);
await fun2()
console.log(8);
}
async function fun2(){
return Promise.resolve().then(()=>{
console.log(9);
})
}
执行过程:
1.渲染主线程中执行全局js代码,遇到function a,只是定义不是执行,接着往下走遇到第一个setTimeout(这里记做setTimeout1)放入交互队列,再往下走遇到第二个setTimeout(记做steTimeout2)放入交互队列,排在setTimeout1后面
2.再往下遇到了fun1()调用,fun1()是一个async声明的异步函数会立即开始执行,把fun1()放入渲染主线程中,来看fun1()的代码,先打印了一个7 ,然后遇到await开始执行fun2()
3.fun2()把一个promise (记做promise1) 放入微队列,await会等待,所以需要把第⼀轮微任务执⾏完才会执行第22行,这时候因为fun1()还在等待所以跳过了fun1()到了第15行,后将15行这个Promise (记做promise2) 放入微队列,然后遇到console.log(6)打印6 ,这时全局js执行完了
3.到现在全局js执行完毕开始第二轮event loop,先把微队列拿到渲染主线程中,执行的第一个promise1打印9,以此类推执行完promise2,打印5
4.这时候这轮微任务执行完毕,把await后面的语句即第22行放到微任务队列中,主线程再从微任务队列中取出执行,打印8
5.这时候fun1()彻底执行完毕了,微任务队列也为空了,所以来看延时队列,主线程执行setTimeout1打印3 ,然后发现一个Promise.then(a),把它加到微任务队列中,现在主线程为空,优先取出微队列中任务,也就是a函数,所以打印出 1 , 2
6.最后取出setTimeOut2 打印 4 结束
最终结果为:7 6 9 5 8 3 1 2 4

One More Thing
这两种promise写法要注意一下
new Promise(function (resolve) {
console.log("promise");
resolve();
});
Promise.resolve().then(()=>{
console.log('promise');
})
第一种写法Promise构造函数会立即执行不会被添加到微队列,第二种才会
二.Nodejs事件循环
事件循环分为多个阶段,每个阶段处理特定的任务。关键阶段如下:
- Timers:执行
setTimeout()和setInterval()的回调。 - I/O Callbacks:处理一些延迟的 I/O 回调。
- Idle, prepare:内部使用,不常见。
- Poll:检索新的 I/O 事件,执行与 I/O 相关的回调。
- Check:执行
setImmediate()回调。 - Close Callbacks:处理关闭的回调,如
socket.on('close', ...)
nodejs中优先级排列:
在同步的代码执行完毕后,首先是特殊的两个任务队列
1.process.nextTick任务队列
2.promise微任务队列
接下来是事件循环部分,主要分为timer,poll,check三个任务队列
- timer就是settimeout和setinterval这两个
- poll就是nodejs对I/O的操作,包括文件读取,网络访问等
- check就是setImmediate调用结果
事件循环会按照从timer->poll->check的顺序循环执行,要注意的是当到poll时会去检查timer和check队列是否为空,如果为空事件循环就在poll阶段阻塞,等待I/O任务的出现,如果那两者不为空就会取出执行,调用完后回到poll继续阻塞等待。
值得注意的是对于这段代码
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
输出结果是有两种情况的,因为理论上是先执行第一段结果,但是由于在nodejs中setTimeout的最短延时时间是1ms,所以可能出现系统运行较快,当第二段执行完后第一段才被加入队列,解决方法是放入一个IO操作中,这样在poll阶段这两个任务分别放入两个队列,然后会执行check队列,接着再循环到timer队列。