✏️最新内容请以github上的为准❗️

为什么要写这篇文章?

主要为 CSS 优化工作打一下基础。要编写高性能的网站和应用,除了确保编写的代码尽可能高效地运行外,还需要确保页面性能,刷新频率尽量到达 60FPS。这就需要了解浏览器是如何进行渲染的。而浏览器渲染与 CSS 密切相关,因此只有了解其中工作原理才能让 CSS 更好地工作。

另外,接下来会出一篇优化实战文章,会涉及 JavaScript 和 CSS 一些优化。其中关于 JavaScript 的优化之前已进行过介绍:常见的 JavaScript 内存泄露。本文是对 CSS 优化进行一个补充。

Contents

浏览器

  1. 用户界面(User Interface):包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。
  2. 浏览器引擎(Browser engine):在用户界面(User Interface)和渲染引擎(Rendering engine)之间传送指令。
  3. 渲染引擎(Rendering engine):负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
  4. 网络(Networking):用于网络调用,比如 HTTP 请求。其接口与平台无关,并为所有平台提供底层实现。
  5. JavaScript 解释器(JavaScript Interperter):用于解析和执行 JavaScript 代码。
  6. 用户界面后端(UI Backend):用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。
  7. 数据存储(Data storage):这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookies。浏览器还支持诸如 localStorage,IndexedDB,WebSQL 和 FileSystem 之类的存储机制。

本文主要介绍浏览器的渲染,即渲染引擎(Rendering engine)负责的工作: 将请求的 HTML 和 CSS 内容解析并渲染在屏幕上。

DOM tree

DOM:Document Object Model,文档对象模型。它可以以一种独立于平台和语言的方式,访问和修改一个文档的内容和结构。它定义了一组与平台,语言无关的接口,该接口允许编程语言动态地访问和修改结构化文档。基于 DOM 表示的文档被描述成一个树形结构,使用 DOM 的接口可以对 DOM 树进行操作。

这里以一个实例展开讲解,如下 HTML 结构包含一些文本和一张图片:

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

浏览器是如何处理这个 HTML 页面:

  1. 转换(Conversion):浏览器从磁盘或网络读取 HTML 的字节码,并根据文件的指定编码(例如 UTF-8)将其转换成对应的字符。
  2. 令牌化(Tokenizing):浏览器将字符串转换成W3C HTML5 标准规定的各种 token,例如<html><body>,以及其他尖括号内的字符串。每个 token 都具有一定特殊含义和规则。
  3. 词法分析(Lexing):将 token 转换成定义其属性和规则的“对象”。
  4. DOM tree:HTML 标签定义了不同标签之间的关系(某些标签包含在其他标签中),所创建的对象链接在树状数据结构中,该数据结构还捕获原始标签中定义的父——子关系:HTML 是 body 的父对象,body 是段落的父对象,依此类推。

整个过程的最终输出是 HTML 页面的 DOM tree,后续浏览器对页面进一步的所有处理都会用到它。浏览器每次处理 HTML 标签时,都会完成以上所有步骤:将字节转换成字符,确定 token,将 token 转换成节点,然后构建 DOM tree

CSSOM tree

CSSOM:CSS Object Model,CSS 对象模型。CSSOM 定义了 JavaScript 访问样式的能力和方式。它是在 DOM 中的一些接口中,加入获取和操作 CSS 属性或接口的 JavaScript 接口,因而 JavaScript 可以动态操作 CSS 样式。DOM 提供了接口让 JavaScript 修改 HTML 文档,CSSOM 提供了接口让 JavaScript 获得和修改 CSS 代码设置的样式信息。

在浏览器构建 DOM 遇到 link 标签时,该标签引用一个外部 CSS 样式表:style.css。由于预见到需要利用该资源来渲染页面,它会立即发出对该资源的请求,并返回以下内容:

body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }

与处理 HTML 时类似,需要将收到的 CSS 规则转换成某种浏览器能够理解和处理的内部表示。因此会重复 HTML 过程,不过是对 CSS 而不是 HTML:

将 CSS 字节转换成字符,接着转换成 token 和节点,最后将它们链接到 CSSOM tree 结构中:

CSSOM tree 可以用于确定节点对象的计算样式。如 span 标记包含了color:red样式和继承于 body 标记的font-size:16px样式;

RenderObject tree(也称为 Render tree)

  • DOM tree 中,存在不可见与可见节点之分。顾名思义,不可见节点是不需要绘制最终页面中的节点,如metaheadscript等,以及通过 CSS 样式display:none隐藏的节点。相反可见节点是用户可见的,如bodydivspancanvasimg等。对于这些可见节点,浏览器需要将它们的内容绘制到最终的页面中,所以浏览器会为它们建立对应的 RenderObject 对象。一个 RenderObject 对象保存了为绘制 DOM 节点的各种信息。这些 RenderObject 对象与 DOM 对象类似,也构成一棵树,称为RenderObject tree。RenderObject tree 是基于 DOM tree 建立起来的一棵新树,是为了布局计算和渲染等机制而构建的一种新的内部表示。RenderObject tree 节点与 DOM tree 节点不是一一对应关系。因为创建一个 RenderObject 对象需要满足如下规则:
  • DOM tree 的 document 节点
  • DOM tree 中的可见节点,如htmlbodydiv等。而浏览器不会为不可见节点创建 RenderObject 节点。
  • 某些情况下浏览器需要创建匿名的 RenderObject 节点,该节点不对应 DOM 树中的任何节点。

RenderObject 对象构成了 RenderObject tree,每个 RenderObject 对象保存了为绘制 DOM 节点的计算样式。RenderObject tree 也可以理解成由 CSSOM treeDOM tree 合并成:

Layout(布局)

当浏览器创建 RenderObject 对象之后,每个对象并不知道自己在设备视口内的位置、大小等信息。浏览器根据盒模型(Box-model)来计算它们的位置、大小等信息的过程称为布局计算(重排)。布局计算是一个递归的过程,这是因为一个节点的大小通常需要先计算它的孩子节点的位置、大小等信息。为了计算节点在页面中的确切大小和位置,浏览器会从 RenderObject tree 的根节点开始进行遍历。

实例:

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Critial Path: Hello world!</title>
  </head>
  <body>
    <div style="width: 50%">
      <div style="width: 50%">Hello world!</div>
    </div>
  </body>
</html>

页面包含了两个嵌套 div:父 div 将其的显示尺寸设置为 viewport 宽度的 50%,子 div 将其宽度设置为其父项的 50%,即 viewport 宽度的 25%。

RenderLayer tree

浏览器渲染引擎并不是直接使用 RenderObject tree 进行绘制,为了方便处理 Positioning(定位),Clipping(裁剪),Overflow-scroll(页內滚动),CSS Transform/Opacity/Animation/Filter,Mask or Reflection,Z-indexing(Z 排序)等,浏览器需要会为一些特定的 RenderObject 生成对应的 RenderLayer,并生成一棵对应的 RenderLayer tree。而这些特定的 RenderObject 跟对应的 RenderLayer 就是直属的关系,如果它们的子节点如果没有对应的 RenderLayer,就从属于父节点的 RenderLayer。最终,每一个 RenderObject 都会直接或者间接地从属于一个 RenderLayer。因此 RenderObject 节点与 RenderLayer 节点不是一一对应关系,而是一对多的关系。那需要满足什么条件,渲染引擎才为 RenderObject 建立对应的 RenderLayer:

  • It's the root object for the page
  • It has explicit CSS position properties (relative, absolute or a transform)
  • It is transparent
  • Has overflow, an alpha mask or reflection
  • Has a CSS filter
  • Corresponds to <canvas> element that has a 3D (WebGL) context or an accelerated 2D context
  • Corresponds to a <video> element

翻译:

  • DOM tree 的 Document 节点对应的 RenderObject 节点和 HTML 节点对应的 RenderObject 节点
  • 显式指定 CSS position 属性的 RenderObject 节点
  • 有透明度的 RenderObject 节点
  • 有 overflow,alpha 和 reflection 的样式 RenderObject 节点
  • 有 filter 样式的 RenderObject 节点
  • 使用 Canvas 2D 和 3D(WebGL)技术的 RenderObject 节点
  • video 元素对应的 RenderObject 节点

每个 RenderLayer 对象可以想象成图像中一个图层,各个图层叠加构成了一个图像。浏览器会遍历 RenderLayer tree,再遍历从属这个 RenderLayer 的 RenderObject,RenderObject 对象存储有绘制信息,并进行绘制。RenderLayer 和 RenderObject 共同决定了最终呈现的网页内容,RenderLayer tree 决定了网页的绘制的层次顺序,而从属于 RenderLayer 的 RenderObject 决定了该 RenderLayer 的内容。

Rendering(渲染方式)

在完成构建 RenderLayer tree 之后,浏览器会使用图形库将其构建的渲染模型绘制出来,该过程分为两个阶段:

  • 绘制:将从属每个 RenderLayer 图层上的 RenderObject 绘制在其 RenderLayer 上。即绘制(Paint)或者光栅化(Rasterization),将一些绘图指令转换成真正的像素颜色值。

    • 软件绘图:CPU 来完成绘图操作
    • 硬件加速绘图:GPU 来完成绘图操作
  • 合成(compositing):将各个 RenderLayer 图层合并成到一个位图(Bitmap)中。同时还可能包括位移(Translation),缩放(Scale),旋转(Rotation),Alpha 合成等操作。

渲染引擎的渲染,目前有三种网页的渲染方式:

  • 软件渲染方式:使用 CPU 来绘制每个 RenderLayer 图层的内容(RenderObject)到一个位图,即一块 CPU 使用的内存空间。绘制每一层的时候都会使用该位图,区别在于绘制的位置可能不一样,绘制顺序按照从后到前。因此软件渲染机制是没有合成阶段的。
  • 硬件加速渲染的合成化渲染方式:使用 GPU 来绘制所有合成层,并使用 GPU 硬件来加速合成。
  • 软件绘图的合成化渲染方式: 某些合成层使用 CPU 来绘图,另外一些使用 GPU 来绘制。对于使用 CPU 来绘制的图层,该层的绘制结果会先保存在 CPU 内存中,之后会被传输到 GPU 内存中,然后再使用 GPU 来完成合成工作。

第二种和第三种渲染方式,都是使用了合成化渲染技术,合成工作也都是由 GPU 来做。对于常见的 2D 绘图操作,使用 GPU 来绘图不一定比使用 CPU 绘图在性能上有优势,例如绘制文字、点、线等。原因是 CPU 的使用缓冲机制有效减少了重复绘制的开销而且不需要考虑与 GPU 并行。另外,GPU 的内存资源相对 CPU 的内存资源来说比较紧张,而且网页的分层使得 GPU 的内存使用相对比较多。鉴于此,就目前的情况来看,三者都存在是有其合理性的,下面分析一下它们的特点:

  • 软件渲染是目前很常见的技术,也是浏览器最早使用的渲染方式。这一技术比较节省内存,特别是宝贵的 GPU 内存,但是软件渲染只能处理 2D 方面的操作。简单的网页没有复杂绘图或者多媒体方面的需求,软件渲染方式就比较合适来渲染该类型的网页。问题是,一旦遇上了 HTML5 的很多新技术,软件渲染显得无能为力,一是因为能力不足;二是因为性能不好,例如视频、Canvas 2D 等。所以,软件渲染技术被用的越来越少,特别是在移动领域。软件渲染同硬件加速渲染另外一个很不同的地方就是对更新区域的处理。当网页中有一个更新小型区域的请求(如动画)时,软件渲染可能只需要计算一个极小的区域,而硬件渲染可能需要重新绘制其中的一层或者多层,然后再合成这些层。硬件渲染的代价可能会大得多。
  • 对于硬件加速的合成化渲染方式来说,每个层的绘制和所有层的合成均使用 GPU 硬件来完成,这对需要使用 3D 绘图的操作来说特别合适。这种方式下,在 RenderLayer 树之后,浏览器还需要建立更多的内部表示,目的是支持硬件加速机制,这显然会消耗更多的内存资源。但是,一方面,硬件加速机制能够支持现在所有的 HTML5 定义的 2D 或者 3D 绘图标准;另一方面,关于更新区域的讨论,如果需要更新某个层的一个区域,因为软件渲染没有为每一层提供后端存储,因而它需要将和这个区域有重叠部分的所有层次的相关区域一次从后往前重新绘制一遍,而硬件加速渲染只需要重新绘制更新发生的层次,因而在某些情况下,软件渲染的代价又变得更大。当然,这取决于网页的结构和渲染策略。
  • 软件绘图的合成化渲染方式结合了前面两种方式的优点,这时因为很多网页可能既包含基本的 HTML 元素,也包含一些 HTML5 新功能,使用 CPU 绘图方式来绘制某些层,使用 GPU 来绘制其他一些层。原因当然是前面所述的基于性能和内存方面综合考虑的结果。

GrphicsLayer tree

对于软件渲染而言,到 RenderLayer tree 就结束了,后面不会建立其它额外的树来对应于 RenderLayer tree。但是,对于硬件渲染来说,在 RenderLayer tree 之后,浏览器渲染引擎为硬件渲染提供了更多的内部结构来支持这个机制。

在硬件加速渲染的合成化渲染和软件绘图的合成化渲染架构下,一个 RenderLayer 对象如果需要后端存储,它会创建一个 RenderLayerBacking 对象,该对象负责 Renderlayer 对象所需要的各种存储。理想情况下,每个 RenderLayer 都可以创建自己的后端存储,事实上不是所有 RenderLayer 都有自己的 RenderLayerBacking 对象。如果一个 RenderLayer 对象被像样的创建后端存储,那么将该 RenderLayer 称为合成层(Compositing Layer)。

哪些 RenderLayer 对象可以是合成层?如果一个 RenderLayer 对象具有以下的特征之一,那么它就是合成层:

  • Layer has 3D or perspective transform CSS properties
  • Layer is used by < video> element using accelerated video decoding
  • Layer is used by a < canvas> element with a 3D context or accelerated 2D context
  • Layer is used for a composited plugin
  • Layer uses a CSS animation for its opacity or uses an animated webkit transform
  • Layer uses accelerated CSS filters
  • Layer with a composited descendant has information that needs to be in the composited layer tree, such as a clip or reflection
  • Layer has a sibling with a lower z-index which has a compositing layer (in other words the layer is rendered on top of a composited layer)

翻译:

  • RenderLayer 具有 3D 或透视转换的 CSS 属性
  • RenderLayer 包含使用硬件加速的视频解码技术的<video>元素
  • RenderLayer 包含使用硬件加速的 2D 或 WebGL-3D 技术的<canvas>元素
  • RenderLayer 使用了合成插件。
  • RenderLayer 使用了opacitytransform动画
  • RenderLayer 使用了硬件加速的 CSS Filters 技术
  • RenderLayer 后代中包含了一个合成层(如有 clip 或 reflection 属性)
  • RenderLayer 有一个 z-index 比自己小的合成层(即在一个合成层之上)

每个合成层都有一个 RenderLayerBacking,RenderLayerBacking 负责管理 RenderLayer 所需要的所有后端存储,因为后端存储可能需要多个存储空间。在浏览器(WebKit)中,存储空间使用类 GraphicsLayer 来表示。浏览器会为这些 RenderLayer 创建对应的 GraphicsLayer,不同的浏览器需要提供自己的 GrphicsLayer 实现用于管理存储空间的分配,释放,更新等等。拥有 GrphicsLayer 的 RenderLayer 会被绘制到自己的后端存储,而没有 GrphicsLayer 的 RenderLayer 它们会向上追溯有 GrphicsLayer 的父/祖先 RenderLayer,直到 Root RenderLayer 为止,然后绘制在有 GrphicsLayer 的父/祖先 RenderLayer 的存储空间,而 Root RenderLayer 总是会创建一个 GrphicsLayer 并拥有自己独立的存储空间。在将每个合成图层包含的 RenderLayer 内容绘制在合成层的后端存储中,这里绘制可以是软件绘制或硬件绘制。接着由合成器(Compositor)将多个合成层合成起来,形成最终用户可见的网页,实际上就是一张图片。

GraphicsLayer 又构成了一棵与 RenderLayer 并行的树,而 RenderLayer 与 GraphicsLayer 的关系有些类似于 RenderObject 与 RenderLayer 之间的关系。如下是 DOM treeRenderObject treeRenderLayer treeGraphicsLayer tree关系图:

这样可以合并一些 RenderLayer 层,从而减少内存的消耗。其次,合并之后,减少了合并带来的重绘性能和处理上的困难。在硬件加速渲染的合成化渲染和软件绘图的合成化渲染架构下,RenderLayer 的内容变化,只需要更新所属的 GraphicsLayer 的缓存即可,而缓存的更新,也只需要绘制直接或者间接属于这个 GraphicsLayer 的 RenderLayer,而不是所有的 RenderLayer。特别是一些特定的 CSS 样式属性的变化,实际上并不引起内容的变化,只需要改变一些 GraphicsLayer 的混合参数,然后重新混合即可,而混合相对绘制而言是很快的,这些特定的 CSS 样式属性我们一般称之为是被加速的,不同的浏览器支持的状况不太一样,但基本上 CSS Transform & Opacity 在所有支持混合加速的浏览器上都是被加速的。被加速的 CSS 样式属性的动画,就比较容易达到 60 帧/每秒的流畅效果了。

不过并不是拥有独立缓存的 RenderLayer 越多越好,太多拥有独立缓存的 RenderLayer 会带来一些严重的副作用:

  • 它大大增加了内存的开销,这点在移动设备上的影响更大,甚至导致浏览器在一些内存较少的移动设备上无法很好地支持图层合成加速;
  • 它加大了合成的时间开销,导致合成性能的下降,而合成性能跟网页滚动/缩放操作的流畅度又息息相关,最终导致网页滚动/缩放的流畅度下降,让用户觉得操作不够流畅。

Tiled Rendering(瓦片渲染)

通常一个合成层的后端存储被分割成多个大小相同的瓦片状的小存储空间,每个瓦片可以理解为 OpenGL 中的一个纹理,合成层的结果被分开存储在这些瓦片中。为什么使用瓦片化的后端存储?

  • DOM 树种的 html 元素所在的层可能会比较大,因为网页的高度很大,如果只是使用一个后端存储的话,那么需要一个很大的纹理对象,但是实际的 GPU 硬件可能只支持非常有限的纹理大小。
  • 在一个比较大的合成层中,可能只是其中一部分发生变化,根据之前的介绍,需要重新绘制整个层,这样必然产生额外的开销,使用瓦片话的后端存储,就只需要重绘一些存在更新的瓦片。
  • 当层发生滚动的时候,一些瓦片可能不再需要,然后渲染引擎需要一些新的瓦片来绘制新的区域,这些大小相同的后端存储很容易重复利用。

High Performance Animations(流畅动画)

网页加载后,绘制新的每一帧,一般都需要经过计算布局(layout)、绘图(paint)、合成(composite)三阶段。因此要想提高页面性能(或 FPS),需要减少每一帧的时间。而在这三个阶段中,layout 和 paint 比较耗时间,而合成需要的时间相对较少一些。

layout

如果修改 DOM 元素的 layout 样式(如 width, heihgt 等),浏览器会计算页面需要 relayout 的元素,然后触发一个 relayout。被 relayout 的元素,接着会执行绘制,最后进行渲染合并生成页面。

paint

如果修改 DOM 元素的 paint 样式(如 color, background 等),浏览器会跳过布局,直接执行绘制,再进行合成。

composite

如果修改 DOM 元素的 composite 样式(如 transform, opacity 等)。浏览器会跳过布局和绘制,直接执行合成。该过程是开销最小的,也是优化着手点。

如果想知道修改任何指定 CSS 样式会触发 layout、paint、composite 中的哪一个,请查看CSS 触发器

优化

可以通过什么途径进行优化,减少每一帧的时间(避免过多 layout 或 paint):

  • 使用适合的网页分层技术减少 layout 和 paint。一旦有请求更新,如果没有分层,渲染引擎可能需要重新绘制所有区域,因为计算更新部分对 GPU 来说可能消耗更多的时间。而网页分层之后,部分区域的更新可能只在网页的一层或几层,而不需要将整个网页重新绘制。通过重新绘制网页的一层或几层,并将它们和其他之前绘制完的层合并起来,既能使用 GPU 的能力,又能够减少重绘的开销。
  • 使用合成属性样式(opcity、tansform)来完成 tansition 或 animation。当合成器合成时候,每个合成层都可以设置变形属性:位移(Translate)、缩放(Scale)、旋转(Rotation)、opacity,这些属性仅仅改变合成层的变换参数,而不需要 layout 和 paint 操作,极大地减少每一帧的渲染时间。

即使用 GPU 硬件加速,为某些 RenderLayer 创建对应 GraphicsLayer。通过为每一个合成层设置transform属性来完成 transition 或 animation,有效地避免 relayout 和 repaint 的开销。

总结

本文重点介绍了浏览器渲染引擎的渲染过程,涉及了 DOM treeCSSOM treeRenderObject treeRenderLayer treeGraphicsLayer tree。并对各种渲染模式进行了简单介绍,其中引入了硬件加速机制,还给出一些优化建议。了解这些知识点对我们开发高性能的 web 应用会有很大的帮助。

参考文章:

12-31 03:48