通过伪代码彻底理解事件循环
前言
学习了本文内容后,你将更好的理解以下内容:
- 宏任务和微任务有哪些
- 一帧中浏览器的工作流程
- 浏览器的事件循环机制
- Nodejs 的事件循环机制
- Web Worker 的事件循环机制
- Nodejs 和浏览器的事件循环差异
本文默认你已知道
进程
和线程
的概念,并且知道浏览器内核中的五个工作线程:GUI渲染线程
、JS引擎线程
、定时器触发线程
、事件触发线程
、异步http请求线程
的工作内容。
宏任务和微任务
- 宏任务:script(整体代码)、setTimeout、setInterval、I/O、UI 事件交互、setImmediate(Nodejs 环境)
- 微任务:Promise.then、MutationObserver、process.nextTick(Nodejs 环境)
一帧中(对于 FPS 为 60HZ 的显示器来说就是 16.6ms)浏览器的工作流程
- 输入事件(如:阻塞-touch、非阻塞-click),并将回调添加到事件循环队列
- 处理 JS 定时器,并将回调添加到事件循环队列
- 处理开始帧对应的事件(如:window.resize、scroll)
- 执行 rAF 回调(请求动画渲染) requestAnimationFrame
- 页面布局(样式计算、更新布局)
- 样式绘制
- 如果以上六个高优先级的阶段执行完则进入该阶段(空闲阶段)requestIdleCallback
requestIdleCallback 执行超出时间剩余时间的函数会导致浏览器卡住。而 requestAnimationFrame 会自动调节频率,如果 callback 工作太多无法在一帧内完成会自动降低为 30fps。虽然降低了,但总比浏览器卡死好。
浏览器的事件循环机制
/*
浏览器的事件循环相当于一个无限循环的旋转木马一样
*/
while (true) {
queue = getNextQueue() // 获取下一个任务队列
task = queue.shift() //取出任务队列中的第一个任务
execute(task) // 执行当前任务
// 当微任务队列有任务时,全部执行
while (microtaskQueue.hasTasks()) {
doMicrotask()
}
// 准备开始重绘
if (isRepaintingTime()) {
animationTasks = animationQueue.copyTasks() // 复制所有的动画队列中的任务
// 遍历动画队列并依次执行
for (task in animationTasks) {
doAnimationTask(task)
}
repaint() // 重绘
}
}
- 微任务队列和动画队列(由 requestAnimationFrame 产生的回调队列)都会在单次循环中全部执行完
- 微任务队列在运行时递归自身进入会死循环,导致页面卡死
- 动画队列在运行时递归自身不会进入死循环,不会卡死页面。因为他是根据系统来决定回调函数的执行时机的,
Nodejs 的事件循环
Nodejs 可比浏览器的事件循环简单多了,有以下原因:
- 没有脚本解析事件:你不需要再 HTML 中去挑选出 JavaScript,你只需要给它一个 JavaScript 文件然后运行它即可
- 没有讨厌的用户交互
- 没有动画队列的回调
Nodejs 有三个主要阶段(从上往下顺序执行):
- timer 计时器阶段,负责执行
setTimeout
和setInterval
回调 - poll 轮训阶段,用于执行所有 I/O 事件的回调,比如 定时回调、XHR 请求、磁盘读取、磁盘写入以及其他内容
- check 检查阶段,通过
setImmediate
添加,setImmediate(cb)
会先于setTimeout(cb,0)
执行
poll 阶段比较特殊和重要:
- 如果队列不为空,会遍历队列并同步执行,直到队列为空或达到系统限制
如果队列为空,会有三件事发生:
- 如果有 setImmediate 回调需要执行,poll 阶段会立即停止并进入 check 阶段执行回调
- 如果没有 setImmediate 回调需要执行,会等到回调被加入的到队列中并立即执行,这里会有超时时间设置防止一直等待下去
- 如果没有 setImmediate 回调并且没有其他事件回调需要执行时,当 timer 到期了,需要回到 timer 阶段执行回调
/*
Nodejs事件循环相当于一个等待循环的过山车一样
*/
while (tasksAreWaiting()) {
queue = getNextQueue() // 每一次事件循环都会获取下一个任务队列
// 当前阶段有任务时,全部执行
while (queue.hasTasks()) {
task = queue.shift() // 取出任务队列中的第一个任务
execute(task) // 执行当前任务
// 当process.nextTick队列有任务时,全部执行
while (nextTickQueue.hashTasks()) {
doNextTickTask()
}
// 当promise队列有任务时,全部执行
while (promiseQueue.hashTasks()) {
doPromiseTask()
}
}
}
Web Worker 的事件循环
每个 Web Worker 都在自己的线程中工作,它有自己的堆栈,队列,一切都自己运行,它比 Nodejs 事件循环机制更加简单
- 没有 script 标签
- 没有用户交互
- 没有 DOM 操作,所以不用关心动画、帧或者类似的东西
- 没有 Nodejs 中的
setImmediate
和nextTick
/*
其和浏览器的事件循环相似,只是任务队列中少了很多事件和动画
*/
while (true) {
queue = getNextQueue() // 获取下一个任务队列
task = queue.shift() //取出任务队列中的第一个任务
execute(task) // 执行当前任务
while (microtaskQueue.hasTasks()) {
// 当微任务队列有任务时,全部执行
doMicrotask()
}
if (isRepaintingTime()) {
repaint() // 重绘
}
}
Nodejs 和浏览器的事件循环差异
- 浏览器环境下,微任务队列是每个宏任务执行完之后再执行
- Nodejs 环境下,微任务队列会在事件循环的各个阶段执行,在 Nodejs11 之前,需要执行完每个阶段中的所有宏任务才会执行微任务队列,但 Nodejs11 后与浏览器一致,当每个阶段执行完一个宏任务后,就执行微任务队列。
接下来以一个例子看清楚他们的区别
setTimeout(() => {
console.log("timer1")
Promise.resolve().then(function() {
console.log("promise1")
})
}, 0)
setTimeout(() => {
console.log("timer2")
Promise.resolve().then(function() {
console.log("promise2")
})
}, 0)
- 在浏览器和 Node11 及之后版本中会打印出:timer1 -> promise1 -> timer2 -> promise2
- 在 Nodejs11 之前会打印出:(如果 timer1 和 timer2 都在 timer 阶段中)timer1 -> timer2 -> promise1 -> promise2,如果 timer2 还没到期则和浏览器一致。