事件循环机制(Event Loop)
浏览器页面主线程如何调度和执行任务
学习重点:
- 消息队列和事件循环
- 宏任务和微任务
- Promise 和 async/await 的实现原理
- 任务调度的优先级
1. 消息队列和事件循环
1.1 为什么需要消息队列
问题:页面主线程需要处理很多不同类型的任务,包括:
- 渲染事件(如解析 DOM、计算布局、绘制)
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
- JavaScript 脚本执行事件
- 网络请求完成、文件读写完成事件
解决方案:需要一个系统来统筹调度这些任务,这个统筹调度系统就是消息队列和事件循环系统。
消息队列:一种数据结构,可以存放要执行的任务。它符合队列”先进先出”(FIFO, First In First Out)的特点。
事件循环:主线程采用一个 for 循环,不断地从消息队列中取出任务并执行任务。
1.2 消息队列的工作原理
工作原理:
[任务队列]
队列头部 ← ... ← 队列尾部
操作:
- 添加任务:添加到队列的尾部
- 取出任务:从队列头部取出
示例:
// 模拟消息队列
class TaskQueue {
constructor() {
this.queue = []
}
// 添加任务到队列尾部
pushTask(task) {
this.queue.push(task)
}
// 从队列头部取出任务
takeTask() {
return this.queue.shift()
}
}
// 主线程循环
const taskQueue = new TaskQueue()
function MainThread() {
while (true) {
const task = taskQueue.takeTask()
processTask(task)
}
}1.3 事件循环的简单实现
第一版:处理安排好的任务
function MainThread() {
const num1 = 1 + 2 // 任务 1
const num2 = 20 / 5 // 任务 2
const num3 = 7 * 8 // 任务 3
console.log(num1, num2, num3) // 任务 4
}
MainThread()第二版:在线程运行过程中处理新任务
function MainThread() {
while (true) {
const firstNum = getInput()
const secondNum = getInput()
const result = firstNum + secondNum
console.log(result)
}
}第三版:处理其他线程发送过来的任务
const taskQueue = new TaskQueue()
function MainThread() {
while (true) {
const task = taskQueue.takeTask()
processTask(task)
}
}
// 其他线程发送任务
taskQueue.pushTask(clickTask)
taskQueue.pushTask(parseHTMLTask)1.4 消息队列中的任务类型
常见的任务类型:
- 输入事件:鼠标滚动、点击、移动
- 微任务:Promise 回调、MutationObserver 回调
- 文件读写:文件读写完成事件
- WebSocket:WebSocket 消息事件
- JavaScript 定时器:setTimeout、setInterval 回调
- 解析 DOM:HTML 解析事件
- 样式计算:CSS 样式计算事件
- 布局计算:布局计算事件
- CSS 动画:CSS 动画事件
注意:以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,你还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。
2. 宏任务和微任务
2.1 什么是宏任务
定义:消息队列中的任务称为宏任务(Macro Task)。
特点:
- 宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的
- 对一些高实时性的需求就不太符合了
常见的宏任务:
- 渲染事件(如解析 DOM、计算布局、绘制)
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
- JavaScript 脚本执行事件
- 网络请求完成、文件读写完成事件
- setTimeout、setInterval 回调
执行流程:
消息队列 → [取出最老的任务] → [记录开始时间] → [执行任务] → [删除任务] → [统计执行时长]
2.2 什么是微任务
定义:微任务(Micro Task)是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
特点:
- 微任务可以在实时性和效率之间做一个有效的权衡
- 每个宏任务都关联了一个微任务队列
常见的微任务:
- Promise 回调(resolve/reject)
- MutationObserver 回调
- queueMicrotask 回调
产生方式:
- 使用 MutationObserver:监控某个 DOM 节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务
- 使用 Promise:当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务
2.3 微任务的执行时机
执行时机:
- 通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务
- WHATWG 把执行微任务的时间点称为检查点(Checkpoint)
执行流程:
执行宏任务
↓
执行 JavaScript 代码
↓
准备退出全局执行上下文
↓
[检查点] 检查微任务队列
↓
执行微任务队列中的所有微任务
↓
继续执行下一个宏任务
关键点:
- 如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中
- V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束
- 也就是说在执行微任务过程中产生的微任务并不会推迟到下一个宏任务中执行,而是在当前的宏任务中继续执行
2.4 宏任务和微任务的执行顺序
执行顺序:
- 执行当前宏任务
- 执行当前宏任务中的所有微任务
- 执行下一个宏任务
示例:
console.log('1')
setTimeout(function() {
console.log('2')
}, 0)
Promise.resolve().then(function() {
console.log('3')
})
console.log('4')执行结果:
1
4
3
2
执行流程:
- 执行宏任务:输出 ‘1’ 和 ‘4’
- 执行微任务:输出 ‘3’(Promise 回调)
- 执行下一个宏任务:输出 ‘2’(setTimeout 回调)
2.5 为什么需要微任务
问题:如何平衡效率和实时性?
场景:监控 DOM 节点的变化
方案 1:同步通知
- 如果每次发生变化的时候,都直接调用相应的 JavaScript 接口
- 那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降
方案 2:异步通知(宏任务)
- 如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部
- 那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了
解决方案:微任务
- 通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列
- 在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中
- 这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题
- 等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务
- 因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题
3. Promise 的实现原理
3.1 Promise 解决的问题
问题 1:代码逻辑不连续
- 异步回调导致代码的逻辑不连贯、不线性,非常不符合人的直觉
问题 2:回调地狱
- 如果嵌套了太多的回调函数就很容易使得自己陷入了回调地狱,不能自拔
解决方案:
- Promise 通过回调函数延迟绑定、回调函数返回值穿透和错误”冒泡”技术解决了这些问题
3.2 Promise 的实现机制
Promise 的工作原理:
function XFetch(request) {
function executor(resolve, reject) {
let xhr = new XMLHttpRequest()
xhr.open('GET', request.url, true)
xhr.ontimeout = function(e) { reject(e) }
xhr.onerror = function(e) { reject(e) }
xhr.onreadystatechange = function() {
if(this.readyState === 4) {
if(this.status === 200) {
resolve(this.responseText, this)
} else {
reject({ code: this.status, response: this.response }, this)
}
}
}
xhr.send()
}
return new Promise(executor)
}使用方式:
var x1 = XFetch(makeRequest('https://time.geekbang.org/?category'))
var x2 = x1.then(value => {
console.log(value)
return XFetch(makeRequest('https://www.geekbang.org/column'))
})
var x3 = x2.then(value => {
console.log(value)
return XFetch(makeRequest('https://time.geekbang.org'))
})
x3.catch(error => {
console.log(error)
})3.3 Promise 和微任务
为什么 Promise 使用微任务:
- Promise 采用了回调函数延迟绑定技术
- 在执行 resolve 函数的时候,回调函数还没有绑定,那么只能推迟回调函数的执行
- 要让 resolve 中的回调函数延后执行,可以在 resolve 函数里面加上一个定时器,让其延时执行
- 不过使用定时器的效率并不是太高,好在我们有微任务,所以 Promise 又把这个定时器改造成了微任务了
- 这样既可以让回调函数延时被调用,又提升了代码的执行效率
示例:
function Bromise(executor) {
var onResolve_ = null
var onReject_ = null
this.then = function(onResolve, onReject) {
onResolve_ = onResolve
}
function resolve(value) {
// 使用微任务延迟执行
queueMicrotask(function() {
onResolve_(value)
})
}
executor(resolve, null)
}4. async/await 的实现原理
4.1 生成器(Generator)
定义:生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的。
示例:
function* genDemo() {
console.log('开始执行第一段')
yield 'generator 0'
console.log('开始执行第二段')
yield 'generator 1'
console.log('开始执行第三段')
yield 'generator 2'
console.log('执行结束')
return 'generator 3'
}
let gen = genDemo()
console.log(gen.next().value) // 'generator 0'
console.log(gen.next().value) // 'generator 1'
console.log(gen.next().value) // 'generator 2'
console.log(gen.next().value) // 'generator 3'特点:
- 在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行
- 外部函数可以通过 next 方法恢复函数的执行
4.2 协程(Coroutine)
定义:协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。
特点:
- 协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)
- 这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源
协程的切换:
- 如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程
- 当执行到 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息
- 同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息
4.3 async/await 的工作原理
async 函数:
- async 是一个通过异步执行并隐式返回 Promise 作为结果的函数
- async 函数返回的是一个 Promise 对象
await 的执行流程:
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)执行流程:
- 执行
console.log(0),输出 0 - 执行
foo()函数,由于 foo 函数是被 async 标记过的,所以当进入该函数的时候,JavaScript 引擎会保存当前的调用栈等信息 - 执行
console.log(1),输出 1 - 执行到
await 100时,会默认创建一个 Promise 对象,然后将该任务提交给微任务队列 - JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将 promise 对象返回给父协程
- 父协程调用
promise.then来监控 promise 状态的改变 - 执行
console.log(3),输出 3 - 父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列
- 微任务队列中有
resolve(100)的任务等待执行,执行到这里的时候,会触发promise.then中的回调函数 - 该回调函数被激活以后,会将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程
- foo 协程激活之后,会把刚才的 value 值赋给变量 a,然后 foo 协程继续执行后续语句,执行完成之后,将控制权归还给父协程
执行结果:
0
1
3
100
2
5. 总结
5.1 核心要点
- 消息队列:存放要执行的任务,遵循先进先出的原则
- 事件循环:主线程不断从消息队列中取出任务并执行
- 宏任务:消息队列中的任务,时间粒度比较大
- 微任务:在当前宏任务结束之前执行,时间粒度比较小
- Promise:使用微任务实现,解决了回调地狱的问题
- async/await:基于生成器和协程实现,使用同步的方式编写异步代码
5.2 执行顺序
执行顺序规则:
- 执行当前宏任务
- 执行当前宏任务中的所有微任务
- 执行下一个宏任务
示例:
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')
// 输出: 1, 4, 3, 26. 参考资源
- 浏览器工作原理与实践 - 事件循环 — 李兵老师的系列文章
- WHATWG HTML 规范 - 事件循环 — HTML 规范
- MDN - Event Loop — MDN 文档