@pspgbhu
2018-02-07T08:31:52.000000Z
字数 8005
阅读 1339
Base
浏览器渲染相关的知识点大家或多或少都会有一定的接触与了解,但是这些知识如果没有将其相互关联起来,建立起一定的体系话,这些零散的知识点将很难体现出它们应有的价值。
通过这次分享,大家将会有如下的收获:
当浏览器请求到一个 HTML 文档,并向页面渲染出相应的内容,其渲染流程示意如下:
主要分为四个步骤:
解析 HTML 标记形成 DOM 树,解析 CSS 标记 形成 CSSOM 树。
DOM 树和 CSSOM 树将共同创建另一个树:布局树
对布局树排版布局,来确定每个节点在视口内确切位置和大小。
渲染引擎遍历整个布局树来将每个节点绘制出来。
这里说的树是指 DOM 树和 CSSOM 树。
<body>
等)浏览器渲染流程的开始通常是从一个 HTML 文档的请求开始的,下图来自 W3C 描述了 HTML 解析的大致流程:
HTML 解析基本流程:网络 -> 字节 -> 字符 -> 标记化 -> 树构建
整个过程具体为:
在 Network 阶段我们会根据浏览器 URL 来 请求回相应的 HTML 文档;
字节流解码器 (Byte Stream Decoder) 按照请求回的 HTML 文件的字符编码格式将字节 解码为字符;
随后被 解码的字符 和来自 直接操纵输入流的各种 API(如 document.write()
)传入的字符一起放入了 输入流预处理器 (Input Stream Preprocessor),以供后续标记化阶段使用;
进入 标记化阶段 之后,标记生成器 会根据标记化算法来解析 HTML 文档,输出结果是 HTML 标记(像是 <html>
<body>
)。标记化算法根据 HTML 代码中 HTML 标签的闭合来创建 HTML 标记。值得一提的是,浏览器在解析 HTML 增添了许多容错机制,使浏览器能够解析不规范的 HTML 语法;
在解析器创建的时候,也会创建 Document 对象。在 树构建阶段 以 Document 为根节点的 DOM 树也会不断地进行修改,向其中添加各种元素。标记生成器发送的每个节点都会由树构建器进行处理。当解析器解析到 script 标签时,主解析器会暂停之后的解析,直到脚本执行完毕。如果在脚本中更改了 DOM 结构,解析器会重新解析文档,因此 HTML 文档的解析往往是一个反复的过程。
script 标记及 JS 脚本的执行会阻塞 DOM 解析。
当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建。
如果 JavaScript 更改了 DOM 结构,则解析器将会重新解析部分 HTML 文档。
与处理 HTML 时一样,我们需要将收到的 CSS 规则转换成某种浏览器能够理解和处理的东西。因此,我们会重复类似于构建 DOM 的步骤,不过这次是为了 CSS:
CSS 规则的来源:
CSSOM 与 DOM 是分别独立的数据结构。
部分样式的可继承性使 CSSOM 具备了树状结构。
从上图可以看出,CSSOM 树的结构是完全依赖于 DOM 树的结构的。
同时,CSS 规则与 DOM 节点的匹配是一个相当复杂和有性能问题的事情。所以,我们常常能再各个地方看到一些忠告:DOM 树要小,CSS 尽量使用 id 和 class,千万不要过渡层叠下去...
比如:
/* bad */
div div div {
...
}
/* good */
.target {
...
}
后者的匹配效率是远远大于前者的。
主解析器在遇见 script 标签时会暂停解析,并将控制权交给 js 引擎。
此时其他线程会继续去解析文档的剩余部分,找出并加载需要通过网络加载的其他资源。这样就可以使网络资源并行加载,从而提高整体速度,这称之为 预解析。
预解析是不会去修改 DOM 树的,DOM 树的构建仅仅由主解析器进行。
WebKit 中称布局树为 RenderTree 而 Blink 则称之为 LayoutTree
从文章开始的流程图那里可以看出,DOM 树与 CSSOM 树共同构建成了一个新的树:布局树。
WebKit 将布局对象称之为 RenderObject,而在 Blink 中则称之为 LayoutObject。
同 DOM 与 DOM 树的关系一样,布局树也同样是由布局对象按一定的结构构建而来。
每一个布局对象都代表了一个矩形的区域,通常对应于相关节点的 CSS 盒模型,它包含诸如宽度、高度和位置等几何信息。布局对象的类型会受到节点相关的 display 属性所影响。
提问: 请从布局对象的角度来猜测一下 display: none;
与 visibility: hidden;
的区别。
从 DOM 树的根节点开始遍历每个可见节点
display: none
样式属性的节点也不会被添加在布局树上。对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。
连同其内容和计算的样式来构建一个布局对象
由于 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 解析至第一个外部脚本的时,布局树将会提前构建。
布局/重排(Layout):当布局树构建完毕后,布局树上并没有每个对象在设备视口内的确切位置和大小,而为它确定这些信息的过程。
盒模型 就是在布局阶段计算并产生的。浏览器会遍历整个布局树,来为每一个 布局对象 计算出对应的 盒模型数据。
仅仅有 盒模型 还是不能够准确的将每一个 布局对象 准确的绘制到浏览器视口内,浏览器会继续去计算每一个元素在页面中的坐标值,即 x 与 y 值。
过深的 DOM 层级会增加浏览器遍历布局树的难度,带来额外的性能消耗,因此我们最好刻意的控制一下 DOM 的层级。
使用 JS 改变 DOM 元素的一些属性时,往往会引起其所有子组件的重新布局。
绘制 (Paint) 是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个绘制层上完成的。
“绘制一般是在多个上完成的”,这里所说的 绘制层 在 WebKit 中被称作 RenderLayer 在 Blink 中称之为 PaintLayer。而将多个进行合并的步骤称之为 绘制层合并 (Composite)。
绘制层合并 (Composite): 由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。
一般来说,具有相同坐标系的 布局对象 (LayoutObject),隶属同一个 绘制层 (PaintLayer)。绘制层的存在使得页面内的元素能够按照正确的顺序合成,以便能够正确的显示重叠的内容,比如半透明的元素等。
有一些特定的条件可以为一些特殊的布局对象创建一个新的。
根据创建 PaintLayer 的原因不同,可以将其分为常见的 3 类:
NormalPaintLayer
<video>
元素<canvas>
元素OverflowClipPaintLayer
NoPaintLayer
满足以上条件的 布局对象 会拥有独立的,而其他的 布局对象 则和其第一个拥有 **** 的父元素共用一个。
如果为每一个都单独的绘制一遍 “背衬”(backing surface),这将会浪费大量的内存,因此浏览器会将一些(并不是全部)组成一个共同的 合成层 (GraphicsLayer)。
每个合成层都有相关的所绘制的图形上下文 (GraphicsContext)。合成器 (Compositor) 会将图形上下文的以位图的形式输出,最终由 GPU 将多个位图进行合成,最终将页面呈现在浏览器视口中。
和布局对象与的关系一样,每一个要么拥有自己的合成层,要么与其他一起共用一个来自于它们共同祖先的合成层。这里也有一些特定的条件,可以为特定的创建一个新的合成层,下面列举了一些:
<video>
元素<canvas>
元素更多可以查看 CompositingReasons.cpp
至此,图像就在浏览器上渲染出来了。
首屏渲染的瓶颈主要出现在 Layout Tree 的构建上。是由于
JavaScript 是单线程的,如果在初始化页面的时候,你的代码中存在大量同步运行的代码,导致 JS 线程一直处于繁忙状态,这时候用户在页面上进行交互时将不会得到任何反应,就像是卡死了一样。
先看下面这个例子:在 CSS 文件后通过 background-image 样式属性来引入图片资源。
<link rel="stylesheet" href="/static/style.css">
<div id="div-outer" style="display: none; background-image: url('/static/outer.png');"></div>
我们要观察它们的加载顺序并不难,通过 Chrome DevTools 里的 Network 便能一目了然:
很明显,在 CSS 文件加载完成后图片资源才开始引入的。但这是为什么呢?
请看下图:
CSS 中的图片资源是在 Recalculate Style 阶段开始下载的。即 CSS 中的图片资源是在布局树构建时才开始解析下载的。
在布局树构建阶段,浏览器会去遍历整个 DOM 树并计算相应节点的样式规则,在样式属性中所引入的图片资源也是在这个阶段才开始下载的,因此在 CSS 文件没有下载完成之前布局树是无法构建的,所以上面的图片才会在 CSS 文件下载完成后才开始加载。
现在我们来看另外一个例子:
<div style="display: none;">
<div id="div-inner" style="background-image: url('/static/inner.png');">
</div>
</div>
<div id="div-outer" style="display: none; background-image: url('/static/outer.png');"></div>
让我们再看一下 Chrome DevTools
和上一个例子一模一样,只有 outer.png 被加载了。
仔细观察一下 #div-inner
是被包裹在一个 display: none
的 div 元素下。
当浏览器去遍历 DOM 树来构建布局树的时候,如果一个 DOM 元素的样式属性为 display: none
时,该元素是不会再布局树中生成布局对象的,因此浏览器只遍历了该元素的最外一层,对于其子元素浏览器不会再去遍历。
只有在读取到了该 DOM 元素对应的 CSS 属性有图片资源引用时,浏览器才会去下载。像上面这个例子,#div-inner
其父元素是 display: none
导致 #div-inner
元素不会在布局树构建阶段被遍历到,因此浏览器便不会去加载这个图片。
我们再看看这个例子:
<div style="display: none;">
<img src="/static/img_inner.png" alt="">
</div>
大家来猜一下这个结果;
By the way, 现在浏览器 img 标签 src 设置为空,并不会发起一次空的请求,但是为了兼容旧版浏览器,还是建议不要这么做。
提问:集思广益 总结一些优化策略,并解释其原理。
除了在页面初始化时浏览器会绘制页面,同时我们还可以使用 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 的执行也是优化的重要手段。
问题:现在小明要在页面上写一个动画,效果是一个正方形盒子由大到小缩放。
下面是小明写的:
.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);
上面的这种写法可以实现小明想要的效果,但是却又很多性能隐患,很有可能在执行动画时造成卡顿。
提问:指出小明的错误,并给出 正确的方案: