由一道JS题目引发的事件循环和浏览器渲染机制的思考

1,103 阅读7分钟

题目

  1. 运行以下代码body颜色会变绿色吗?
document.body.style.backgroundColor = 'green'
while(true) {}
  1. 运行以下代码body颜色会变红色吗?
document.body.style.backgroundColor = 'red'
setTimeout(() => {
    while(true) {}
})
  1. 运行以下代码body颜色会变黄吗?
document.body.style.backgroundColor = 'yellow'
Promise.resolve().then(() => {
    while(true) {}
})

初步使用事件循环解读

这道题目乍一看,问的是事件循环啊,没错。让我们先来捋一下事件循环的相关原理:

avatar

相关event loop概念

  • 宏任务(macroTask): script标签,setTimeOut,setInterval,I/O操作,UI rendering等

  • 微任务(microTask):Promise.then,MutationObserver(HTML5新特性)等

event loop过程

  1. 调用栈为空,从macroTask队列里取出script标签进入JS执行栈;
  2. 执行调用栈中的JS代码,当遇到宏任务时,把对应宏任务放入宏任务的macroTask队列,当遇到微任务时,把对应微任务放入微任务的microTask队列中,直至执行栈清空;
  3. 取microTask中的一条微任务放至执行栈中执行,执行方法同上第2步;
  4. 重复执行第3步,直至microTask队列被清空;
  5. 取macroTask队列中的一条宏任务放至执行栈中执行,重复以上步骤。

使用event loop解读题目

把事件循环的原理梳理完了,长舒一口气。看到这里,大家心里应该有了答案,那就是第一个是不会变色,因为while陷入了死循环,所以一直在同步代码里出不来,也就走不到UI渲染这一步;第二步是会变色,因为UI rendering是宏任务,直接先执行了渲染;第三题是不会变色,因为程序走到微任务那里陷入了死循环。上周和其他小伙伴们讨论这个问题的时候,有个小伙伴立马给出了这样的答案。
当然这个答案是对的,但是,你以为这么简单就结束了么,显然没有~

题目延伸

如果我现在把题目改成这样:

setTimeout(() => {
    while(true) {}
})
document.body.style.backgroundColor = 'red'

现在再来回答一下这个问题,还会变色么。
网上铺天盖地的关于event loop的文章很多,但是有些总结的并没有那么全。如果只了解了上面的原理来看这题的话,答案应该是不会,因为setTimout先入的栈,然后就陷入了死循环。然鹅,事实确是依然会变色。

更完整的event Loop过程

之前自己在学习事件循环的时候,发现网上大部分的文章都是只写了上面几个过程,却忽视了一个很重要的过程,就是UI rendering这个特殊的宏任务。更权威的event loop请参考HTML标准规范之event loop(适合英文较好的人阅读,因为真的有点难懂)。下面是笔者根据HTML规范自己整理的一个较为完善的事件循环过程:

  1. 调用栈为空,从macroTask队列里取出script标签进入JS执行栈;
  2. 执行调用栈中的JS代码,当遇到宏任务时,把对应宏任务放入宏任务的macroTask队列,当遇到微任务时,把对应微任务放入微任务的microTask队列中,直至执行栈清空;
  3. 取microTask中的一条微任务放至执行栈中执行,执行方法同上第2步;
  4. 重复执行第3步,直至microTask队列被清空;
  5. 执行UI rendering,更新界面;
  6. 取macroTask队列中的一条宏任务放至执行栈中执行,重复以上步骤,进入下一次事件循环过程。

其实,就是多加了一步UI渲染,注意这个顺序一定是在执行完所有微任务之后,执行其他宏任务之前。这样也就解释了为什么题目变了以后依然会变色的问题。讲到这里,才算把完整的事件循环机制说完了。注意,这里只说了在浏览器里的事件循环,如果是node环境下的,会有一定的区别。

再谈浏览器渲染原理

在和小伙伴讨论的过程中,发现一些人对渲染这块同样存在着一定的误解,有些人会认为,执行到document.body.style.backgroundColor = 'red'这一步浏览器直接就渲染了,渲染完了再去执行执行栈的其他代码。但事实是,这一步是JS取出这个任务执行出栈后,把控制权交给了渲染引擎,同时JS引擎被搁置。因为JS引擎和渲染引擎是完全互斥的两个引擎。 来看下浏览器的内部工作机制:
以Chrome浏览器为例说明:浏览器内核包括JS引擎和渲染引擎。渲染引擎又包括了HTML解释器,CSS解释器,图层布局计算模块,视图绘制模块等相关零件。完整的浏览器渲染过程如下图所示:

avatar

  • HTML解释器将HTML文档解析成DOM tree;
  • CSS解释器将CSS解析成CSSOM tree;
  • DOM tree 和CSSOM tree结合成render Tree;
  • 图层布局计算模块根据render Tree计算元素在页面的位置大小进行布局;
  • 视图绘制模块把每一个页面图层转换成对应的像素;
  • 浏览器整合各个图层,将数据由CPU输出给GPU最终绘制在屏幕上。

完整解读过程

有了以上的了解,再来解读下上面代码运行的完整过程。仍然以上面延伸的题目为例:

setTimeout(() => {
    while(true) {}
})
document.body.style.backgroundColor = 'red'
  1. 首先上面这段代码进入JS执行栈中;
  2. 执行到setTimeout,将回调函数(即while循环)推入对应的宏任务队列中;
  3. 执行到document.body.style.backgroundColor = 'red',将UI rendering推入对应的宏任务队列中;
  4. 无微任务队列,则进行UI rendering,控制权由JS引擎交给渲染引擎;
  5. 渲染引擎根据上面讲的渲染过程将页面颜色渲染成红色;
  6. 取出来下一条宏任务,即while循环放入JS调用栈中执行,进入死循环。

反转:使用代码触发事件处理程序在事件循环中的执行

本以为梳理完上面的过程大功告成,事情却发生了反转。有个小伙伴对我上面的内容产生了怀疑,并呈现了一段代码如下:

var startTime = new Date().getTime()
document.getElementById('btn').addEventListener('click', function() {
    alert('click happen')
    while (new Date().getTime() <= startTime + 2000) {
}
}, false)

document.body.style.backgroundColor = 'blue'
document.getElementById('btn').click()

这位小伙伴说,事件监听程序中的代码是宏任务,document.body.style.backgroundColor = 'blue'属于UI渲染,按照我上面的理论应该是先渲染成蓝色再执行alert,然鹅事实却刚好相反。
我一下子傻眼了,心想难道我上面的结论真的有问题?如果有问题的话,那么到底UI渲染应该是在哪一步呢。抱着这样的问题,去翻墙看了国外比较权威的event loop讲解视频。视频作者在讲event loop的过程中给出了一个看似简单的题目,题目如下:

document.getElementById('btn').addEventListener('click', function() {
    new Promise(function(resolve, reject) {
        console.log('promise1')
        resolve()
    }).then(function() {
        console.log('promise then1')
    })
})
document.getElementById('btn').addEventListener('click', function() {
    new Promise(function(resolve, reject) {
        console.log('promise2')
        resolve()
    }).then(function() {
        console.log('promise then2')
    })
})
问:点击btn按钮,会打印出什么

如果在上面的代码中再加一行代码document.getElementById('btn').click(),又会打印什么。结果是第一种情况先后打印:promise1,promise then1,promise2,promise then2;而第二种情况先后打印:promise1,promise2,promise then1 ,promise then2。视频讲解者的解释是,当使用代码触发click事件时,两个事件处理函数会被同时触发。
当时看的时候没太明白,现在想想,其实就是使用代码触发事件是同步代码的方式处理,而直接手动触发click事件则是使用异步的方式处理。至于为什么是这样子,这就是浏览器的底层机制决定的了,感兴趣的可以自己翻翻源码啥的。这样也就解释了上一段代码运行的原因,并不是异步的宏任务阻塞了渲染,而是同步代码阻塞了渲染。

对上述event loop过程的补充

  • 大体过程如上所述,只不过并不是每次event loop的时候都会去执行渲染,这和浏览器的频率有关系,比如谷歌浏览器大概约16.4ms一次;
  • 做动画不要使用setTimeout和setInterval,应该使用requestAnimationFrame。一方面因为requestAnimationFrame和浏览器渲染频率同步而不会造成不必要帧的浪费,一方面是由于使用setTimeout会导致渲染被推至下一个事件循环里。

结束语

在平常的工作和学习中,还是要多思考,有时候多想想会悟出来很多东西。当然了,以上有些是我个人的理解,有不对和描述不恰当的地方欢迎随时指出来。