浏览器渲染流程解析

2,417 阅读9分钟

前言

大家可能经常会听到 css 动画比 js动画性能更好这样的论断,或者是“硬件加速”,“层提升” 这样的字眼;要了解这些内容就需要对浏览器的渲染流程有个大致的了解,本文就是我个人对这些内容的一个总结梳理

需要注意的是:

  1. 本文仅个人学习总结梳理,如有错漏,望指正
  2. 本文以谷歌浏览器Blink内核为例,参考内容链接大多需要科学上网
  3. 随着谷歌浏览器的更新迭代,有些渲染流程或对象名词可能发生变化(如, RenderObject 变成了 LayoutObject,RenderLayer 变成了 PaintLayer),查看相关文档时需要注意文档的时间

渲染流程

先来看下blink的一个大致渲染流程,图源谷歌的一份共享幻灯片 Life of a Pixel ,它比较全面的阐述了浏览的渲染流程,非常值得一看,我们就借这张图来梳理一遍

渲染流程.png

图源 Life of a Pixel

图中分为 渲染进程(renderer process) 和 GPU进程(GPU process) 两部分,其中渲染进程包含 主线程(main) 和 合成线程(impl)

我们可以借助谷歌开发工具的 performance 标签查看是否执行了某些渲染流程步骤,我这里写了一个简单的html可以作为对比

<!DOCTYPE html>
<html lang="zh-cn"><head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>transform demo</title>
</head>
<style>
  #normal {
    display: grid;
    place-items: end;
    width: 150px;
    height: 150px;
    background-color: pink;
  }
​
  #compositor {
    margin-top: 20px;
    display: grid;
    place-items: end;
    width: 150px;
    height: 150px;
    background-color: palegoldenrod;
  }
​
  #stacking {
    display: grid;
    place-items: end;
    width: 150px;
    height: 150px;
    position: absolute;
    z-index: -1;
    top: 240px;
    background-color: skyblue;
  }
​
  .active {
    animation: transformAni 2s both;
  }
​
  @keyframes transformAni {
    to {
      transform: translate(200px);
    }
  }
</style><body>
  <div id="compositor">Compositor Layers</div>
  <div style="display: flex; margin-top: 20px;">
    <div id="cssBtn" style="background-color:  palegoldenrod; width: 200px;">add css animation</div>
  </div>
​
  <div id="stacking">The Stacking Context</div>
  <div style="display: flex; margin-top: 220px;">
    <div id="jsBtn" style="background-color: skyblue; width: 200px;"> add js animation</div>
  </div>
​
  <script>
    const cssBtn = document.getElementById('cssBtn')
    const compositor = document.getElementById('compositor')
    cssBtn.addEventListener('click', () => {
      compositor.classList.add("active");
    })
​
    const jsBtn = document.getElementById('jsBtn')
    const stacking = document.getElementById('stacking')
    jsBtn.addEventListener('click', () => {
      setInterval(() => {
        stacking.style.left = `${stacking.getBoundingClientRect().left + 10}px`
      }, 100);
    })
​
  </script>
</body></html>

performance.png

1. 构建DOM树

对应头图中 DOM 节点,由于浏览器本身无法直接理解和使用html,所以需要将html转换为浏览器能够理解的DOM树,也正是因此我们才能通过js控制dom节点

DOM树解析.png 图源 Life of a Pixel

2. 样式计算

对应头图中 style 节点,不仅是html,浏览器同样无法直接读懂我们写的 css 。因此浏览器会将我们写的 css 转换成它能理解的 styleSheets ,同时计算每个 DOM 节点的样式结果。包括将处理样式的继承覆盖,将 rem 等相对单位转换成 px,将 margin: 8 这样的缩写,拆开解析成 margin-left: 8margin-top: 8 等具体的值。可以通过 computed 标签查看。

computed.png

3. 布局计算

对应头图中 layout 节点,这个阶段也是我们很常听到的 回流(reflow),重排。在上两个阶段结束后会生成一个储存其计算结果的树结构 LayoutObject Tree。在这个阶段浏览器会遍历 LayoutObject Tree 计算每个节点在页面上具体的布局(比如是正常流布局,或是flex布局,哪个元素该放到哪个具体的像素位置上),计算文本实际宽高等;这一阶段谷歌正在重构,目前输入和输出都混在 LayoutObject Tree 上,之后可能会将输出部分抽离出来

4. 分层阶段

对应头图中 comp.assign (compositing assignments) 节点,这个阶段是我们获取性能提升的关键。页面上的元素,根据所处坐标空间(基本可以理解为层叠上下文)不同等原因,会被划分为不同的 PaintLayer,通过分层的方式保证页面上元素以正确的顺序层叠;在此基础上,某些特殊的PaintLayer 会被提升为合成层(Compositing Layers),每个合成层拥有单独的 GraphicsLayer , 而没有被提升的 PaintLayer 则与其祖先元素共用同一个 GraphicsLayer.

它们间的对应关系如下图

GraphicsLayer.png 图源 无线性能优化:Composite

每个 GraphicsLayer 都有一个 GraphicsContextGraphicsContext 负责输出该层的位图,即每层代表一份位图,GPU将位图合成渲染到屏幕上也就是我们看到的页面

我们可以通过开发者工具的 Layer 标签看到 GraphicsLayer 的分层,划分 PaintLayer 和 提升为 GraphicsLayer 的条件具体可见 无线性能优化:Composite (需要注意层重叠,层压缩问题)

比如我上面的例子中,我给橙色的 div 加上了 will-change:transform 导致了层提升,而蓝色的 div 与 document 共用一个 GraphicsLayer;我们还可以在 Details 标签看到层提升的具体原因还有内存消耗 (tips: 层提升原因还可以看 safari 浏览器开发者工具的 layers ,会更加具体)

layer.png

5. Pre-paint

这一阶段主要有两个任务,一是判断与上一次paint阶段(见下)相比有哪些内容需要被更新,二是构建 property trees

Paint invalidation which invalidates display items which need to be painted.

Builds paint property trees.

property treesproperty 是指 translation, scale 等需要大量计算的属性。将这些属性抽离出来单独管理,避免父元素的变动导致其子元素上所有的属性都有全部重新计算,具体见 How cc Works

6. paint

绘制阶段,这一阶段即我们常说的重绘阶段,但这一阶段并不是执行实际的页面绘制,而是依据页面内容的层叠顺序生成 绘制任务列表,详见 layer 工具,滚动滑轮可以重播绘制过程,可以观察到,同一层叠上下文情况下,先生成背景绘制任务,再生成元素内容绘制任务,再生成更高层级的层叠上下文元素的绘制任务;

主线程的任务到这里基本结束,将绘制列表提交(commit)到合成线程

7. tiling

tiling 分块,为 GPU光栅化做准备;光栅化是GPU根据绘制任务生成位图,并将位图储存在内存中。大家可能听过 CPU 光栅化的操作,这里引用一段 How cc Works 中文译文

Chromium 目前实际支持三种不同的光栅化和合成的组合方式:软件光栅化 + 软件合成,软件光栅化 + gpu 合成,gpu 光栅化 + gpu 合成。在移动平台上,大部分设备和移动版网页使用的都是 gpu 光栅化 + gpu 合成的渲染方式,理论上性能也最佳

由于这一操作需要消耗较多资源,为了减少资源消耗和使页面更快呈现会将图层进行分块( tiles ),将图块作为光栅化的基本单位,同时优先对视口附近的图块进行光栅化

通过rendering 标签,勾选 layer borders 可以看到分块情况,橙线是不同的 layer 而 青绿色的线则划分了图块

tiling.png

8. raster

这一步由GPU执行光栅化操作,之后的节点我没再深入了解,大概是光栅化生成draw quads 命令,该命令会引用光栅化结果最后将内容展现在屏幕上

总结

最后我们分别录制两个动画的执行流程

js 动画

js-animation-1.png

可以看到 js 动画在每次执行时会重排重绘,执行整个流程,上面橙红色的那条前面有写到 Layout Shift,即 布局提升,也就是我们说的强制重排,因为我们在 js 脚本里执行了 stacking.getBoundingClientRect().left 访问元素位置,这就需要立刻重排来计算元素当前的位置

css动画

css-animation.png

可以看到,css动画主线程上没有进行重排重绘

梳理完整个流程,我们就能理解开头提到的内容了,关键点就在于分层合成

“层提升” 即文中的 分层阶段;

“硬件加速” 即 GPU加速,一些可能导致页面大范围重排重绘(如 translate动画),或需要大量简单计算的任务(如 filter动画)都会导致层提升,将这部分任务交由GPU处理,将处理完后的结果再合成到页面上;

而 css 动画性能更优的原因是:

  1. 避免了通过js访问元素的位置信息导致强制重排
  2. css动画元素移动时在合成层上进行,避免了页面重排
  3. 合成由 GPU 进程控制,即使 js 阻塞主线程,css动画也能正常执行

层提升会加大内存消耗,加大移动端设备负担,需要酌情使用

补充

will-change

上文我们的例子提到了 will-change 属性,它的作用是提前告知浏览器可能变动的属性,让浏览器提前做好准备,提前进行相关计算等,它有以下取值

  • auto 让浏览器自己猜哪些值会变动
  • scroll-position 表示滚动条位置可能发生变化或产生动画
  • contents 表示元素内容可能变动或产生动画
  • <custom-ident> 表示所有css属性

基本上哪里的css属性变化导致了页面的卡顿都可以使用 will-change 优化

我们的例子中已经写入了 will-change: transform ,因此浏览器一开始就帮我们做了层提升准备,所以橙色 div 一开始在页面上就是分层的情况。而如果我们去掉这个属性,观察 layer 会发现橙色 div 一开始在页面上并没有层提升,只有在执行动画时才进行了层提升,动画结束后层提升又消失了

使用该属性同样要注意的是内存消耗问题,因为浏览器会提前进行优化计算并储存计算结果。由于浏览器本身已经做了十足的性能优化,因此在页面没出现动画卡顿之前没有必要使用该属性,如果需要使用也尽量通过以下形式:

.will-change-parent:hover .will-change {
  will-change: transform;
}
.will-change {
  transition: transform 0.3s;
}
.will-change:hover {
  transform: scale(1.5);
}

当父元素 hover 时,给子元素加上 will-change,hover 失效则移出,既给了浏览器准备的时间,又避免了一直挂着该属性带来的资源消耗

requestAnimationFrame / requestIdleCallback

讲到动画我们就顺便提一嘴 requestAnimationFramerequestIdleCallback

我们看到的动画都是由屏幕快速播放一系列连贯的图片组成,为了让人眼感受不到卡顿,大多数屏幕的刷新频率都是60Hz,即一秒钟刷新六十次屏幕,每次刷新叫做一帧,一帧时间大约16.7ms,如果一帧的渲染时间超过这个数就会导致动画看起来出现了卡顿,一帧流程大致如下图

anatomy-of-a-frame.png

图源 The Anatomy of a Frame

requestAnimationFrame会在每一帧的渲染流程执行前都执行一次,因此使用js实现动画时,相比于 setInterval 实际执行时间的不确定性requestAnimationFrame 更加可靠;

requestIdleCallback 则是在每一帧结束前判断是否有剩余时间,如果有则执行,无则不执行

参考链接

  1. Life of a Pixel
  2. chromium renderer/core/paint
  3. 无线性能优化:Composite
  4. How cc Works / 中文
  5. How Blink works / 中文
  6. RenderingNG deep-dive: BlinkNG / 中文
  7. The Anatomy of a Frame
  8. 《css新世界》