阅读 2579
深入理解浏览器解析渲染HTML

深入理解浏览器解析渲染HTML

前言

作为Web工程师,我们每天写HTML,CSS和JavaScript,但是浏览器是如何解析这些文件,最终将它们以像素显示在屏幕上的呢?

这一过程叫做Critical Rendering Path

Critical Rendering Path

Critical Rendering Path,中文翻译过来,叫做关键渲染路径。指的是浏览器从请求HTML,CSS,JavaScript文件开始,到将它们最终以像素输出到屏幕上这一过程。包括以下几个部分:

  1. 构建DOM
    • 将HTML解析成许多Tokens
    • 将Tokens解析成object
    • 将object组合成为一个DOM树
  2. 构建CSSOM
    • 解析CSS文件,并构建出一个CSSOM树(过程类似于DOM构建)
  3. 构建Render Tree
    • 结合DOM和CSSOM构建出一颗Render树
  4. Layout
    • 计算出元素相对于viewport的相对位置
  5. Paint
    • 将render tree转换成像素,显示在屏幕上

值得注意的是,上面的过程并不是依次进行的,而是存在一定交叉,后面会详细解释。

想要提高网页加载速度,提升用户体验,就需要在第一次加载时让重要的元素尽快显示在屏幕上。而不是等所有元素全部准备就绪再显示,下面一幅图说明了这两种方式的差异。

为了便于理解,我下面使用Chrome Dev Tool的Performance来描述CRP过程,但是根据官网文档,DevTool并不适合特别精确细致的分析,不过对于理解CRP,已经足够。

构建DOM

DOM (Document Object Model,文档对象模型),构建DOM是必不可少的一环,浏览器从发出请求开始到得到HTML文件后,第一件事就是将HTML解析成许多Tokens,再将Tokens转换成object,最后将object组合成一颗DOM树。

这个过程是循序渐进的,我们假设HTML文件很大,一个RTT (Round-Trip Time,往返时延) 只能得到一部分,浏览器得到这部分之后就会开始构建DOM,并不会等到整个文档就位才开始渲染。这样做可以加快构建过程,而且由于自顶向下构建,后面的构建不会对前面造成影响。而后面我们将会看到,CSSOM则必须等到所有字节收到才开始构建。

这一部分对应Dev Tool Performance Panel里的Parse HTML过程。

构建CSSOM

CSSOM (CSS Object Model,CSS对象模型),构建过程类似DOM,当HTML解析中遇到<link>标签时,会请求对应的CSS文件,当CSS文件就位时便开始解析它(如果遇到行内<style>时则直接解析),这一解析过程可以和构建DOM同时进行。

假设有如下CSS代码:

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

构建出来的CSSOM是这样的:

需要注意的是,上面并不是一颗完整的CSSOM树,文档有一些默认的CSS样式,称作user agent styles,上面只展示了我们覆盖的部分。

如果是外部样式,CSSOM的构建必须要获得一份完整的CSS文件,而不像DOM的构建是一个循序渐进的过程。因为CSS文件中包含大量的样式,后面的样式会覆盖前面的样式,如果我们提前就构建CSSOM,可能会得到错误的结果。

这一部分对应Dev Tool Performance Panel里的过程如下:

  1. 如果是内联样式,CSSOM构建包含在Parse HTML过程中
  2. 如果是外部样式,包含在Parse Stylesheet过程中
  3. 如果没有设置样式,使用User Agent Style,则包含在Parse HTML过程中

构建Render Tree

这也是关键的一步,浏览器使用DOM和CSSOM构建出Render Tree。此时不像构建DOM一样把所有节点构建出来,浏览器只构建需要在屏幕上显示的部分,因此像<head>,<meta>这些标签就无需构建了。同时,对于display: none的元素,也无需构建。

display: none告诉浏览器这个元素无需出现在Render Tree中,但是visibility: hidden只是隐藏了这个元素,但是元素还占空间,会影响到后面的Layout,因此仍然需要出现在Render Tree中。

构建过程遵循以下步骤:

  1. 浏览器从DOM树开始,遍历每一个“可见”节点。
  2. 对于每一个"可见"节点,在CSSOM上找到匹配的样式并应用。
  3. 生成Render Tree。

这一部分对应Dev Tool Performance Panel里Layout过程

扩展:CSS匹配规则为何从右向左

相信大多数初学者都会认为CSS匹配是左向右的,其实恰恰相反。学习了CRP,也就不难理解为什么了。

CSS匹配发生在Render Tree构建时(Chrome Dev Tools将其归属于Layout过程)。此时浏览器构建出了DOM,而且拿到了CSS样式,此时要做的就是把样式跟DOM上的节点对应上,浏览器为了提高性能需要做的就是快速匹配。

首先要明确一点,浏览器此时是给一个"可见"节点找对应的规则,这和jQuery选择器不同,后者是使用一个规则去找对应的节点,这样从左到右或许更快。但是对于前者,由于CSS的庞大,一个CSS文件中或许有上千条规则,而且对于当前节点来说,大多数规则是匹配不上的,到此为止,稍微想一下就知道,如果从右开始匹配(也是从更精确的位置开始),能更快排除不合适的大部分节点,而如果从左开始,只有深入了才会发现匹配失败,如果大部分规则层级都比较深,就比较浪费资源了。

除了上面这点,我们前面还提到DOM构建是"循序渐进的",而且DOM不阻塞Render Tree构建(只有CSSOM阻塞),这样也是为了能让页面更早有元素呈现。考虑如下情况,如果我们此时构建的只是部分DOM,而CSSOM构建完成,浏览器就会构建Render Tree。这个时候对每一个节点,如果找到一条规则从右向左匹配,我们只需要逐层观察该节点父节点是否匹配,而此时其父节点肯定已经在DOM上。但是反过来,我们可能会匹配到一个DOM上尚未存在的节点,此时的匹配过程就浪费了资源。

Layout

我们现在为止已经得到了所有元素的自身信息,但是还不知道它们相对于Viewport的位置和大小,Layout这一过程需要计算的就是这两个信息。

根据这两个信息,Layout输出元素的Box Model(盒模型),关于这个,我也写过一篇文章深入理解文档流(Document Flow)和视觉格式化模型(CSS Visual Formatting Model)

这里的Layout阶段和Chrome Dev Tool中的Layout过程是不同的。根据官网文档的解释,Layout过程捕捉了Render Tree构建和Layout部分:

The "Layout" event captures the render tree construction, position, and size calculation in the Timeline.

目前为止,我们已经拿到了元素相对于Viewport的详细信息,所有的值都已经计算为相对Viewport的精确像素大小和位置,就差显示了。

Paint

浏览器将每一个节点以像素显示在屏幕上,最终我们看到页面。

这一过程需要的时间与文档大小、CSS应用样式的数量和复杂度、设备自身都有关,例如对简单的颜色进行Paint是简单的,但是box-shadow进行paint则是复杂的。

这一部分对应Dev Tool Performance Panel里Paint过程

引入JavaScript

前面的过程都没有提到JavaScript,但在如今,JavaScript却是网页中不可缺的一部分。这里对它如何影响CRP做一个概要,具体细节我后面使用Chrome Dev Tools进行了测验。

  1. 解析HTML构建DOM时,遇到JavaScript会被阻塞
  2. JavaScript执行会被CSSOM构建阻塞,也就是说,JavaScript必须等到CSSOM构建完成后才会执行
  3. 如果使用异步脚本,脚本的网络请求优先级降低,且网络请求期间不阻塞DOM构建,直到请求完成才开始执行脚本

使用Chrome Dev Tools检测CRP

为了模拟真实网络情况,我把Demo部署到了我的githubpage,你也可以在仓库找到源代码

同时,不要混淆DOM, CSSOM, Render Tree这三个概念,我刚开始就容易混淆DOM和Render Tree,这两个是不同的

下面的Chrome截图部分,如果不清晰,请直接点击图片查看原图

0. 代码部分

HTML

<html>
  
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="../css/main.css" />
  <title>Critical Rendering Path with separate script file</title>
</head>

<body>
  <p>What's up? <span>Bros. </span>My name is tianzhich</p>
  <div><img src="../images/xiaoshuang.jpg" alt="小爽-流星雨" height="500"></div>
  <script src="../js/main.js"></script>
</body>
  
</html>
复制代码

JavaScript

var span = document.getElementsByTagName('span')[0];
span.textContent = 'Girls. '; // change DOM text content
span.style.display = 'inline';  // change CSSOM property

// create a new element, style it, and append it to the DOM
var loadTime = document.createElement('div');
loadTime.textContent = 'You loaded this page on: ' + new Date();
loadTime.style.color = 'blue';
document.body.appendChild(loadTime);
复制代码

CSS

/* // [START full] */
body {
  font-size: 16px
}

p {
  font-weight: bold
}

span {
  color: red
}

p span {
  display: none
}

img {
  float: right
}
/* // [END full] */
复制代码

1. 不加载JS情况

首先来看没有加载JS的情况

上图中,浏览器收到HTML文件后,便开始解析构建DOM。

需要注意,上图接收的只是图片的一部分

接下来我们详细看看这三个部分:

DOM构建

可以看出,浏览器解析到<link><img>等等标签时,会马上发出HTTP请求,而且解析也将继续进行,解析完成后会触发readystatechange事件和DOMContentLoaded事件,在上图中,由于时间间隔已经到了100微秒级别,事件间隔有些许差异,但不影响我们对这一过程的理解。

细心的话可能会注意到上图中还触发了Recalculate Style (紫色部分),这一过程表示CSS样式的一些计算过程。但是此时我们并没有拿到CSS,这一部分从何而来呢?我在下面第4部分做了分析。

页面首次出现画面

下面这一过程依次展示了CSS解析构建CSSOM,Render Tree生成,layout和paint,最终页面首次出现画面

从这里我们可以看出,DOM即使构建完成,也需要等CSSOM构建完成,才能经过一个完整的CRP并呈现画面,因此为了画面尽快呈现,我们需要尽早构建出CSSOM,比如:

  1. html文档中的<style>或者<link>标签应该放在<head>里并尽早发现被解析(第4部分我会分析将这两个标签放在html文档后面造成的影响)
  2. 减少第一次请求的CSS文件大小
  3. 甚至可以将最重要部分的CSS Rule以<style>标签发在<head>里,无需网络请求

页面首次出现图片

上图说明,浏览器接收到部分图片字节后,便开始渲染了,而不是等整张图片接收完成才开始渲染,至于渲染次数,本例中的图片大小为90KB左右,传输了6次,渲染了2次。我觉得这应该和网络拥塞程度以及图片大小等因素有关。

还有一点需要注意,两次渲染中,只有首次渲染引发了Layout和之后的Update Layer Tree,而第二次渲染只有Update Layer Tree,我猜想对于图片来说,浏览器第一次渲染便知道了其大小,所以重新进行Layout并留出足够空间,之后的渲染只需要在该空间上进行paint即可。整张图片加载完毕之后,触发Load事件。

上图包括之后图片中的Chrome扩展脚本可以忽视,虽然使用了隐私模式做测验(避免缓存和一些扩展脚本的影响),但我发现还是有一个脚本无法去除,虽然这不影响测验结果。

接下来我们考虑JavaScript脚本对CRP的影响

2. 引入JS

行内Script (Script位于html尾部)

上图来看,Parse HTML这一过程被JavaScript执行打断,而且JavaScript会等待CSSOM构建完成之后再执行,执行完成之后,DOM继续构建。

前面的例子中,我们看到DOM几乎都是在CSSOM构建完成前就构建完成了,而引入JS后,DOM构建被JS执行打断,而JS执行又必须等CSSOM构建完毕,这无疑延长了第一次CRP时间,让页面首次出现画面的时间更长。

如果使用外部script脚本,这一时间会更长,我们来看看这种情况。

外部Script (Script位于html尾部)

对于网络请求的资源,浏览器会为其分配优先级,优先级越高的资源响应速度更快,时间更短,在这个例子中,CSS的优先级最高,其次是JS,优先级最低的是图片。

我们主要来看第一部分,后面部分和第1个研究类似

可以看到,增加了对JS文件的网络请求时间,一轮CRP时间更长了。对比上面的行内Script可能时间差异没有那么明显,因为这个例子中的JS文件体积小,传输时间只比CSS多一点,如果JS稍大,由于请求优先级低于CSS,则差异会明显变大。

3. Async Script

如果Script会对页面首次渲染造成这么大的影响,有没有什么好的办法解决它呢?

答案是肯定的,就是使用异步脚本<script src="" async />

使用异步脚本,其实就是告诉浏览器几件事:

  1. 无需阻塞DOM,在对Script执行网络请求期间可以继续构建DOM,直到拿到Script之后再去执行
  2. 将该Script的网络请求优先级降低,延长响应时间

需要注意如下几点:

  1. 异步脚本是网络请求期间不阻塞DOM,拿到脚本之后马上执行,执行时还是会阻塞DOM,但是由于响应时间被延长,此时往往DOM已经构建完毕(下面的测验图片将会看到,CSSOM也已经构建完毕而且页面很快就发生第一次渲染),异步脚本的执行发生在第一次渲染之后。
  2. 只有外部脚本可以使用async关键字变成异步,而且注意其与延迟脚本 (<script defer>)的区别,后者是在Document被解析完毕而DOMContentLoaded事件触发之前执行,前者则是在下载完毕后执行。
  3. 对于使用document.createElement创建的<script>,默认就是异步脚本。

直接看图

由于Script执行修改了DOM和CSSOM,因此重新经过Recalculate Style、计算Layout、重新Paint,最终呈现页面。由于速度很快(总共只用了140ms左右),因此我们还是察觉不到这个变化。

4. CSS在HTML中不同位置的影响

前面留下了一个问题,CSSOM没有构建完成,为什么刚开始的Parse HTML同时就有Recalculate Style这部分?或许这部分会给你一个答案。

这里为了避免JS带来的影响,使对比更有针对性,删除了JavaScript。

<style><link>在HTML文件头部

先来回顾一下在头部设置<link>

link tag on top of html file

前面的DOM构建部分出现了Recalculate Style,之后获得CSS并解析后还有一次,一共出现了2次

再来看看改成<style>Recalculate Style一共出现1次

<style>在头部,一开始就直接解析完成,没有网络请求

<style><link>在HTML文件尾部

先来看看设置<style>在尾部,Recalculate Style出现了1次

再看设置<link>在尾部,Recalculate Style一共出现2次下图中左半部分有误,标出了两次,其中前1次叫做Schedule Style Recalculation,后1次才是真正的Recalculate Style

实验结果

实验中将<link>放在头部,<style>放在头部,<link>放在尾部,<style>放在尾部,Recalculate Style的次数分别是2,1,2,1。

我们需要了解Chrome Dev Tools Performance Panel下Recalculate Style的定义:

To find out how long the CSS processing takes you can record a timeline in DevTools and look for "Recalculate Style" event: unlike DOM parsing, the timeline doesn’t show a separate "Parse CSS" entry, and instead captures parsing and CSSOM tree construction, plus the recursive calculation of computed styles under this one event.

上面这段描述就是Recalculate Style包括CSS的解析和CSSOM的构建,还包括一些递归的样式计算(例如计算父元素大小时需要先递归计算子元素的大小)。但是我在实验中却发现CSS解析和CSSOM构建准确来说发生在Parse HTMLParse Stylesheet中。因此,我更愿意把它以字面意思理解,也就是一些样式的计算

除此之外,要明确浏览器还有一个默认的User Agent Style,我们的Style只是对其进行一个覆盖。

最后猜想这4个结果的原因:

  1. 浏览器如果发现<head>里存在<link>,则会发出css请求,同时继续解析html,并使用User Agent Style构建CSSOM。此时DOM构建不被阻塞,DOM和CSSOM会马上构建完成,接着触发一次Recalculate Style,但是不会出现Layout。当css接收完成,CSSOM更新完成后,触发第二次Recalculate Style

  2. <style>放在头部,浏览器马上就能解析CSS,CSSOM和DOM构建完成后,就可以马上触发一次Recalculate Style

  3. <style>放在尾部,和头部一样。不过如果有JavaScript,则表现和<link>放在尾部类似。

  4. <link>放在尾部是最糟糕的情况,因为这不仅会阻塞DOM构建,还会浪费资源。浏览器首先使用User Agent Style构建CSSOM,完成后触发一次Recalculate Style,并且进行LayoutPaint(此时DOM构建被阻塞,而且此时的LayoutPaint是资源浪费)。当css请求完成后DOM恢复构建,同时触发CSSOM更新和第二次Realculate Style,当DOM构建完毕后触发DOMContentLoad事件,并进行Layout - > Paint

所以,我们需要将<style><link>放在头部,对于<style>在尾部,这个例子省略了JS的影响,如果加入JS,则结果又会不一样。

本来想再测试一下JS在HTML中不同位置的影响,但是就CRP这一过程来讲,这部分比较容易叙述清楚。

因为JS不管在哪个位置都会默认阻塞DOM。如果DOM尚未构建完成,JS中对不在DOM的元素进行操作,会抛出错误,脚本直接结束。如果将脚本设置为async,则放在前面也是OK的,例如使用document.createElement创建的<script>,其默认使用的就是异步。

总结

这篇文章是我阅读了Google Developer的Web Performance Fundamentals后,自己做实践得到的总结。非常建议每位FEDer阅读这一系列文章。文章作者Ilya Grigorik还和Udacity开设了联合课程Website Performance Optimization以及他关于Web Performance的一次演讲,都值得一看。

由于水平有限,我只看了前半部分(关于CRP),后半部分则关于在Web Performance Optimization的实践。

疏漏之处,欢迎指正。

参考

  1. developers.google.com/web/fundame… (Recommended)
  2. Website Performance Optimization - Udacity
  3. stackoverflow.com/questions/5…
  4. www.youtube.com/watch?v=PkO…
文章分类
前端
文章标签