眼见为实的EventLoop事件循环「1. requestAnimationFrame」

2,287 阅读5分钟

在学习事件循序的过程中,能够从网络上轻易的找到许多优秀的文章,它们从源码的角度也好,从面试题的角度也好,做到了详尽和全面的剖析,而这一篇文章想尝试着从另一个角度,从眼见为实的角度来讲一讲事件循环,之所以称之为眼见为实,是因为我们将借助Chrome 中的 Perfomance 这一双眼睛来一窥究竟

在正式开始之前,让我们回忆一下,我们是怎么回答「什么是事件循环」这个问题的?

  1. 检查执行栈,如果为空,从宏任务队列中出队一个并移动到执行栈
  2. 执行并清空执行栈
  3. 执行过程中如果遇到宏任务微任务就将其添加到各自的任务队列
  4. 执行栈执行完毕后,继续执行并清空微任务队列
  5. GUI 渲染
  6. 回到步骤 1

如果是我,我想我会给出以上的回答,当然,以上的回答在很多地方都能看得到,在面试中也可能是能够被接受的,不过在接下来,让我们本着实事求是的原则,好奇心,以及眼见为实的观察,对以上的回答做一些细微的修正和尽可能详细的补充

首先,我想阐述一下此时我心中的疑惑:

以下将 requestAnimationFrame 简称为 RAF

  1. RAF 的回调会发生在事件循环的哪个地方?
  2. RAF 是宏任务还是微任务?
  3. 在定义中 RAF 的回调会发生在「下一次渲染页面之前」,我们能理解为GUI 渲染前吗?
  4. 在定义中 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 线程 栅格化线程合成点阵图

通过以上的详细流程,我们显然可以发现:

  1. 事件循环并不是每一次都会在JS 线程执行完之后唤起GUI 线程
  2. requestAnimationFrame 的回调出现在哪似乎只和GUI 线程的执行有关
  3. requestAnimationFrame 表现得既不像宏任务,也不像微任务
  4. GUI 线程的唤起大概间隔 15ms 以上,与刷新周期60Hz 相对应

总结

是时候让我们补充一下「什么是事件循环?」这个问题的答案,当然了,要想真正的理解事件循环,只能从源码的角度入手,目前,我们只是让答案看起来稍微更加正确一些,我想我们可以这样比较简单的理解有 requestAnimationFrame 参与的事件循环:

写在最后的话 /≧▽≦)/

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