WebKit 渲染流程基础及分层加速

当我们打开一个网页时,WebKit 渲染流程大概如下:

  1. HTML 解释器解析 HTML 文档生成 DOM 树。
  2. CSS 解释器解析生成 CSS 树(CSSOM)。
  3. 根据 DOM 树构建 RenderObject 树,并对 RenderObject 进行布局计算,并把结果保存到 RenderObject 中。
  4. 根据 RenderObject 树和 CSSOM 相关属性构建 RenderLayer 树,主要用于网页分层与渲染合成。
  5. 采用软件或硬件渲染。

本文将根据这个顺序,逐步讲解 WebKit 的渲染流程,以及网页分层机制和硬件加速机制。

HTML 解释器和 DOM 模型

在 WebKit 中,当接受到以字节流来表示的 HTML 文档时,HTML 解释器就要开始工作,将从网络或者本地文件获取的字节流转成内部表示的结构—— DOM 树。

DOM(Document Object Model)的全称是文档对象模型,它可以以一种独立于平台和语言的方式访问和修改一个文档的内容和结构。这里的文档可以是 HTML 文档、XML 文档或者 XHTML 文档。DOM 以面向对象的方式来描述文档, 在 HTML 文档中,Web 开发者可以使用 JavaScript 语言来访问、创建、删除或者修改 DOM 结构,其主要目的是动态改变 HTML 文档的结构。

在开始介绍 HTML 解释器和 DOM 之前,我们有必要先了解一下每个版本的 DOM 接口标准,包括 DOM Level 1、DOM Level 2 和 DOM Level 3,每个版本都是在原有基础上增加新的接口以加强功能。

DOM Level 1,于 1998 年成为 W3C 推荐标准,包含两个部分。

DOM Level 2,于 2000 年成为 W3C 推荐标准,包含六个部分。

DOM Level 3,于 2004 年成为 W3C 推荐标准,包含五个部分。

HTML 解释器

HTML 解释器的工作就是将网络或者本地磁盘获取的 HTML 网页和资源从字节流解释成 DOM 树结构。这一过程大致可以理解成下图所述步骤。

WebKit 的 HTML 解析过程在图中被描述得很清楚:首先是字节流,经过解码之后是字符流,然后通过词法分析器会被解释成词语(Tokens),之后经过词法分析器构建成节点,最后这些节点被组建成一棵 DOM 树。

DOM 模型

如:

<html>
    <head></head>
    <body>
        <p></p>
        <div></div>
    </body>
</html>

生成 DOM 树:

词法分析

在进行词法分析之前,解释器首先要做的事情就是检查该网页内容使用的编码格式,以便后面使用合适的解码器。如果解释器在 HTML 网页中找到了设置的编码格式,WebKit 会使用相应的解码器来将字节流转换成特定格式的字符串。如果没有特殊的格式,则直接使用 HTMLTokenizer 类进行词法分析。

词法分析的工作都是由 HTMLTokenizer 类来完成的,简单来说,它就是一个状态机——输入的是字符串,输出的是一个个的词语。

XSSAuditor 验证词语

当词语生成之后,WebKit 需要使用 XSSAuditor 来验证词语流(Token Stream)。主要是针对安全方面的考虑。

根据 XSS 的安全机制,对于解析出来的这些词语,可能会阻碍某些内容的进一步执行,所以 XSSAuditor 类主要负责过滤这些被阻止的内容,只有通过的词语才会作后面的处理。详细的规则和方法这里不作介绍。

词语到节点

经过词法分析器解释之后的词语随之被 XSSAuditor 过滤并且在没有被阻止之后,将被 WebKit 用来构建 DOM 节点。这一步骤是由 HTMLDocumentParser 类调用 HTMLTreeBuilder 类的constructTree函数来实现的。

节点到 DOM 树

从节点到构建 DOM 树,包括为树中的元素节点创建属性节点等工作由 HTMLConstructionSite 类来完成。

因为 HTML 文档的 Tag 标签是有开始和结束标记的,所以构建这一过程可以使用栈结构来帮忙。HTMLConstructionSite 类中包含一个HTMLElementStack变量,它是一个保存元素节点的栈,其中的元素节点是当前有开始标记但是还没有结束标记的元素节点。例如有一个片段<body><div><img></img></div></body>,当解释到img元素的开始标记时,栈中的元素就是 body、div 和 img,当遇到 img 的结束标记时,img 退栈,img 是 div 元素的子元素;当遇到 div 的结束标记时,div 退栈,表明 div 和它的子孙元素都已经处理完了,以此类推。

WebKit 对 JavaScript 执行的处理

在 HTML 解释器的工作过程中,可能会有 JavaScript 代码(全局作用域的代码、放在页面顶部的代码)需要执行,它发生在将字符串解释成词语之后、创建各种节点的时候。这也是为什么全局执行的 JavaScript 代码不能访问 DOM 树的原因——因为 DOM 树还没有被创建完。

因为 JavaScript 代码可能会调用例如document.write()来修改文档结构,所以 JavaScript 代码的执行会阻碍后面节点的创建,同时当然也会阻碍后面的资源下载(这个在上一篇文章《性能优化规则》中提到过,下面会提到 WebKit 这种情况的处理),这时候 WebKit 对需要什么资源一无所知,这导致了资源不能够并发地下载这一严重影响性能的问题。

WebKit 有什么办法能够处理这样的情况呢?WebKit 使用预扫描和预加载机制来实现资源的并发下载而不被 JavaScript 的执行所阻碍。

具体做法是,当遇到需要执行 JavaScript 代码的时候,WebKit 先暂停当前 JavaScript 代码的执行,使用预先扫描器 HTMLPreloadScanner 类来扫描后面的词语。如果 WebKit 发现它们需要使用其他资源,那么使用预资源加载器 HTMLResourcePreloader 类来发送请求,再者之后,才执行 JavaScript 的代码。预先扫描器本身并不创建节点对象,也不会构建 DOM 树,所以速度比较块。就算如此,还是推荐将 script 元素放在后面,因为不是所有渲染引擎都作了如此的考虑。

当 DOM 树构建完以后,WebKit 触发DOMContentLoaded事件,注册在该事件上的 JavaScript 函数会被调用。当所有资源都被加载完之后,WebKit 触发onload事件。

CSS 解释器和样式布局

从整个网页的加载和渲染过程来看,CSS 解释器和规则匹配处于 DOM 树建立之后,RenderObject 树建立之前,CSS 解释器解释后的结果会保存起来,然后 RenderObject 树基于该结果来进行规范匹配和布局计算。当网页有用户交互或者动画等动作的时候,通过 CSSOM 等技术,JavaScript 代码同样可以非常方便地修改 CSS 代码,WebKit 此时需要重新解释样式并重复以上这一过程。

CSSOM(CSS Object Model)

我们平常书写的 CSS 代码,都是静态的。CSS 提供了有一些方法让开发者可以自定义一些脚本去操作它们的状态。这就是 CSSOM,称为 CSS 对象模型。它的思想是在 DOM 中的一些节点接口中,加入获取和操作 CSS 属性或者接口的 JavaScript 接口,因而 JavaScript 可以动态操作 CSS 样式。DOM 提供了接口让 JavaScript 修改 HTML 文档,同理,CSSOM 提供了接口让 JavaScript 获得和修改 CSS 代码设置的样式信息。

对于内部和外部样式表,CSSOM 定义了样式表的接口,称为CSSStyleSheet,这是一个可以在 JavaScript 代码中访问的接口。借助于该接口,开发者可以在 JavaScript 中获取样式表的各种信息,例如 CSS 的href、样式表类型type、规则信息cssRules等,甚至可以获取样式表中的 CSS 规则列表。它是 CSSOM 定义的新接口。开发者可以通过document.stylesheets查看当前网页中包含的所有 CSS 样式表。

通过这个接口,开发者甚至可以动态选择使用哪些 CSS 样式表。获取的样式表就是前面定义的 CSSStyleSheet 对象,JavaScript 代码可以修改这些对象的属性。

  1. 在任意页面打开 Chrome 控制台,输入document.styleSheets,可以看到当前页面有几个样式表对象。
  2. 在控制台中输入document.styleSheets[0].disabled = true,读者在浏览器中会发现网页中的样式会发生改变,因为第一个样式表被关闭了。
  3. 也可以用它来修改样式,document.styleSheets[1].cssRules[0].style.color = 'grey'

解释过程

CSS 解释过程是指从 CSS 字符串经过 CSS 解释器处理后变成渲染引擎的内部规则表示的过程。

这一过程并不复杂,基本的思想是由 CSSParser 类负责。当 WebKit 需要解释 CSS 内容的时候,它调用 CSSParser 对象来设置 CSSGrammer 对象等,解释过程中需要的回调函数由 CSSParser 来负责处理。

在解释网页中自定义的 CSS 样式之前,实际上 WebKit 渲染引擎会为每个网页设置一个默认的样式(user agent),这决定了网页所没有设置的元素属性及其属性默认值和将要显示的效果。

样式规则匹配

样式规则建立完成之后,当 DOM 的节点建立之后,WebKit 会为其中的一些可视节点选择合适的样式信息。

匹配的基本思路是,使用 StyleResolver 类来为 DOM 的元素节点匹配样式。StyleResolver 类根据元素的信息,例如标签名、类别等,从样式规则中查找最匹配的规则,然后将样式信息保存到新建的 RenderStyle 对象中。最后,这些 RenderStyle 对象被 RenderObject 类所管理和使用。

规则的匹配是由 ElementRuleCollector 类来计算并获得的。

首先,当 WebKit 需要为 HTML 元素创建 RenderObject 类(下文会讲到)的时候,StyleResolver 类负责获取样式信息,并返回 RenderStyle 对象,RenderStyle 对象包含了匹配完的结果样式信息。

其次,根据实际需求,每个元素需要匹配不同来源的规则,依次是用户代理规则集合(即浏览器默认样式)、用户规则集合和 HTML 网页中包含的自定义规则集合。

再次,对于自定义规则集合,它先查找 ID 规则,检查有无匹配的规则,之后依次检查类型规则、标签规则等。如果某个规则匹配上该元素,WebKit 把这些规则保存到匹配结果中,

最后,WebKit 对这些规则进行排序。对于该元素需要的样式属性,WebKit 选择从高优先级规则中选取,并将样式属性值返回。

RenderObject 树

在进行完 CSS 样式解释和匹配后,WebKit 需要创建 RenderObject 对象,组成 RenderObject 树,再结合 RenderObject 树利用盒子模型进行相应的布局计算,并把结果保存到 RenderObejct 树中。RenderObject 树和 RenderLayer 树等其他树,构成了 WebKit 渲染的主要基础设施。

WebKit 在构建完 DOM 树之后,就需要为 DOM 树节点构建 RenderObject 树。

在 DOM 树中,某些节点是用户不可见的,例如表示 HTML 文件头的meta节点,我们称之为“非可视化节点”。该类型还包含headscript等。而另外的节点就是用来展示网页内容的,称为“可视节点”。

对于这些“可视节点”,因为 WebKit 需要将它们的内容绘制到最终的网页结果中,所以 WebKit 会为它们建立相应的 RenderObject 对象。一个 RenderObject 对象保存了为绘制 DOM 节点缩需要的各种信息,例如上面提到的样式布局信息。经过 WebKit 的处理之后,RenderObject 对象知道如何绘制自己。这些 RenderObject 对象同 DOM 的节点对象类似,它们也构成一棵树,在这里我们称之为 RenderObject 树。

RenderObject 树是基于 DOM 树建立起来的一棵新树,是为了布局计算和渲染等机制而构建的一种新的内部表示。 RenderObject 树节点和 DOM 树节点不是一一对应的关系。以下三条规则,会为 DOM 树节点创建一个 RenderObject 对象。

WebKit 在创建 DOM 树的时候也同样创建 RenderObject 对象。当然,如果 DOM 树被动态加入了新节点,WebKit 也可能创建相应的 RenderObject 对象。

下图为简单的 RenderObject 树举例:

布局计算

当 WebKit 创建 RenderObject 对象之后,每个对象是不知道自己的位置、大小等信息的,WebKit 根据盒子模型来计算它们的位置、大小等信息的过程称为布局计算(或成为排版)。

布局计算根据计算的范围大致可以氛围两类:第一类是对整个 RenderObject 树进行的计算;第二类是对 RenderObject 树中某个子树的计算,常见于文本元素或者是overflow:auto块的计算,这种情况一般是其子树布局的改变不会影响其周围元素的布局,因而不需要重新计算更大范围内的布局。

布局计算是一个递归的过程,这是因为一个节点的大小通常需要先计算它的子孙节点的位置、大小等信息。

下图描述了 RenderObject 节点计算布局的主要过程,中间省略了很多判断和步骤,主要逻辑都是由 RenderObject 类的layout函数来完成。

首先,该函数会判断 RenderObject 节点是否需要重新计算,通常这需要通过检查数组中的 相应标记位、子女是否需要计算布局等来确定。

其次,该函数会确定网页的宽度和垂直方向上的外边距,这时因为网页通常是在垂直方向上滚动,而水平方向尽量不需要滚动。

再次,该函数会遍历其每一个子女节点,依次计算它们的布局。每一个元素会实现自己的layout函数,根据特定的算法来计算该类型元素的布局。如果当前元素还有子女,则 WebKit 需要递归这一过程。

哪些情况下需要重新计算布局呢?

布局计算相对也是比较耗时间的,更糟糕的是,一旦布局发生变化,WebKit 就需要后面的重新绘制操作。另一方面,减少样式的变动而依赖现在 HTML5 的新功能可以有效地提高网页的渲染效率,这些会在后面介绍绘图的时候一并介绍。

网页层次和 RenderLayer 树

事实上,网页是可以分层的,这有两点原因:

WebKit 会为网页的层次创建相应的 RenderLayer 对象。当某些类型 RenderObject 的节点或者具有某些 CSS 样式的 RenderObject 节点出现的时候,WebKit 就会为这些节点创建 RenderLayer 对象。一般来说,某个 RenderObject 节点的后代都属于该节点,除非 WebKit 根据规则为某个后代 RenderObject 节点创建了一个新的 RenderLayer 对象。

RenderLayer 树是基于 RenderObject 树建立起来的一棵新树,根据上面的描述,可以得出这样的结论:RenderLayer 节点 和 RenderObject 节点不是一一对应关系,而是一对多的关系。那么哪些情况下的 RenderObject 节点需要建立新的 RenderLayer 节点呢?以下是基本规则:

每个 RenderLayer 节点包含的 RenderObject 节点其实是一棵 RenderObject 子树。RenderLayer 节点的使用可以有效地减小网页结构的复杂程度,并在很多情况下能够减少重新渲染的开销。理想情况下,每个 RenderLayer 对象都有一个后端类,用来存储该 RenderLayer 对象绘制的结果。

在 WebKit 创建 RenderObject 树之后,WebKit 也会创建 RenderLayer 树。当然某些 RenderLayer 节点也有可能在执行 JavaScript 代码时或者更新页面的样式时被动态创建。

与 RenderObject 类不同的是,RenderLayer 类没有子类,它表示的是网页的一个层次,并没有“子层次”的说法。

构建 RenderLayer 树

RenderLayer 树的构建非常简单,甚至比构建 RenderObject 树还要简单。根据前面所述的条件来判断一个 RenderObject 节点是否需要建立一个新的 RenderLayer 对象,并设置 RenderLayer 对象的父亲和兄弟关系即可,这里不再介绍。

为了直观地理解 RenderLayer 树,下图举例上面 RenderObject树 对应的 RenderLayer:

渲染方式

在完成构建 DOM 树之后,WebKit 索要做的事情就是构建渲染的内部表示并使用图形库将这些模型绘制出来。提到网页的渲染方式,目前主要有三种方式,第一种是软件渲染,第二种是硬件加速渲染,第三种是混合模式。

每个 RenderLayer 对象可以被想象成图像中的一个层,各个层一同构成了一个图像。在渲染的过程中,浏览器也可以作同样的理解。每个层对应网页中的一个或者一些可视元素,这些元素都绘制内容到该层上。

如果绘图操作使用 CPU 来完成,那么称之为软件绘图。如果绘图操作由 GPU 来完成,称之为 GPU 硬件加速绘图。理想情况下,每个层都有个绘制的存储区域,这个存储区域用来保存绘图的结果。最后,需要将这些层的内容合并到同一个图像之中,我们称之为合成(Compositing),使用了合成技术的渲染称之为合成化渲染。

下图是网页的三种渲染方式:

第一种软件渲染实际上用的是一块 CPU 内存空间。第二种和第三种方式,都是使用了合成化的渲染技术,也就是使用 GPU 硬件来加速合成这些网页层,合成的工作都是由 GPU 来做,这里成为硬件加速合成(Accelerated Compositing)。但是,对于每个层,这两种方式有不同选择。其中某些层,第二种方式使用 CPU 来绘图,另外一些层使用 GPU 来绘图。对于使用 CPU 来绘图的层,该层的结果首先当然保存在 CPU 内存中,之后被传输到 GPU 的内存中,这主要是为了后面的合成工作。第三种渲染方式使用 GPU 来绘制所有合成层。第二种方式和第三种方式其实都属于硬件加速渲染方式。

为什么会有三种渲染方式,这时因为三种方式各有各的优缺点和适用场景,在介绍它们之前,先了解一些渲染方面的基本知识。

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

软件渲染过程

在很多情况下,也就是没有那些需要硬件加速内容的时候(包括但不限于 CSS3 3D 变形、CSS3 3D 变换、WebGL 和视频),WebKit 可以使用软件渲染技术来完成页面的绘制工作(除非读者强行打开硬件加速机制)。

在软件渲染过程中,对于每个 RenderObject 对象,需要三个阶段绘制自己,第一阶段是绘制该层中所有的背景和边框,第二阶段是绘制浮动内容,第三阶段是前景(Foreground),也就是内容部分、轮廓(它是 CSS 标准属性,绘制于元素周围的一条线,位于边框边缘的外围)等部分。当然,每个阶段还可能会有一些子阶段。值得指出的是,内嵌元素的背景、边框、前景等都是在第三阶段中别绘制的,这时不同之处。

硬件加速机制

随着 HTML5 中不断加入图形和多媒体方面的功能,例如 Canvas 2D、WebGL、CSS 3D 和视频等,这对渲染引擎使用图形库的性能提出了很高的要求。

这里说的硬件加速技术是指使用 GPU 的硬件能力来帮助渲染网页,因为 GPU 的作用主要是用来绘制 3D 图形并且性能特别好,这时它的专长所在,它同软件渲染有很多不同的地方,既有自己的优点,当然也有些不足之处。

对于 GPU 绘图而言,通常不像软件渲染那样只是计算其中更新的区域,一旦有更新请求,如果没有分层,引擎可能需要重新绘制所有的区域,因为计算更新部分对 GPU 来说可能耗费更多时间。当网页分层之后,部分区域的更新可能只在网页的一层或者几层,而不需要将整个网页都重新绘制。通过重新绘制网页的一个或者几个层,并将它们和其他之前绘制完的层合成起来,既能使用 GPU 的能力,又能够减少重绘的开销。

WebKit 硬件加速设施

一个 RenderLayer 对象如果需要后端存储,它会创建一个 RenderLayerBacking 对象,该对象负责 RenderLayer 对象所需要的各种存储。理想情况下,每个 RenderLayer 都可以穿件自己的后端存储,但事实上并不是所有 RenderLayer 都有自己的 RenderLayerBacking 对象。如果一个 RenderLayer 对象被 WebKit 依照一定的规则创建了后端存储,那么该 RenderLayer 被称为合成层

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

至于为什么这么做,有以下三个原因:

  1. 为了合并一些 RenderLayer 层,这样可以减少内存的使用量。
  2. 合并之后,尽量减少合并带来的重绘性能和处理上的困难。
  3. 对于那些使用单独层能够显著提升性能的 RenderLayer 对象,可以继续使用这些好处,例如使用 WebGL 技术的 Canvas 元素。

至此,WebKit 渲染流程大致结束。

本文只挑了与 WebKit 相关的底层渲染流程作介绍,至于一些更高层的知识点,例如何时重绘重排、能触发重绘重排的相关 CSS 属性和 JavaScript 操作、触发硬件加速的具体 CSS 语句枚举、如何减少重绘重排、CSS 动画优化等等知识点都不在此文介绍(以后会有相应的博文专门作总结)。

推荐书籍:《WebKit 技术内幕》