[关闭]
@pspgbhu 2018-02-07T08:31:52.000000Z 字数 8005 阅读 1339

浏览器渲染原理分享

Base


前言

浏览器渲染相关的知识点大家或多或少都会有一定的接触与了解,但是这些知识如果没有将其相互关联起来,建立起一定的体系话,这些零散的知识点将很难体现出它们应有的价值。

通过这次分享,大家将会有如下的收获:

浏览器渲染流程概览

关键词

流程概览

当浏览器请求到一个 HTML 文档,并向页面渲染出相应的内容,其渲染流程示意如下:

rendering-process

主要分为四个步骤:

  1. 解析 HTML 标记形成 DOM 树,解析 CSS 标记 形成 CSSOM 树。

  2. DOM 树和 CSSOM 树将共同创建另一个树:布局树

  3. 对布局树排版布局,来确定每个节点在视口内确切位置和大小。

  4. 渲染引擎遍历整个布局树来将每个节点绘制出来。

文档的解析与树的构建

这里说的树是指 DOM 树和 CSSOM 树。

关键词:

HTML 解析与 DOM 树的构建

浏览器渲染流程的开始通常是从一个 HTML 文档的请求开始的,下图来自 W3C 描述了 HTML 解析的大致流程:

HTML Parse

HTML 解析基本流程:网络 -> 字节 -> 字符 -> 标记化 -> 树构建

整个过程具体为:

  1. 在 Network 阶段我们会根据浏览器 URL 来 请求回相应的 HTML 文档

  2. 字节流解码器 (Byte Stream Decoder) 按照请求回的 HTML 文件的字符编码格式将字节 解码为字符

  3. 随后被 解码的字符 和来自 直接操纵输入流的各种 API(如 document.write())传入的字符一起放入了 输入流预处理器 (Input Stream Preprocessor),以供后续标记化阶段使用;

  4. 进入 标记化阶段 之后,标记生成器 会根据标记化算法来解析 HTML 文档,输出结果是 HTML 标记(像是 <html> <body>)。标记化算法根据 HTML 代码中 HTML 标签的闭合来创建 HTML 标记。值得一提的是,浏览器在解析 HTML 增添了许多容错机制,使浏览器能够解析不规范的 HTML 语法;

  5. 在解析器创建的时候,也会创建 Document 对象。在 树构建阶段Document 为根节点的 DOM 树也会不断地进行修改,向其中添加各种元素。标记生成器发送的每个节点都会由树构建器进行处理。当解析器解析到 script 标签时,主解析器会暂停之后的解析,直到脚本执行完毕。如果在脚本中更改了 DOM 结构,解析器会重新解析文档,因此 HTML 文档的解析往往是一个反复的过程。

HTML 解析的阻塞

CSS 的解析与 CSSOM 树的构建

与处理 HTML 时一样,我们需要将收到的 CSS 规则转换成某种浏览器能够理解和处理的东西。因此,我们会重复类似于构建 DOM 的步骤,不过这次是为了 CSS:

CSSOM-process

CSS 规则的来源:

CSSOM 与 DOM 是分别独立的数据结构。

部分样式的可继承性使 CSSOM 具备了树状结构。

cssom-tree

从上图可以看出,CSSOM 树的结构是完全依赖于 DOM 树的结构的。

同时,CSS 规则与 DOM 节点的匹配是一个相当复杂和有性能问题的事情。所以,我们常常能再各个地方看到一些忠告:DOM 树要小,CSS 尽量使用 id 和 class,千万不要过渡层叠下去...

比如:

  1. /* bad */
  2. div div div {
  3. ...
  4. }
  5. /* good */
  6. .target {
  7. ...
  8. }

后者的匹配效率是远远大于前者的。

预解析

主解析器在遇见 script 标签时会暂停解析,并将控制权交给 js 引擎。

此时其他线程会继续去解析文档的剩余部分,找出并加载需要通过网络加载的其他资源。这样就可以使网络资源并行加载,从而提高整体速度,这称之为 预解析

预解析是不会去修改 DOM 树的,DOM 树的构建仅仅由主解析器进行。

布局树(渲染树)

关键词

WebKit 中称布局树为 RenderTree 而 Blink 则称之为 LayoutTree

从文章开始的流程图那里可以看出,DOM 树与 CSSOM 树共同构建成了一个新的树:布局树。

render-tree-construction.png-34.1kB

布局对象

WebKit 将布局对象称之为 RenderObject,而在 Blink 中则称之为 LayoutObject

同 DOM 与 DOM 树的关系一样,布局树也同样是由布局对象按一定的结构构建而来。

每一个布局对象都代表了一个矩形的区域,通常对应于相关节点的 CSS 盒模型,它包含诸如宽度、高度和位置等几何信息。布局对象的类型会受到节点相关的 display 属性所影响。

提问: 请从布局对象的角度来猜测一下 display: none;visibility: hidden; 的区别。

查看答案

布局树的构建流程

  1. 从 DOM 树的根节点开始遍历每个可见节点

    • 某些不可见节点(如 script 标签, meta 标签等)不需要渲染在页面中,因此会被忽略。
    • 设置了 display: none 样式属性的节点也不会被添加在布局树上。
  2. 对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。

    • 解析样式和创建布局对象的过程称为 “Attachment” 。每个 DOM 节点都有一个 “attach” 方法。附加是同步进行的,将节点插入 DOM 树需要调用新的节点 “attach” 方法。
  3. 连同其内容和计算的样式来构建一个布局对象

  4. 由于 DOM 树的影响,布局对象之间也构成了树状结构:布局树

    • 布局树虽然和 DOM 有联系,但结构并不完全相同。

布局树 和 Dom 树的关系

布局树的结构完全依赖于 DOM 树的结构,布局对象是和 DOM 元素相对应的,但并非一一对应。非可视化的 DOM 元素不会插入布局树中。

如:meta 元素等非可视化元素。同时 display 属性值为 none 的元素也不会出现在布局树中。

浮动定位和绝对定位的元素处于正常的文本流之外,放置在树中的其他地方,而在其原为的只是一个占位对象。

对于一些特殊的 HTML 元素,如 select, video 等,浏览器会通过添加 shadow DOM 的方式来为其添加多个默认的 DOM 元素节点。

可在 Chrome DevTools 中 Settings -> Preferences -> Elements -> Show user agent shadow DOM 开启对 shadow DOM 的元素审查。

还有一点需要验证的是布局树是不是需要等到 DOM 树构建完毕后才开始构建呢?

结论是:HTML 解析至第一个外部脚本的时,布局树将会提前构建

js-load-before.png-195.4kB
js-load-after.png-172.5kB

布局阶段

布局/重排(Layout):当布局树构建完毕后,布局树上并没有每个对象在设备视口内的确切位置和大小,而为它确定这些信息的过程。

盒模型 就是在布局阶段计算并产生的。浏览器会遍历整个布局树,来为每一个 布局对象 计算出对应的 盒模型数据

此处输入图片的描述

仅仅有 盒模型 还是不能够准确的将每一个 布局对象 准确的绘制到浏览器视口内,浏览器会继续去计算每一个元素在页面中的坐标值,即 x 与 y 值。

过深的 DOM 层级会增加浏览器遍历布局树的难度,带来额外的性能消耗,因此我们最好刻意的控制一下 DOM 的层级。

使用 JS 改变 DOM 元素的一些属性时,往往会引起其所有子组件的重新布局。

绘制和合成阶段

关键词

绘制层 (PaintLayer) 及绘制层合并 (Composite)

绘制 (Paint) 是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个绘制层上完成的。

“绘制一般是在多个上完成的”,这里所说的 绘制层 在 WebKit 中被称作 RenderLayer 在 Blink 中称之为 PaintLayer。而将多个进行合并的步骤称之为 绘制层合并 (Composite)

绘制层合并 (Composite): 由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。

一般来说,具有相同坐标系的 布局对象 (LayoutObject),隶属同一个 绘制层 (PaintLayer)。绘制层的存在使得页面内的元素能够按照正确的顺序合成,以便能够正确的显示重叠的内容,比如半透明的元素等。

有一些特定的条件可以为一些特殊的布局对象创建一个新的。

根据创建 PaintLayer 的原因不同,可以将其分为常见的 3 类:

满足以上条件的 布局对象 会拥有独立的,而其他的 布局对象 则和其第一个拥有 **** 的父元素共用一个。

合成层 (GraphicsLayer)

如果为每一个都单独的绘制一遍 “背衬”(backing surface),这将会浪费大量的内存,因此浏览器会将一些(并不是全部)组成一个共同的 合成层 (GraphicsLayer)

每个合成层都有相关的所绘制的图形上下文 (GraphicsContext)。合成器 (Compositor) 会将图形上下文的以位图的形式输出,最终由 GPU 将多个位图进行合成,最终将页面呈现在浏览器视口中。

和布局对象与的关系一样,每一个要么拥有自己的合成层,要么与其他一起共用一个来自于它们共同祖先的合成层。这里也有一些特定的条件,可以为特定的创建一个新的合成层,下面列举了一些:

更多可以查看 CompositingReasons.cpp

至此,图像就在浏览器上渲染出来了。

性能

首屏渲染阻塞

rendering-process

首屏渲染的瓶颈主要出现在 Layout Tree 的构建上。是由于

首屏交互响应阻塞

JavaScript 是单线程的,如果在初始化页面的时候,你的代码中存在大量同步运行的代码,导致 JS 线程一直处于繁忙状态,这时候用户在页面上进行交互时将不会得到任何反应,就像是卡死了一样。

图片资源的加载

1. 图片资源是在布局树构建时才开始解析下载

先看下面这个例子:在 CSS 文件后通过 background-image 样式属性来引入图片资源。

  1. <link rel="stylesheet" href="/static/style.css">
  2. <div id="div-outer" style="display: none; background-image: url('/static/outer.png');"></div>

我们要观察它们的加载顺序并不难,通过 Chrome DevTools 里的 Network 便能一目了然:

屏幕快照 2018-02-05 下午8.33.33.png-23kB

很明显,在 CSS 文件加载完成后图片资源才开始引入的。但这是为什么呢?

请看下图:

css-request-img.png-123.3kB

CSS 中的图片资源是在 Recalculate Style 阶段开始下载的。即 CSS 中的图片资源是在布局树构建时才开始解析下载的。

在布局树构建阶段,浏览器会去遍历整个 DOM 树并计算相应节点的样式规则,在样式属性中所引入的图片资源也是在这个阶段才开始下载的,因此在 CSS 文件没有下载完成之前布局树是无法构建的,所以上面的图片才会在 CSS 文件下载完成后才开始加载。

2. 外层包裹 display: none 的元素,则 CSS 中引入的图片不会被加载

现在我们来看另外一个例子:

  1. <div style="display: none;">
  2. <div id="div-inner" style="background-image: url('/static/inner.png');">
  3. </div>
  4. </div>
  5. <div id="div-outer" style="display: none; background-image: url('/static/outer.png');"></div>

让我们再看一下 Chrome DevTools

屏幕快照 2018-02-05 下午8.33.33.png-23kB

和上一个例子一模一样,只有 outer.png 被加载了。

仔细观察一下 #div-inner 是被包裹在一个 display: none 的 div 元素下。

当浏览器去遍历 DOM 树来构建布局树的时候,如果一个 DOM 元素的样式属性为 display: none 时,该元素是不会再布局树中生成布局对象的,因此浏览器只遍历了该元素的最外一层,对于其子元素浏览器不会再去遍历。

只有在读取到了该 DOM 元素对应的 CSS 属性有图片资源引用时,浏览器才会去下载。像上面这个例子,#div-inner 其父元素是 display: none 导致 #div-inner 元素不会在布局树构建阶段被遍历到,因此浏览器便不会去加载这个图片。

3. 通过 img 来引用图片

我们再看看这个例子:

  1. <div style="display: none;">
  2. <img src="/static/img_inner.png" alt="">
  3. </div>

大家来猜一下这个结果

By the way, 现在浏览器 img 标签 src 设置为空,并不会发起一次空的请求,但是为了兼容旧版浏览器,还是建议不要这么做。

优化首屏性能

提问:集思广益 总结一些优化策略,并解释其原理。

页面动画性能

除了在页面初始化时浏览器会绘制页面,同时我们还可以使用 JavaScript 来操作页面上的 DOM 元素及其 CSS 样式来完成各种炫酷的变幻效果。当浏览器执行完 JavaScript,并且再次去绘制页面时,开销是巨大的,并且很可能造成页面卡顿,影响体验。

下图称之为 像素管道 (The pixel pipeline),管道的每个部分都有机会产生卡顿,因此务必准确了解您的代码触发管道的哪些部分。

css triggers

此处输入图片的描述

1. JS / CSS > 样式 > 重排 > 重绘 > 合成

此处输入图片的描述

如果修改了元素的“layout”属性,也就是改变了元素的几何属性(例如宽度、高度、左侧或顶部位置等),那么浏览器将必须检查所有其他元素,然后“自动重排”页面。任何受影响的部分都需要重新绘制,而且最终绘制的元素需进行合成。

2. JS / CSS > 样式 > 重绘 > 合成

此处输入图片的描述

如果修改“paint only”属性(例如背景图片、文字颜色或阴影等),即不会影响页面布局的属性,则浏览器会跳过布局,但仍将执行绘制。

3. JS / CSS > 样式 > 合成

此处输入图片的描述

如果更改一个既不要布局也不要绘制的属性,则浏览器将跳到只执行合成。

仅仅触发合成步骤的开销最小,最适合于应用生命周期中的高压力点,例如动画或滚动。

注:如果想知道更改任何指定 CSS 属性将触发上述三个版本中的哪一个,请查看 CSS 触发器。

Compositor-Only 与流畅动画

1. Compositor-Only(流畅动画的最重要条件)

正如前文所提到的,如果更改一个既不要布局也不要绘制的属性,则浏览器将跳到只执行合成。目前只有两个 CSS 属性满足 “仅合成器” (Compositor-Only)

因此坚持使用上面两个属性来完成各种动画,是一个简单且立竿见影的优化方式。

2. 将动画元素提升到自己的层

此外,将即将进行动画的元素提升到自己的层,可以减少浏览器绘制区域。

创建新层的最佳方式是使用 will-change CSS 属性。并且通过 transform 的值将创建一个新的合成器层:

  1. .moving-element {
  2. will-change: transform;
  3. }

此外,对于不支持 will-change 但受益于层创建的浏览器,如 IOS Safari < 9.3 的浏览器,可以使用 3D 变形来强制创建一个新合成层。

  1. .moving-element {
  2. transform: translateZ(0);
  3. }

不过需要注意的是:虽然提升合成层有利于性能,但是切勿滥用,创建的每一层都需要内存和管理,因此只提升必要的合成层。

如无必要,请勿提升元素。

3. 减少 JavaScript 的介入(非常重要)

JavaScript 的执行处于 像素管道 的第一环,减少非必要的 JavaScript 的执行也是优化的重要手段。

问题:现在小明要在页面上写一个动画,效果是一个正方形盒子由大到小缩放。

下面是小明写的:

  1. .target {
  2. width: 100px;
  3. height: 100px;
  4. }
  1. var el = document.querySelector('.target');
  2. var w = 100;
  3. var step = w / 60;
  4. function scale() {
  5. w = w <= step ? 0 : w - step;
  6. // 更改元素的宽高
  7. el.style.width = w + 'px';
  8. el.style.height = w + 'px';
  9. if (w > 0) {
  10. window.setTimeout(scale, 1000 / 60);
  11. }
  12. }
  13. window.setTimeout(scale, 1000 / 60);

上面的这种写法可以实现小明想要的效果,但是却又很多性能隐患,很有可能在执行动画时造成卡顿。

提问:指出小明的错误,并给出 正确的方案

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注