本文主要讲的是 JS 事件循环一些不会注意的细节,以及对页面渲染的影响。
事件循环概述
- 从主执行栈出发,执行栈执行结束。
- 执行栈结束后切换到微任务队列,分两种情况:
2-1. 有任务,取出队头的任务放入执行栈,再从 1 开始。
2-2. 无任务,切换到宏任务队列。 - 切换到宏任务队列,宏任务队列和微任务队列操作一样。
- 宏任务清空之后继续回到 1。
常见的宏任务和微任务
宏任务:setTimeout, setInterval, script 代码块
微任务:Promise, queueMicrotask, async/await, Mutation Observer
代码示例
简单示例
js
setTimeout(() => console.log(1), 0)
Promise.resolve().then(() => console.log(2))
Promise.resolve().then(() => console.log(3))
console.log(4)
console.log(5)
点击展开/折叠答案
打印顺序:4,5,2,3,1
答案解析:
- 初始状态下 微任务队列 = [], 宏任务队列 = []
- 上面代码块全部放入主执行栈
- 执行到第一行,此时 宏任务队列 = [() => cosole.log(1)]
- 第二行,遇到微任务,加入微任务队列,此时 微任务队列 = [() => console.log(2)]
- 第三行,此时 微任务队列 = [() => console.log(2), () => console.log(3)]
- 第四行,同步任务,直接打印 4
- 第五行,同步任务,直接打印 5
- 主执行栈执行结束,切换到微任务队列,发现有任务,取出队头,此时 微任务队列 = [() => console.log(3)]
- 执行刚才取出的任务,打印 2
- 主执行栈执行结束,继续切换到微任务队列,取出队头,此时微任务队列 = []
- 打印 3
- 主执行栈执行结束,继续切换到微任务队列,发现没有任务,切换到宏任务队列
- 取出宏任务,此时宏任务队列为空
- 执行 console.log(1),结束
script 代码块示例
html
<script>
Promise.resolve().then(() => console.log(1))
</script>
<script>
console.log(2)
</script>
点击展开/折叠答案
打印顺序:1,2
答案解析:
按理来说我们打印顺序应该是 2,1,也就是先执行同步任务,再执行异步任务,但是此时却相反,因为我们的 script 整个代码块也会被认为是一个宏任务。
- 页面加载完毕,此时 宏任务队列 = [script代码块1,script代码块2],微任务队列 = []
- 执行栈为空,切换到微任务队列
- 微任务队列也为空,切换到宏任务队列
- 发现有任务,取出队头,此时 宏任务队列 = [script代码块2]
- 主执行栈执行任务,产生了微任务,此时 微任务队列 = [() => console.log(1)]
- 主执行栈执行结束,切换到微任务,发现有任务,取出任务执行
- 打印 1
- 微任务队列为空,宏任务队列有任务,取出任务执行
- 打印 2
复杂示例
js
console.log(1)
setTimeout(() => {
console.log(2)
Promise.resolve().then(() => {
console.log(3)
})
}, 0)
Promise.resolve().then(() => {
console.log(4)
Promise.resolve().then(() => {
console.log(5)
setTimeout(() => console.log(7), 0)
})
})
setTimeout(() => console.log(8), 0)
console.log(9)
点击展开/折叠答案
打印顺序:1,9,4,5,2,3,8,7
这个只是代码多了,和上面示例逻辑一样
事件循环与渲染的关系
浏览器的 js 线程和渲染线程是交叉进行的,我们如果能清楚了解两者之间的关系,就可以写出更高性能的代码。
渲染是同步还是异步?
请问页面颜色是立即改变,还是 5 秒后
js
function wait (num = 5) {
console.log('wait start')
num *= 1000
const start = performance.now()
while (performance.now() - start < num) {
}
console.log('wait end')
}
document.body.style.background = 'pink'
wait()
点击展开/折叠答案
答案解析:
dom 的操作是同步的,但渲染是异步的,所以会在同步任务执行完毕之后
渲染具体在哪个时机?
上面我们知道了渲染是异步的,下面我们找下渲染的具体时机
js
function wait (num = 5) {
console.log('wait start')
num *= 1000
const start = performance.now()
while (performance.now() - start < num) {
}
console.log('wait end')
}
document.body.style.background = 'pink'
Promise.resolve().then(() => {
console.log(1)
wait(2)
})
setTimeout(() => {
wait(2)
console.log(2)
}, 0)
console.log(3)
点击展开/折叠答案
答案:
- 打印 3
- 打印 1
- 空转 2s
- 浏览器渲染
- 空转 2s
- 打印 2
答案解析:
我们可以得出浏览器渲染是在微任务清空之后,宏任务执行之前
requestAnimationFrame 探索
我们之前做动画会用到 requestAnimationFrame 这个 api,官方的定义是他在渲染之前执行,那么我们探索一下他是否是微任务
js
function wait (num = 5) {
console.log('wait start')
num *= 1000
const start = performance.now()
while (performance.now() - start < num) {
}
console.log('wait end')
}
document.body.style.background = 'pink'
setTimeout(() => {
wait(2)
console.log(1)
}, 0)
requestAnimationFrame(() => {
wait(2)
console.log(2)
})
Promise.resolve().then(() => {
console.log(3)
Promise.resolve().then(() => {
wait(2)
console.log(4)
})
})
Promise.resolve().then(() => {
console.log(5)
})
console.log(6)
点击展开/折叠答案
答案:
- 打印 6
- 打印 3
- 打印 5
- 空转 2s
- 打印 4
- 空转 2s
- 打印 2
- 浏览器渲染
- 空转 2s
- 打印 1
答案解析:
如上所见,requestAnimationFrame 回调函数的注册时间迟于 setTimeout,早于 Promise,但是他执行时机却是在微任务队列清空之后,渲染之前,也就在宏任务执行之前,所以 requestAnimationFrame 严格来说不从属于宏任务和微任务,是存在与他们之间,渲染之前。
深入 requestAnimationFrame
我们调整下上面的代码,深究一下 requestAnimationFrame
js
setTimeout(() => {
console.log(1)
}, 0)
requestAnimationFrame(() => {
console.log(2)
Promise.resolve().then(() => {
console.log(3)
})
})
requestAnimationFrame(() => {
console.log(4)
})
Promise.resolve().then(() => {
console.log(5)
Promise.resolve().then(() => {
console.log(6)
})
})
Promise.resolve().then(() => {
console.log(7)
})
console.log(8)
点击展开/折叠答案
答案:
- 打印 8
- 打印 5
- 打印 7
- 打印 6
- 打印 2
- 打印 3
- 打印 4
- 打印 1
答案解析:
我们发现打印 2 和 4,两个 requestAnimationFrame 回调之间打印了 3,可以得出 requestAnimationFrame 也是在维护一个队列,和微任务,宏任务执行机制一样,也是先取出队头任务,放入执行栈,然后依次从微任务队列开始查找,再继续往后。
我们根据上面示例丰富我们的流程图
事件循环与人机交互的关系
人机交互,也叫 UI event,指的就是我们平常对浏览器的操作,比如说我们的点击事件,滚轮事件。
UI event 是宏任务还是微任务?
当我们回车输入以下代码,在第一次空转的时候点击页面,看下最终的打印结果
js
function wait (num = 5) {
console.log('wait start')
num *= 1000
const start = performance.now()
while (performance.now() - start < num) {
}
console.log('wait end')
}
document.body.addEventListener('click', () => console.log('click'))
document.body.style.background = 'pink'
console.log(1)
setTimeout(() => console.log(2), 0)
requestAnimationFrame(() => {
console.log(3)
wait(2)
})
Promise.resolve().then(() => console.log(4))
console.log(5)
wait()
Promise.resolve().then(() => console.log(6))
点击展开/折叠答案
答案:
- 打印 1
- 打印 5
- 空转 5s
- 打印 4
- 打印 6
- 打印 click
- 打印 3
- 空转 2s
- 页面渲染
- 打印 2
答案解析:
如上可知,我们在空转时点击了页面,是在执行 Promise.resolve().then(() => console.log(6)) 之前,但是最终执行是在打印 6 之后,所以我们可以得出,UI event 也是在微任务清空之后,requestAnimationFrame 执行之前。
UI event 是否也在维护一个队列?
对于 UI event,拿点击事件举例,同一个元素可以注册多个事件,我们修改下上面的代码,当我们回车输入以下代码,在第一次空转的时候点击页面
js
function wait (num = 5) {
console.log('wait start')
num *= 1000
const start = performance.now()
while (performance.now() - start < num) {
}
console.log('wait end')
}
document.body.addEventListener('click', () => {
console.log('click 1')
Promise.resolve().then(() => console.log(0))
})
document.body.addEventListener('click', () => {
console.log('click 2')
})
document.body.style.background = 'pink'
console.log(1)
setTimeout(() => console.log(2), 0)
requestAnimationFrame(() => {
console.log(3)
wait(2)
})
Promise.resolve().then(() => console.log(4))
console.log(5)
wait()
Promise.resolve().then(() => console.log(6))
点击展开/折叠答案
答案:
- 打印 1
- 打印 5
- 空转 5s
- 打印 4
- 打印 6
- 打印 click 1
- 打印 0
- 打印 click 2
- 打印 3
- 空转 2s
- 页面渲染
- 打印 2
答案解析:
首先根据上一个示例,我们得出 UI event 是在微任务队列清空之后,requestAnimationFrame 之前,现在我们对同一个点击注册了两个回调,第一个回调执行过程中会产生微任务,而这个微任务的执行是在第二个回调执行之前。
所以我们得出 UI event 也是维护了一个队列,当我们 click 1 回调放入执行栈执行,过程中产生了新的微任务,此时执行栈执行结束,继续从微任务队列找任务,微任务队列清空之后才到了 click 2。
最后
最后再丰富下我们的流程图:
欢迎大家讨论,发表意见,文中有错误的地方也欢迎指出来,祝大家都能掌握事件循环,拿到好的 offer,写出高性能的代码。