@pspgbhu
2018-01-26T04:21:07.000000Z
字数 10748
阅读 1740
blog
“渲染引擎(Rendering Engine)”也称“排版引擎(Layout Engine)”,负责在浏览器的屏幕上显示请求的内容,是浏览器重要组成之一。本文将基于 Blink 渲染引擎来深入解析浏览器渲染工作的流程及原理。
WebKit 是苹果公司主导研发的一款浏览器渲染引擎,并在 2005 年 6 月开源了该引擎。在 2008 年 Google 推出了 Chrome 浏览器,并且采用了 WebKit 作为其浏览器的渲染引擎。
根据提交统计,Google 自 2009 年年底以来一直是 WebKit 代码库的最大贡献者,Google 工程师们在实现标准上一直表现出了比较激进的态度,而 Apple 则一向比较保守,虽然 Google 的开发工程师提交大部分的 WebKit 更改,但是 WebKit 的最终决策权还是 Apple 的。同时像 Google 这种技术实力雄厚的公司也不愿意一直受制于人,希望在浏览器的开发上拥有更大的自由度,因此久而久之 Google 与 Apple 分道扬镳就是必然的事情了。
最终,Google 在 2013 年 4 月 宣布从 WebKit 分支出自己的浏览器渲染引擎 Blink。
下图展示了渲染引擎的主要工作流程。
其主要分为下面四个步骤:
HTML 解析基本流程:网络 -> 字节 -> 字符 -> 标记化 -> 树构建
document.write()
)传入的字符一起放入了 输入流预处理器 (Input Stream Preprocessor),以供后续标记化阶段使用;如果希望进一步了解 HTML 文档解析,可以查看 HTML5 规范 - HTML解析。
CSSOM 即 CSS 对象模型,它定义了媒体查询,选择器和 CSS(包括通用解析和序列化规则)本身的 API。
与处理 HTML 时一样,我们需要将收到的 CSS 规则转换成某种浏览器能够理解和处理的东西。因此,我们会重复类似于构建 DOM 的步骤,不过这次是为了 CSS:
CSS 规则的来源:
CSSStyleSheet
对象。CSSOM 与 DOM 是分别独立的数据结构。与 DOM 树相同,CSSOM 也构成了树状的结构。CSSOM 为何具有树状结构?因为页面上的任何对象计算最后一组样式时,浏览器都会先从适用于该节点的最通用规则开始,然后通过应用更具体的规则以递归方式优化计算的样式。示意图如下:
JS 脚本的执行会使解析器暂定文档的解析,那么在 JS 脚本之后才引入的外部资源也会在 JS 脚本执行完成之后才开始加载吗?当然不会是这样,虽然主解析器暂停的文档的解析,但是其他线程会继续去解析文档的剩余部分,找出并加载需要通过网络加载的其他资源。这样就可以使网络资源并行加载,从而提高整体速度,这称之为 预解析。预解析是不会去修改 DOM 树的,DOM 树的构建仅仅由主解析器进行。
WebKit 中称布局树为 RenderTree 而 Blink 则称之为 LayoutTree。
1. 从 DOM 树的根节点开始遍历每个可见节点
某些不可见节点(如 script 标签, meta 标签等)不需要渲染在页面中,因此会被忽略。
设置了 display: none
样式属性的节点也不会被添加在布局树上。
2. 对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。
3. 连同其内容和计算的样式来构建一个布局对象
4. 由于 DOM 树的影响,布局对象之间也构成了树状结构:布局树
WebKit 将布局对象称之为
RenderObject
,而在 Blink 中则称之为LayoutObject
。
每一个布局对象都代表了一个矩形的区域,通常对应于相关节点的 CSS 框,它包含诸如宽度、高度和位置等几何信息。布局对象的类型会受到节点相关的 display 属性所影响。
一个元素的样式为 display: none;
,另一个元素的样式为 visibility: hidden;
,有什么区别呢?最直观的就是 visibility: hidden;
虽然隐藏了,但是还占据着空间,而 display: none;
则消失的更彻底,在页面上找不见一点痕迹。那么对于布局对象来说,这两个又有什么区别呢?下面是一段 Blink 代码,里面描述了根据 display 属性对于布局对象的影响。
// LayoutObject.cpp
LayoutObject* LayoutObject::createObject(Element* element, const ComputedStyle& style)
{
//...
//...
switch (style.display()) {
case NONE:
return nullptr;
case INLINE:
return new LayoutInline(element);
case BLOCK:
case INLINE_BLOCK:
return new LayoutBlockFlow(element);
case LIST_ITEM:
return new LayoutListItem(element);
case TABLE:
case INLINE_TABLE:
return new LayoutTable(element);
case TABLE_ROW_GROUP:
case TABLE_HEADER_GROUP:
case TABLE_FOOTER_GROUP:
return new LayoutTableSection(element);
case TABLE_ROW:
return new LayoutTableRow(element);
case TABLE_COLUMN_GROUP:
case TABLE_COLUMN:
return new LayoutTableCol(element);
case TABLE_CELL:
return new LayoutTableCell(element);
case TABLE_CAPTION:
return new LayoutTableCaption(element);
case BOX:
case INLINE_BOX:
return new LayoutDeprecatedFlexibleBox(*element);
case FLEX:
case INLINE_FLEX:
return new LayoutFlexibleBox(element);
case GRID:
case INLINE_GRID:
return new LayoutGrid(element);
}
ASSERT_NOT_REACHED();
return nullptr;
}
从代码中我们可以看到,当 display: none
时是不创建布局对象的。而 visibility: hidden
时只要其 display 的属性不是 none,依然是会创建对象。
布局对象是和 DOM 元素相对应的,但并非一一对应。非可视化的 DOM 元素不会插入布局树中。例如“head”元素。如果元素的 display 属性值为“none”,那么也不会显示在布局树中(但是 visibility 属性值为“hidden”的元素仍会显示)。
有一些 DOM 元素对应多个可视化对象。它们往往是具有复杂结构的元素,无法用单一的矩形来描述。例如,“select”元素有 3 个呈现器:一个用于显示区域,一个用于下拉列表框,还有一个用于按钮。如果由于宽度不够,文本无法在一行中显示而分为多行,那么新的行也会作为新的布局对象而添加。
有一些呈现对象对应于 DOM 节点,但在树中所在的位置与 DOM 节点不同。浮动定位和绝对定位的元素就是这样,它们处于正常的流程之外,放置在树中的其他地方,并映射到真正的框架,而放在原位的是占位框架。
【图:呈现树及其对应的 DOM 树。初始视口亦为 Block 容器】
在 html 标签处,浏览器会创建一个 Block 属性的初始容器。
首屏渲染的必要条件是布局树 (LayoutTree) 构建完毕,而要构建出布局树则需要依赖于 DOM 树和 CSSOM 树的构建。因此加速首屏渲染的关键就是 避免 DOM 树和 CSSOM 树的构建被阻塞。
因为 CSSOM 的构建依赖于 CSS 规则的解析,在 CSS 样式文件没有下载并被解析之前,浏览器是不会进行首屏渲染的。而且 通常情况下(并不是全部情况)即使 CSS 样式规则在 HTML 元素后定义或从外部引入,也还是会阻塞首屏渲染。
CSS 阻塞渲染的特性是完全合乎情理的,毕竟谁都不愿在 CSS 样式规则生效前看到页面上出现了一堆没有样式的文本。当然,在 CSS 资源请求失败的情况下例外(请求失败时,该 CSS 文件不再会阻塞页面)。
现在许多人使用打包工具(如:Webpack 等)将一些图片转成 base64 放在 CSS 文件中以减少页面上发起的网络请求,但你一定要清楚这样的坏处:增加了 CSS 的体积,CSS 文件需要更长的请求时间,因此增加了首屏白屏的时间。
引入外部脚本的 script 标签在文档内位置,在一些情况下会使其之后外部或内联样式的阻塞特性失效。具体情况下面会详细说道。
//...
//...
根据上面说道的内容我们知道,构建布局树不仅仅需要 DOM 还需要 CSSOM,因此只有在 CSSOM 构建完毕后,才能开始构建布局树。
而且通常情况下,即使 CSS 样式在 HTML 元素后引入,也还是会阻塞页面渲染。
CSS 阻塞渲染的特性是完全合乎情理的,毕竟谁都不愿理在 CSSOM 生成前看到页面上出现了一堆没有样式的文本。当然,在 CSS 资源请求失败的情况下例外。
现在许多人会使用打包工具(如:Webpack 等)将一些图片转成 base64 放在 CSS 文件中,但你一定要清楚这样做无形中的增加了 CSS 的体积,增加白屏的时间。
CSS 和 JS 资源在文档中的相对位置也会对 “CSS阻塞效果” 产生不同的影响,具体表现会在下面 DOM、CSSOM 及 JavaScript 相互阻塞关系 一节中提到
JavaScript 允许我们在 DOM 中创建、样式化、追加和移除新元素,即使该元素可能未出现在布局树中。但是我们在脚本中的同步代码却无法找到位于脚本之后的元素,这透露出一个重要的事实:脚本在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建。
或者,稍微换个说法:执行脚本会阻止 DOM 构建,也就延缓了首次渲染。
在网页中引入脚本的另一个微妙事实是,它们不仅可以读取和修改 DOM 属性,还可以读取和修改 CSSOM 属性。如果浏览器尚未完成 CSSOM 的下载和构建,而我们却想在此时运行脚本,会怎样?答案很简单,对性能不利:浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的下载和构建。
在 Blink 下的表现却不总是这样,下一节我们会来分析不同的情况对 JS 执行及渲染阻塞。
为了进一步的确认阻塞渲染的现象,我在本机 Chrome ( v63.0.3239.132 ) 上进行了相关实验并得出了以下结论:
CSS 资源的位置对 JS 脚本执行的阻塞:
CSS 资源与 JS 脚本的相对位置对页面渲染的阻塞:
首选使用异步 JavaScript 资源
默认情况下,JavaScript 资源会阻塞解析器,除非将其标记为 async 或通过专门的 JavaScript 代码段进行添加。阻塞解析器的 JavaScript 会强制浏览器等待 CSSOM 并暂停 DOM 的构建,继而大大延迟首次渲染的时间。
JavaScript 脚本尽可能的放在 HTML 文档底部
尽可能避免阻塞 DOM 树的构建。
避免运行时间长的 JavaScript
运行时间长的 JavaScript 会阻止浏览器构建 DOM、CSSOM 以及渲染网页,所以任何对首次渲染无关紧要的初始化逻辑和功能都应延后执行。如果需要运行较长的初始化序列,请考虑将其拆分为若干阶段,以便浏览器可以间隔处理其他事件。
尽早在 HTML 文档内指定所有 CSS 资源,以便浏览器尽早发现 标记并尽早发出 CSS 请求。
避免使用 CSS import
一个样式表可以使用 CSS import (@import) 指令从另一样式表文件导入规则。不过,应避免使用这些指令,因为它们只有在收到并解析完带有 @import 规则的 CSS 样式表之后,才会发现导入的 CSS 资源。
内联阻塞渲染的 CSS
为获得最佳性能,可以考虑将关键 CSS 直接内联到 HTML 文档内。这样可以减少引用外部网络资源的时间。。
避免将关键 CSS 资源在任何 JS 脚本后引入
这不是一条性能优化的建议,但是如果你将页面关键样式资源在任意 JS 文件后引入,则该 CSS 将会失去阻塞页面渲染的效果,会导致没有改 CSS 没有被附加在布局树上之前,页面就将文档内容渲染出来,尤其是在 Blink 渲染引擎下。
避免将图片转成 base64 放在 CSS 文件中
base64 图片会增加 CSS 文件的体积,因此会造成更长的 CSS 文件加载时间。
在实践中并不需要死守上面的教条,比如说:“使用内联样式” 虽然会减少白屏时间,但却不利于项目的开发维护。因此在实际开发中合理的取舍就显得尤为重要。
当布局树构建完毕后,布局树上并没有每个对象在设备视口内的确切位置和大小,而为它确定这些信息的过程我们称之为 布局(Layout)。
盒模型 就是在 布局阶段 计算并产生的。浏览器会遍历整个 布局树,来为每一个 布局对象 计算出对应的 盒模型数据。
仅仅有 盒模型 还是不能够准确的将每一个 布局对象 准确的绘制到浏览器视口内,浏览器会继续去计算每一个元素在页面中的坐标值,即 x 与 y 值。
Layout 涉及大量的页面计算,往往页面性能瓶颈也是出现这里,因此关于优化这里大有文章可做,具体的会在 “绘制 - 重排与重绘” 一节中讲到
绘制 (Paint) 是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个渲染层上完成的。
“绘制一般是在多个渲染层上完成的”,这里所说的 渲染层 在 WebKit 中被称作 RenderLayer 在 Blink 中称之为 PaintLayer。而将多个渲染层进行合并的步骤称之为 渲染层合并 (Composite)。
渲染层合并 (Composite): 由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。
一般来说,具有相同坐标系的 布局对象 (LayoutObject),隶属同一个 渲染层 (PaintLayer)。渲染层的存在使得页面内的元素能够按照正确的顺序合成,以便能够正确的显示重叠的内容,比如半透明的元素等。有一些特定的条件可以为一些特殊的布局对象创建一个新的渲染层。
根据创建 PaintLayer 的原因不同,可以将其分为常见的 3 类:
NormalPaintLayer
<video>
元素<canvas>
元素OverflowClipPaintLayer
NoPaintLayer
满足以上条件的 布局对象 会拥有独立的渲染层,而其他的 布局对象 则和其第一个拥有 渲染层 的父元素共用一个。
如果为每一个渲染层都单独的绘制一遍 “背衬”(backing surface),这将会浪费大量的内存,因此浏览器会将一些(并不是全部)渲染层组成一个共同的 合成层 (GraphicsLayer)。每个合成层都有相关的渲染层所绘制的 图形上下文 (GraphicsContext)。合成器 (Compositor) 会将图形上下文的以位图的形式输出,最终由 GPU 将多个位图进行合成,最终将页面呈现在浏览器视口中。
和布局对象与渲染层的关系一样,每一个渲染层要么拥有自己的合成层,要么与其他渲染层一起共用一个来自于它们共同祖先的合成层。这里也有一些特定的条件,可以为特定的渲染层创建一个新的合成层,下面列举了一些(具体可以查看 Blink 源码 CompositingReasons.cpp ):
<video>
元素<canvas>
元素除了在页面初始化时浏览器会绘制页面,同时我们还可以使用 JavaScript 来操作页面上的 DOM 元素及其 CSS 样式来完成各种炫酷的变幻效果。当浏览器执行完 JavaScript,并且再次去绘制页面时,开销是巨大的,并且很可能造成页面卡顿,影响体验。
下图称之为 像素管道 (The pixel pipeline),管道的每个部分都有机会产生卡顿,因此务必准确了解您的代码触发管道的哪些部分。
如果修改了元素的“layout”属性,也就是改变了元素的几何属性(例如宽度、高度、左侧或顶部位置等),那么浏览器将必须检查所有其他元素,然后“自动重排”页面。任何受影响的部分都需要重新绘制,而且最终绘制的元素需进行合成。
如果修改“paint only”属性(例如背景图片、文字颜色或阴影等),即不会影响页面布局的属性,则浏览器会跳过布局,但仍将执行绘制。
如果更改一个既不要布局也不要绘制的属性,则浏览器将跳到只执行合成。
仅仅触发合成步骤的开销最小,最适合于应用生命周期中的高压力点,例如动画或滚动。
注:如果想知道更改任何指定 CSS 属性将触发上述三个版本中的哪一个,请查看 CSS 触发器。
正如前文所提到的,如果更改一个既不要布局也不要绘制的属性,则浏览器将跳到只执行合成。目前只有两个 CSS 属性满足 “仅合成器” (Compositor-Only):
因此坚持使用上面两个属性来完成各种动画,是一个简单且立竿见影的优化方式。
此外,将即将进行动画的元素提升到自己的层,可以减少浏览器绘制区域。
创建新层的最佳方式是使用 will-change CSS 属性。并且通过 transform 的值将创建一个新的合成器层:
.moving-element {
will-change: transform;
}
此外,对于不支持 will-change 但受益于层创建的浏览器,如 IOS Safari < 9.3 的浏览器,可以使用 3D 变形来强制创建一个新合成层。
.moving-element {
transform: translateZ(0);
}
不过需要注意的是:虽然提升合成层有利于性能,但是切勿滥用,创建的每一层都需要内存和管理,因此只提升必要的合成层。
如无必要,请勿提升元素。
JavaScript 的执行处于 像素管道 的第一环,减少非必要的 JavaScript 的执行也是优化的重要手段。
通常最好的动画方式是使用 CSS Animation。下面是一个简单的示例:
@keyframes slidein {
from {
transform: scale(1);
}
to {
transform: scale(0);
}
}
.animation {
animation: 3s linear slidein;
}
.target {
width: 100px;
height: 100px;
will-change: transform;
}
// 为 .target 元素来添加动画
document.querySelector('.target').classList.add('animation');
或者,用 transform
+ transition
来完成动画也是不错的:
.target {
width: 100px;
height: 100px;
transform: scale(1);
transition: transform 1s linear;
will-change: transform;
}
.scale {
transform: scale(0);
}
// 为 .target 元素来添加动画
document.querySelector('.target').classList.add('scale');
总而言之,就是将动画的计算交给浏览器去做,而不是在 JS 中做。
下面举一个极端的反面示例:
.target {
width: 100px;
height: 100px;
}
var el = document.querySelector('.target');
var w = 100;
var step = w / 60;
function scale() {
w = w <= step ? 0 : w - step;
el.style.width = w + 'px';
el.style.height = w + 'px';
if (w > 0) {
window.setTimeout(scale, 1000 / 60);
}
}
window.setTimeout(scale, 1000 / 60);
上面的这种写法依旧可以实现前面示例中的效果,但是却犯了很多个致命的错误,很有可能在执行动画时造成卡顿:
首先是 JS 介入到了动画的每一帧的绘制,这就意味着每一帧动画的绘制都将要经过 “像素管道” 的流程。
其次是使用了 width 和 height 属性来实现动画,这两个属性的改变将会触发元素的 Layout, Paint 和 Composite。因此每一帧的绘制都会触发页面的重排重绘,这往往也是流畅动画的性能瓶颈所在。
最后需要一提的是,如果在某些特殊情况下需要使用 JS 来实现动画,请总是使用 requestAnimationFrame
来代替 setTimeout
和 setInterval
。
浏览器是网页寄宿的重要环境,了解浏览器的工作原理是每一个前端工程师必备的硬性素质之一。无论是页面渲染优化,或疑难杂症的排查都极度依赖于对浏览器工作原理的熟悉程度。本文基本上将浏览器渲染工作原理梳理了一遍,着重描述了布局和绘制阶段,并写下了自己在日常工作中所总结的优化技巧与最佳实践,希望能给大家带来一些帮助。
当然,本文对于浏览器渲染的分析尚停留在表层原理上,对于一些结论还只能停留在表现上,没法为大家抽丝剥茧,还望各位看官不要见笑。