在学习事件循序的过程中,能够从网络上轻易的找到许多优秀的文章,它们从源码的角度也好,从面试题的角度也好,做到了详尽和全面的剖析,而这一篇文章想尝试着从另一个角度,从眼见为实的角度来讲一讲事件循环,之所以称之为眼见为实,是因为我们将借助Chrome 中的 Perfomance 这一双眼睛来一窥究竟
在正式开始之前,让我们回忆一下,我们是怎么回答「什么是事件循环」这个问题的?
- 检查执行栈,如果为空,从宏任务队列中出队一个并移动到执行栈
- 执行并清空执行栈
- 执行过程中如果遇到宏任务微任务就将其添加到各自的任务队列
- 执行栈执行完毕后,继续执行并清空微任务队列
- GUI 渲染
- 回到步骤 1
如果是我,我想我会给出以上的回答,当然,以上的回答在很多地方都能看得到,在面试中也可能是能够被接受的,不过在接下来,让我们本着实事求是的原则,好奇心,以及眼见为实的观察,对以上的回答做一些细微的修正和尽可能详细的补充
首先,我想阐述一下此时我心中的疑惑:
以下将 requestAnimationFrame 简称为 RAF
- RAF 的回调会发生在事件循环的哪个地方?
- RAF 是宏任务还是微任务?
- 在定义中 RAF 的回调会发生在「下一次渲染页面之前」,我们能理解为GUI 渲染前吗?
- 在定义中 RAF 的回调会以大概 16ms 为一个周期执行,因为它要保持与屏幕的刷新周期一致,可是当每次事件循环执行栈中的任务执行的很快的时候「假设执行完一个事件循环只要 2ms」,按照以上的说法,GUI 的渲染会每 2ms 就发生一次,那 RAF 的回调还会在每次GUI 的渲染前被调用吗?
1. requestAnimationFrame
案例
假设,我们有以下代码:
<html>
<body>
<div id='luci' style='width: 100px; height: 100px;'></div>
<div>
<button>click</button>
</div>
<script>
let count = 0;
const div = document.querySelector('#luci')
function getRandomColor(){
return '#'+('00000'+ (Math.random()*0x1000000<<0).toString(16)).substr(-6);
}
function startRequestAnimationFrame() {
console.log('requestAnimationFrame: ', count)
if(count < 200) {
requestAnimationFrame(startRequestAnimationFrame)
}
}
function startRequestIdleCallback() {
requestIdleCallback(function someHeavyComputation(deadline) {
console.log('requestIdleCallback: ', count)
while(deadline.timeRemaining() > 0) {
// console.log('do something');
}
if(count < 200) {
requestIdleCallback(someHeavyComputation);
}
});
}
function timeoutFn() {
console.log('timeoutFn: ', count)
div.style.backgroundColor = getRandomColor()
count++
if (count < 200) {
setTimeout(timeoutFn, 0)
}
}
document.querySelector('button').addEventListener('click', () => {
count = 0
startRequestAnimationFrame()
// startRequestIdleCallback()
timeoutFn()
})
</script>
</body>
</html>
让我们试一下,当点击「click」,在控制台中我们可能看到如下打印:

注意,当我们每一次看到「timeoutFn」被打印出来,都意味着开始了一轮新的事件循环,跳过猜测的步骤,我们一起去Performance 眼见为实的看看,真相到底如何:

为了看起来比较方便,我们在火焰图上大致的标记一下,可以明显的看到,在经过了点击事件之后,发生了一次requestCallbackCB -> 连着5次 setTimeoutCB -> 一次 requestCallbackCB -> ... ,显然,这与控制台打印出的内容是符合的
接下来,我们放大火焰图,试着详细的描述一下整个过程都发生了什么
| 开始时间 | 线程 | 描述 |
|---|---|---|
| 1344.5 ms | JS 线程 | 触发点击事件 |
| 1358.8 ms | JS 线程 | 执行 startRequestAnimationFrame 函数 |
| 1359.0 ms | JS 线程 | 执行 startTimeout 函数 |
| ---------- | JS 线程挂起,唤起 GUI 线程 | |
| 1359.2 ms | GUI 线程 | 触发 Recalculate Style 事件,重计算元素样式 |
| 1359.4 ms | GUI 线程 | 触发 Animation Frame Fired 事件,开始执行所有之前的requestAnimationFrame 回调 |
| 1359.6 ms | GUI 线程 | 执行 requestAnimationFrameCB 函数 |
| 1361.6 ms | GUI 线程 | 触发 Update Layer Tree 事件,更新 RenderLayerTree |
| 1361.6 ms | GUI 线程 | 触发 Paint 事件,重绘 |
| 1362.1 ms | GUI 线程 | 触发 Composite Layers 事件,合并图层 |
| ---------- | GUI 线程挂起,唤起 JS 线程,理论上开始了下一轮的事件循环 ,注意此时上一「帧」的渲染还没有结束 | |
| 1362.4 ms | JS 线程 | 执行 setTimeoutCB 函数 |
| 1362.5 ms | Rasterizer 线程 | 栅格化线程合成点阵图 |
| 1364.0 ms | JS 线程 | 执行 setTimeoutCB 函数 |
| 1365.6 ms | JS 线程 | 执行 setTimeoutCB 函数 |
| 1367.0 ms | JS 线程 | 执行 setTimeoutCB 函数 |
| 1372.3 ms | JS 线程 | 执行 setTimeoutCB 函数 |
| ---------- | JS 线程挂起,唤起 GUI 线程 | |
| 1374.8 ms | GUI 线程 | 触发 Recalculate Style 事件,重计算元素样式 |
| 1374.9 ms | GUI 线程 | 触发 Animation Frame Fired 事件,开始执行所有之前的requestAnimationFrame 回调 |
| 1375.1 ms | GUI 线程 | 执行 requestAnimationFrameCB 函数 |
| 1375.2 ms | GUI 线程 | 触发 Update Layer Tree 事件,更新 RenderLayerTree |
| 1375.3 ms | GUI 线程 | 触发 Paint 事件,重绘 |
| 1375.4 ms | GUI 线程 | 触发 Composite Layers 事件,合并图层 |
| 1375.5 ms | Rasterizer 线程 | 栅格化线程合成点阵图 |
通过以上的详细流程,我们显然可以发现:
- 事件循环并不是每一次都会在JS 线程执行完之后唤起GUI 线程
- requestAnimationFrame 的回调出现在哪似乎只和GUI 线程的执行有关
- requestAnimationFrame 表现得既不像宏任务,也不像微任务
- GUI 线程的唤起大概间隔 15ms 以上,与刷新周期60Hz 相对应
总结
是时候让我们补充一下「什么是事件循环?」这个问题的答案,当然了,要想真正的理解事件循环,只能从源码的角度入手,目前,我们只是让答案看起来稍微更加正确一些,我想我们可以这样比较简单的理解有 requestAnimationFrame 参与的事件循环:

写在最后的话 /≧▽≦)/
纵然以「Perfomance」去了解事件循环是一件方便记忆,容易理解的方法,可是我依然认为,真正的眼见为实,是眼见「源代码」为实,事件循环远比它表现的更为复杂,不过这不是我们停止学习它的借口,当然,作为一名非专业的程序员,我的认知也一定有浅薄的地方,如果在阅读的时候发现了错误或有疑问的地方,请务必在下面留言提出,避免误导了更多的人