深入理解浏览器解析渲染HTML
除了知道如何用代码实现一个页面之外,最重要的是明白这个页面是如何被浏览器解析和渲染,并最终呈现在我们面前的
前言
回顾自己的前端学习历程,现在都还不能深入理解浏览器对HTML文档的解析和渲染过程。最近被问到HTML的重绘(Repaint)和重排(Reflow),才想起自己根本没仔细研究过它。
作为前端工程师,我们每天写HTML,CSS和JavaScript,但是浏览器是如何解析这些文件,最终将它们以像素显示在屏幕上的呢?
这一过程叫做Critical Rendering Path
Critical Rendering Path
Critical Rendering Path,中文翻译过来,叫做关键渲染路径。指的是浏览器从请求HTML,CSS,JavaScript文件开始,到将它们最终以像素输出到屏幕上这一过程。包括以下几个部分
-
构建DOM
- 将HTML解析成许多Tokens
- 将Tokens解析成object
- 将object组合成为一个DOM树
-
构建CSSOM
- 解析CSS文件,并构建出一个CSSOM树(过程类似于DOM构建)
-
构建Render Tree
- 结合DOM和CSSOM构建出一颗Render树
-
Layout
- 计算出元素相对于viewport的相对位置
-
Paint
- 将render tree转换成像素,显示在屏幕上
值得注意的是,上面的过程并不是依次进行的,而是存在一定交叉,后面会详细解释
想要提高网页加载速度,提升用户体验,就需要在第一次加载时让重要的元素尽快显示在屏幕上。而不是等所有元素全部准备就绪再显示,下面一幅图说明了这两种方式的差异
构建DOM
DOM (Document Object Model),文档对象模型,构建DOM是必不可少的一环,浏览器从发出请求开始到得到HTML文件后,第一件事就是解析HTML成许多Tokens,再将Tokens转换成object,最后将object组合成一颗DOM树
这个过程是一个循序渐进的过程,我们假设HTML文件很大,一个RTT (Round-Trip Time)只能得到一部分,浏览器得到这部分之后就会开始构建DOM,并不会等到整个文档就位才开始渲染。这样做可以加快构建过程,而且由于自顶向下构建,因此后面构建的不会对前面的造成影响
后面我们将会提到,CSSOM则必须等到所有字节收到才开始构建
构建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,可能会得到错误的结果
构建Render Tree
这也是关键的一步,浏览器使用DOM和CSSOM构建出Render Tree。此时不像构建DOM一样把所有节点构建出来,浏览器只构建需要在屏幕上显示的部分,因此像<head>
,<meta>
这些标签就无需构建了。同时,对于display: none
的元素,也无需构建
display: none
告诉浏览器这个元素无需出现在Render Tree中,但是visibility: hidden
只是隐藏了这个元素,但是元素还占空间,会影响到后面的Layout,因此仍然需要出现在Render Tree中
构建过程遵循以下步骤
- 浏览器从DOM树开始,遍历每一个“可见”节点
- 对于每一个"可见"节点,在CSSOM上找到匹配的样式并应用
- 生成Render Tree
扩展:CSS匹配规则为何从右向左
相信大多数初学者都会认为CSS匹配是左向右的,其实恰恰相反。学习了CRP,也就不难理解为什么了。
CSS匹配就发生在Render Tree构建时(Chrome Dev Tools里叫做Recalculate Style),此时浏览器构建出了DOM,而且拿到了CSS样式,此时要做的就是把样式跟DOM上的节点对应上,浏览器为了提高性能需要做的就是快速匹配
首先要明确一点,浏览器此时是给一个"可见"节点找对应的规则,这和jQuery选择器不同,后者是使用一个规则去找对应的节点,这样从左到右或许更快。但是对于前者,由于CSS的庞大,一个CSS文件中或许有上千条规则,而且对于当前节点来说,大多数规则是匹配不上的,到此为止,稍微想一下就知道,如果从右开始匹配(也是从更精确的位置开始),能更快排除不合适的大部分节点,而如果从左开始,只有深入了才会发现匹配失败,如果大部分规则层级都比较深,就比较浪费资源了
除了上面这点,我们前面还提到DOM构建是"循序渐进的",而且DOM不阻塞Render Tree构建(只有CSSOM阻塞),这样也是为了能让页面更早有元素呈现。考虑如下情况,如果我们此时构建的只是部分DOM,而此时CSSOM构建完成,浏览器此时需要构建Render Tree,如果对每一个节点,找到一条规则进行从左向右匹配,则必须要求其子元素甚至孙子元素都在DOM上(而此时DOM未构建完成),显然会匹配失败。如果反过来,我们只需要查找该元素的父元素或祖先元素(它们肯定在当前DOM中)
Layout
我们现在为止已经得到了所有元素的自身信息,但是还不知道它们相对于Viewport的位置和大小,Layout这一过程需要计算的就是这两个信息
根据这两个信息,Layout输出元素的Box Model,关于这个,我也写过一篇文章Understand CSS Formatting Model
目前为止,现在我们已经拿到了元素相对于Viewport的详细信息,所有的值都已经计算为相对Viewport的精确像素大小和位置,就差显示了
Paint
浏览器将每一个节点以像素显示在屏幕上,最终我们看到页面
这一过程需要的时间与文档大小,CSS应用样式的多少以及复杂度,还有设备自身都有关,例如对简单的颜色进行Paint是简单的,但是box-shadow
进行paint则是复杂的
引入JavaScript
前面的过程都没有提到JavaScript,但在如今,JavaScript却是网页中不可缺的一部分。这里对它如何影响CRP做一个概要,具体细节我后面使用Chrome Dev Tools进行了测验
- 解析HTML构建DOM时,遇到JavaScript会被阻塞
- JavaScript执行会被CSSOM构建阻塞,也就是说,JavaScript必须等到CSS解析完成后才会执行(这只针对在头部放置
<style>
和<link>
的情况,如果放在尾部,浏览器刚开始会使用User Agent Style构建CSSOM) - 如果使用异步脚本,脚本的网络请求优先级降低,且网络请求期间不阻塞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构建(体现为parse html)
可以看出,浏览器解析到<link>
,<img>
等等标签时,会马上发出HTTP请求,而且解析也将继续进行,解析完成后会触发readystatechange事件和DOMContentLoaded事件,在上图中,由于时间间隔已经到了100微秒级别,事件间隔有些许差异,但不影响我们对这一过程的理解
细心的话可能会注意到上图中还触发了Recalculate Style (紫色部分),这一过程发生在CSSOM树构建完成或者发生变化需要更新Render Tree时,但是此时我们并没有拿到CSS,更没有构建出CSSOM,这一部分从何而来呢?我在下面第4部分做了分析
页面首次出现画面
下面这一过程依次展示了CSS解析构建CSSOM,Render Tree生成,layout和paint,最终页面首次出现画面
下图中有一点错误:render tree构建应该发生在Recalculate Style (layout前一部分),Layout以及后一部分Update Layer Tree作为Layout
从这里我们可以看出,DOM即使构建完成,也需要等CSSOM构建完成,才能经过一个完整的CRP并呈现画面,因此为了画面尽快呈现,我们需要尽早构建出CSSOM,比如
- html文档中的
<style>
或者<link>
标签应该放在<head>
里并尽早发现被解析(第4部分我会分析将这两个标签放在html文档后面造成的影响) - 减少第一次请求的CSS文件大小
- 甚至可以将最重要部分的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,如果JS稍大,由于请求优先级低于CSS,则差异会明显变大
3. Async Script
如果Script会对页面首次渲染造成这么大的影响,有没有什么好的办法解决它呢?
答案是肯定的,就是使用异步脚本<script src="" async />
使用异步脚本,其实就是告诉浏览器几件事
- 无需阻塞DOM,在对Script执行网络请求期间可以继续构建DOM,直到拿到Script之后再去执行
- 将该Script的网络请求优先级降低,延长响应时间
需要注意如下几点
- 异步脚本是网络请求期间不阻塞DOM,拿到脚本之后马上执行,执行时还是会阻塞DOM,但是由于响应时间被延长,此时往往DOM已经构建完毕(下面的测验图片将会看到,CSSOM也已经构建完毕而且页面很快就发生第一次渲染),异步脚本的执行发生在第一次渲染之后
- 只有外部脚本可以使用
async
关键字变成异步,而且注意其与延迟脚本 (<script defer>
)的区别,后者是在Document被解析完毕而DOMContentLoaded事件触发之前执行,前者则是在下载完毕后执行 - 对于使用
document.createElement
创建的<script>
,默认就是异步脚本
直接看图
由于Script执行修改了DOM和CSSOM,因此重新经过Recalculate Style生成Render Tree,重新计算Layout,重新Paint,最终呈现页面。由于这一过程仍然很快(只用了140ms左右),因此我们还是察觉不到这个变化
4. CSS在HTML中不同位置的影响
前面留下了一个问题,CSSOM没有构建完成,为什么刚开始的Parse HTML同时就有Recalculate Style这部分?或许这部分会给你一个答案
这里为了避免JS带来的影响,使对比更有针对性,删除了JavaScript
设置style在html文件头部
先来回顾一下在头部设置<link>
前面的DOM构建部分出现了Recalculate Style,之后获得CSS并解析后还有一次,一共出现了2次
再来看看改成<style>
,Recalculate Style一共出现1次
设置style或者link在尾部
先来看看设置<style>
在尾部,Recalculate Style出现了1次
再看设置<link>
在尾部,Recalculate Style一共出现3次
先总结实验结果
实验中将<link>
放在头部,<style>
放在头部,<link>
放在尾部,<style>
放在尾部,Recalculate Style的次数分别是2,1,3,1
然后我们需要了解Chrome Dev Tools Performance Tab的几个关键过程
- Performance Tab里的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.
在Performance Tab里面,没有看到Render Tree构建这一过程,这一过程也被浏览器隐藏在Recalculate Style里面,所以Recalculate Style既可能包括CSSOM的构建,也可能包括Render Tree的构建
- 对于
<style>
里的CSS,解析过程发生在Recalculate Style中,而<link>
获得的CSS,解析过程是单独的,叫做Parse CSS (和Parse HTML类似) - 同时,要明确浏览器还有一个默认的User Agent Style,我们的Style只是对其进行一个覆盖
最后猜想这4个结果的原因如下
- 浏览器如果发现
<head>
里存在<link>
,则会等待CSS网络请求完成并解析好之后才开始Render Tree,至于第一次的Recalculate Style,我猜想是默认的User Agent Style,此时CSSOM已经开始构建了,而接收到CSS文件,我们设置的Style会对默认的Style进行覆盖。这里第一次Recalculate Style只包含CSSOM构建,第二次则包含了CSSOM更新以及Render Tree构建 <style>
放在头部,浏览器因为可以马上拿到CSS,就可以马上进行解析,此时User Agent Style的解析和我们自定义的Style解析合并,Recalculate Style包含了CSSOM构建和Render Tree构建<style>
放在尾部,和放在头部类似。只不过晚点发现CSS,但是由于是行内<style>
,还是可以马上解析<link>
放在尾部,浏览器一开始没发现<link>
,会使用User Agent Style(2次 Recalculate Style),后面才发CSS网络请求,最后再触发CSSOM的更新(1次 Recalculate Style),这是最糟糕的情况。这里的3次Recalculate Style分别指CSSOM构建,Render Tree构建,CSSOM更新和Render Tree构建。Render Tree构建两次,页面发生两次渲染,为最糟糕的情况
所以,我们需要将<style>
和<link>
放在头部,对于<style>
在尾部,这个例子省略了JS的影响,如果加入JS,则结果又会不一样
本来想再测试一下JS在HTML中不同位置的影响,但是就CRP这一过程来讲,这部分比较容易叙述清楚
因为JS不管在哪个位置都会默认阻塞DOM。如果DOM尚未构建完成,JS中对不在DOM的元素进行操作,会抛出错误,脚本直接结束。如果将脚本设置为async
,则放在前面也是OK的,例如使用document.createElement
创建的<script>
,其默认使用的就是异步
总结
这篇文章是我阅读了Google Developer的Web Performance Fundamentals后,自己做实践得到的总结。非常建议每位FEDers阅读这一系列文章。文章作者Ilya Grigorik还和Udacity开设了联合课程Website Performance Optimization以及他关于Web Performance的一次演讲,都值得一看
由于水平有限,我只看了前半部分(关于CRP),后半部分则关于在Web Performance Optimization的实践
疏漏之处,欢迎指正