浏览器事件循环与渲染机制

2,578 阅读8分钟

前言

最近想深入学习下浏览器渲染相关的问题,于是去拜读了下相关的文档。因为其中涉及到事件循环,所以一并写了一下,这里把自己的理解分享给大家。因为博主只是前端萌新,英语阅读水平也有限,如有错误,敬请斧正。

事件循环

值得一提的是,很多人把事件循环叫做javascript事件循环,但是事件循环并不是javascript engine比如v8之类的提供,而是由runtime提供的,比如浏览器,node.js,下面写的也是以浏览器为参考的。整个循环还是很复杂的,这里为了服务中心只写了一些重要的点。

定义

事件循环: 为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用事件循环。每个代理都有一个关联的事件循环,该循环对于该代理是唯一的。

task queue(s): 任务队列,可能有一个或多个,里面是一些task(一般称为宏任务)。

注意: task queues虽然带着queues但其实是sets(集合),而不是queues(队列),因为事件循环处理模型的步骤 1 是从所选队列中抓取第一个可运行的queue,而不一定是第一个queue,但queue的task是顺序执行的。

microtask queue: 微任务队列,该队列是最初为空的微任务队列。通过task创建的microtask会被推入到里面。

rendering opportunity: 决定是否要渲染的一个变量。它考虑了硬件刷新率限制,出于性能原因的用户代理页面是否可见等等。渲染机会应该定期发生。(即每次渲染时间间隔应该大体相同,比如16.6ms或33.3ms,而不应该出现丢帧,比如多次16.6ms 突然33.3ms)

流程

  • 首先看task queue(s)是非存在有可执行的task的task queue,有就取出一个task queue执行里面的task,如果没有,跳过此步到microtask queue的执行。

  • 接着看microtask queue是否为空,非空就执行,中途如果产生了新的microtask,那么也一并执行。

  • 更新 now 这个值为 current high resolution time。(猜测在这里更新了performance.now的返回值)

  • 通过定义中的描述设置rendering opportunity

  • 如果 rendering opportunity存在为true 并且下面条件至少成立一个 1.浏览器判断更新渲染会带来视觉上的改变 2.map of animation frame callbacks 不为空(可以通过requestAnimationFrame添加) 则进行下面几步,否则跳过本步骤

    • 如果窗口的大小发生了变化,执行监听的 resize 方法。
    • 如果页面发生了滚动,执行 scroll 方法。
    • 执行requestAnimationFrame的回调,并传入now作为时间戳。
    • 执行 IntersectionObserver 的回调,并传入now作为时间戳。
    • 重新渲染绘制用户界面。
  • 判断是否调用requestIdleCallback回调,一般在浏览器空闲时会调用。

Task源与Microtask源

  • Task源

    • 整个JS文件可以看做第一个宏任务。
    • DOM 操作
    • 用户交互,例如键盘或鼠标输入。
    • 网络任务
    • 历史记录遍历,如调用 history.back()和类似的 API。
  • Microtask源

    • process.nextTick
    • Promise
    • Async/Await
    • MutationObserver

浏览器渲染

如果上面判定确实要渲染了,那么我们介绍下渲染的步骤。

大体步骤

  • generic DOM Tree 生成DOM树

    • 通过词法分析文法分析等,生成一棵DOM树。
    • HTML的文法不同于大部分编程语言,它不是二型文法(上下文无关文法),而是一型文法(上下文有关的),因为在产生式中左部并非一定是单个非终结符,比如两个Form嵌套时,解析出来只有一层,说明存在一个和Form相关的产生式,它的左部至少两个符号,这样与之前状态有关了,也就是上下文有关。
  • calculate style 计算样式

    • 格式化:这些文本浏览器并不懂,因此要把各种CSS生成一个对象,这个对象通过document.styleSheets来查看。

    • 标准化:将rem,em,vw等等转化为px 把颜色转化为#xxxxxx等

    • 继承和重叠:最后通过继承和重叠,计算最后样式属性,继承就是一部分属性可能从父元素继承应用到自身,下面介绍下所谓的重叠

      • 先找到某个元素可以匹配的所有规则

      • 按显式权重和来源排序

        从大到小依次为:读者的!impotant声明 > 作者的!important声明 > 作者的常规声明 > 读者的常规声明 > 用户代理默认的样式声明

      • 按特指度排序,特指度形如 0 0 0 0,从高位开始比,遇到不同的较大者胜出

        行内 1 0 0 0 id选择器每个 0 1 0 0 类选择器,属性选择器,伪类选择器每个 0 0 1 0 元素选择器,伪元素选择器每个 0 0 0 1 *是 0 0 0 0 继承来的则是无特指度

      • 如果上面还不能确定,那么靠后出现的声明胜出

  • update layout tree 更新布局树

    • 遍历生成的 DOM 树节点,并把他们添加到布局树。
    • 计算布局树节点的坐标位置。
  • updata layer tree 更新图层树

    是的,有了位置,样式和DOM后还不能渲染,因为我们不知道图层间的顺序,因此还要构建图层树

  • paint 绘制

​ 这一步就是把页面的像素信息绘制出来

  • composite layers 合成图层

    把各个图层进行合成

  • rasterize paint 栅格化图形

    用栅格化线程池栅格化上述数据为位图

  • display 展示

    将位图数据信息传给显卡,显示到显示器上

reflow和repaint

那么我们样式改变以后,画面就会改变,但是它们究竟会经历上面那些流程呢?

reflow 回流(重排)

当DOM树中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。

会导致回流的操作:

  • 页面首次渲染
  • 浏览器窗口大小发生改变
  • 元素尺寸或位置发生改变
  • 元素内容变化(文字数量或图片大小等等)
  • 元素字体大小变化
  • 添加或者删除可见的DOM元素
  • 激活CSS伪类(例如::hover
  • 查询某些属性或调用某些方法

一些常用且会导致回流的属性和方法:

  • client族:clientWidthclientHeightclientTopclientLeft
  • offset族:offsetWidthoffsetHeightoffsetTopoffsetLeft
  • scroll族:scrollWidthscrollHeightscrollTopscrollLeft
  • scrollIntoView()scrollIntoViewIfNeeded()
  • getComputedStyle()
  • getBoundingClientRect()
  • scrollTo()

repaint 重绘

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:colorbackground-colorvisibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

比较

reflow的代价要比repaint的代价要大,因为reflow显然要进行上面所有步骤,而repaint因为位置没有变,自然会跳过 update layout tree这个步骤,因此如果一定要进行reflow操作时,有必要情况下要进行节流操作。

function throttle(func, wait) {
    let previous = 0;
    return function () {
        const now = Date.now();
        const context = this;
        const args = arguments;
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    }
}

下面我们简单弄一个可以动的画面来看看是不是如上面的步骤一样

上面performance展示的,通过margin移动
<style>
    #move {
        width: 20px;
        height: 20px;
        background-color: aqua;
    }
</style>
<div id="move"></div>
<script>
    let move = document.querySelector("#move");
    let i = 0;
    setInterval(() => {
        move.style['margin-top'] = i++ * 0.1 + 'px';
    }, 10)
</script>

下面performance展示的,通过tranform移动
<style>
    #move {
        width: 20px;
        height: 20px;
        background-color: aqua;
    }
</style>
<div id="move"></div>
<script>
    let move = document.querySelector("#move");
    let i = 0;
    setInterval(() => {
        move.style['transform'] = `translateY(${i++*0.1}px)`;
    }, 1)
</script>

image-20211217205232911.png

image-20211217215916237.png

动画.gif

可以看到repaint并没有进行layout这个过程。

如何避免

CSS

  • 避免使用table布局。
  • 尽可能在DOM树的最末端改变class
  • 避免设置多层内联样式。
  • 将动画效果应用到position属性为absolutefixed的元素上。
  • 避免使用CSS表达式(例如:calc())。

JavaScript

  • 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次性更改class属性。
  • 避免频繁操作DOM,创建一个documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中。
  • 也可以先为元素设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。
  • 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。
  • 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。

requestAnimationFrame/requestIdleCallback

具体调用时机,参考事件循环。

requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

实际上,虽然它的名字带有animation但是它并不一定进行与动画相关的操作,我们也可以用它进行节流,那么它的回调函数就可以保证每帧最多只会进行一次。

function throttle(func, wait) {
    let flag = false;
    return function() {
        if (flag) return;
        flag = true;
        const context = this;
        const args = arguments;
        requestAnimationFrame(() => {
            flag = false;
            func.apply(context, args);
        })
    }
}

requestIdleCallback

window.requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

参考

HTML Standard (whatwg.org):html.spec.whatwg.org/multipage/w…