通常我们只需要编写HTML,CSS,JavaScript屏幕上就会显示出漂亮的页面,但浏览器是如何使用我们的代码在屏幕上渲染像素的呢?
浏览器将HTML,CSS,JavaScript转换为屏幕上所呈现的实际像素,这期间所经历的一系列步骤,叫做关键渲染路径(Critical Rendering Path)。
图1-1给出了关键渲染路径的具体步骤。如图所示,首先,浏览器获取HTML并开始构建DOM(文档对象模型 - Document Object Model)。然后获取CSS并构建CSSOM(CSS对象模型 - CSS Object Model)。然后将DOM与CSSOM结合,创建渲染树(Render Tree)。然后找到所有内容都处于网页的哪个位置,也就是布局(Layout)这一步。最后,浏览器开始在屏幕上绘制像素。
正常情况下浏览器会以上面我们描述的步骤进行渲染,但有一个特殊情况是在构建DOM时遇见了JavaScript,这时情况就会变得不太一样。JavaScript会影响渲染的流程,所以它是性能领域很重要的部分,这个特殊情况我们后面再详细讨论,我们先讨论如何构建DOM和CSSOM。
浏览器会遵守一套定义完善的步骤来处理HTML并构建DOM。宏观上,可以分为几个步骤。如图1-2所示。
第一步(转换):浏览器从磁盘或网络读取HTML的原始字节,并根据文件的指定编码(例如 UTF-8)将它们转换成字符,如图1-3所示。
第二步(Token化):将字符串转换成Token,例如:“<html>”、“<body>”等。Token中会标识出当前Token是“开始标签”或是“结束标签”亦或是“文本”等信息。
这时候你一定会有疑问,节点与节点之间的关系如何维护?
事实上,这就是Token要标识“起始标签”和“结束标签”等标识的作用。例如“title”Token的起始标签和结束标签之间的节点肯定是属于“title”的子节点。如图1-5所示。
图1-5给出了节点之间的关系,例如:“Hello”Token位于“title”开始标签与“title”结束标签之间,表明“Hello”Token是“title”Token的子节点。同理“title”Token是“head”Token的子节点。
第三步(生成节点对象并构建DOM):事实上,构建DOM的过程中,不是等所有Token都转换完成后再去生成节点对象,而是一边生成Token一边消耗Token来生成节点对象。换句话说,每个Token被生成后,会立刻消耗这个Token创建出节点对象。
带有结束标签标识的Token不会创建节点对象
节点对象包含了这个节点的所有属性。例如<img src="xxx.png" />
标签最终生成出的节点对象中会保存图片地址等信息。
随后通过“开始标签”与“结束标签”来识别并关联节点之间的关系。最终,当所有Token都生成并消耗完毕后,我们就得到了一颗完整的DOM树。从Token生成DOM的过程如图1-6所示。
图1-6中每一个虚线上有一个小数字,表示构建DOM的具体步骤。可以看出,首先生成出html
Token,并消耗Token创建出html
节点对象。然后生成head
Token并消耗Token创建出head
节点对象,并将它关联到html
节点对象的子节点中。随后生成title
Token并消耗Token创建出title
节点对象并将它关联到head
节点对象的子节点中。最后生成body
Token并消耗Token创建body
节点对象并将它关联到html
的子节点中。当所有Token都消耗完毕后,我们就得到了一颗完整的DOM树。
构建DOM的具体实现,与Vue的模板编译原理非常相似,若想了解构建DOM的过程如何用代码实现,可以查看我之前写的一篇关于Vue模板编译原理的文章。也可以期待一下我的新书,书里面对Vue模板编译原理讲的比文章更细致与透彻。
DOM会捕获页面的内容,但浏览器还需要知道页面如何展示。所以需要构建CSSOM(CSS对象模型 - CSS Object Model)。
构建CSSOM的过程与构建DOM的过程非常相似,当浏览器接收到一段CSS,浏览器首先要做的是识别出Token,然后构建节点并生成CSSOM。如图2-1所示。
假设浏览器接收到了下面这样一段CSS:
body {font-size: 16px;}
p {color: red;}
p span {display:none;}
span {font-size: 14px;}
img {float: right;}
上面这段CSS最终经过一系列步骤后生成的CSSOM如图2-2所示。
从图中还可以看出,body
节点的子节点继承了body
的样式规则(16px的字号)。这就是层叠规则以及CSS为什么叫CSS(层叠样式表)。
这里我要讲一句题外话,HTML可以逐步解析,它不需要等待所有DOM都构建完毕后再去构建CSSOM,而是在解析HTML构建DOM时,若遇见CSS会立刻构建CSSOM,它们可以同时进行。但CSS不行,不完整的CSS是无法使用的,因为CSS的每个属性都可以改变CSSOM,所以会存在这样一个问题:假设前面几个字节的CSS将字体大小设置为16px
,后面又将字体大小设置为14px
,那么如果不把整个CSSOM构建完整,最终得到的CSSOM其实是不准确的。所以必须等CSSOM构建完毕才能进入到下一个阶段,哪怕DOM已经构建完,它也得等CSSOM,然后才能进入下一个阶段。
所以,CSS的加载速度与构建CSSOM的速度将直接影响首屏渲染速度,因此在默认情况下CSS被视为阻塞渲染的资源。
DOM包含了页面的所有内容,CSSOM包含了页面的所有样式,现在我们需要将DOM和CSSOM组成渲染树。
假设我们现在有这样一段代码:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Demos</title>
<style>
body {font-size: 16px;}
p {color: red;}
p span {display:none;}
span {font-size: 14px;}
img {float: right;}
</style>
</head>
<body>
<p>Hello <span>berwin</span></p>
<span>Berwin</span>
<img src="https://p1.ssl.qhimg.com/t0195d63bab739ec084.png" />
</body>
</html>
这段代码最终构建成渲染树,如图3-1所示。
渲染树的重要特性是它仅捕获可见内容,构建渲染树浏览器需要做以下工作:
- 从 DOM 树的根节点开始遍历每个可见节点。
- 有些节点不可见(例如脚本Token、元Token等),因为它们不会体现在渲染输出中,所以会被忽略。
- 某些节点被CSS隐藏,因此在渲染树中也会被忽略。例如:上图中的
p > span
节点就不会出现在渲染树中,因为该节点上设置了display: none
属性。
- 对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。
所以最终渲染出的结果如下图所示。
有了渲染树之后,接下来进入布局阶段。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。通常这一行为也被称为“自动重排”。
布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。如图4-1所示。
布局完成后,浏览器会立即发出“Paint Setup”和“Paint”事件,将渲染树转换成屏幕上的像素。如图5-1所示。
现在,我们回到文章的最开始时留下的问题,我们讨论关键渲染路径,但是之前的讨论并不包含JS。这是因为JS会打破前面我们讨论的内容。
我们都知道JavaScript的加载、解析与执行会阻塞DOM的构建,也就是说,在构建DOM时,HTML解析器若遇到了JavaScript,那么它会暂停构建DOM,将控制权移交给JavaScript引擎,等JavaScript引擎运行完毕,浏览器再从中断的地方恢复DOM构建。
因为JavaScript可以修改网页的内容,它可以更改DOM,如果不阻塞,那么这边在构建DOM,那边JavaScript在改DOM,如何保障最终得到的DOM是否正确?而且在JS中前一秒获取到的DOM和后一秒获取到的DOM不一样是什么鬼?它会产生一系列问题,所以JS是阻塞的,它会阻塞DOM的构建流程,所以在JS中无法获取JS后面的元素,因为DOM还没构建到那。
JavaScript对关键渲染路径的影响不只是阻塞DOM的构建,它会导致CSSOM也阻塞DOM的构建。
原本DOM和CSSOM的构建是互不影响,井水不犯河水,但是一旦引入了JavaScript,CSSOM也开始阻塞DOM的构建,只有CSSOM构建完毕后,DOM再恢复DOM构建。
这是什么情况?
这是因为JavaScript不只是可以改DOM,它还可以更改样式,也就是它可以更改CSSOM。前面我们介绍,不完整的CSSOM是无法使用的,但JavaScript中想访问CSSOM并更改它,那么在执行JavaScript时,必须要能拿到完整的CSSOM。所以就导致了一个现象,如果浏览器尚未完成CSSOM的下载和构建,而我们却想在此时运行脚本,那么浏览器将延迟脚本执行和DOM构建,直至其完成CSSOM的下载和构建。
也就是说,在这种情况下,浏览器会先下载和构建CSSOM,然后再执行JavaScript,最后在继续构建DOM。
这会导致严重的性能问题,我们假设构建DOM需要一秒,构建CSSOM需要一秒,那么正常情况下只需要一秒钟DOM和CSSOM就会同时构建完毕然后进入到下一个阶段。但是如果引入了JavaScript,那么JavaScript会阻塞DOM的构建并等待CSSOM的下载和构建,一秒钟之后,假设执行JavaScript需要0.00000001
秒,那么从中断的地方恢复DOM的构建后,还需要一秒钟的时间才能完成DOM的构建,总共花费了2秒钟才进入到下一个阶段。如图6-1所示。
例如下面不加载JS的代码:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Test</title>
<link rel="stylesheet" href="https://static.xx.fbcdn.net/rsrc.php/v3/y6/l/1,cross/9Ia-Y9BtgQu.css">
</head>
<body>
aa
</body>
</html>
上面这段代码的执行性能结果如图6-2所示。
DOMContentLoaded 事件在116ms左右触发。
在代码中添加JavaScript:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Test</title>
<link rel="stylesheet" href="https://static.xx.fbcdn.net/rsrc.php/v3/y6/l/1,cross/9Ia-Y9BtgQu.css">
</head>
<body>
aa
<script>
console.log(1)
</script>
</body>
</html>
DOMContentLoaded 事件在1.21s触发,如图6-3所示。
关键渲染路径(Critical Rendering Path)是指浏览器将HTML,CSS,JavaScript转换为屏幕上所呈现的实际像素这期间所经历的一系列步骤。
关键渲染路径共分五个步骤。构建DOM -> 构建CSSOM -> 构建渲染树 -> 布局 -> 绘制。
CSSOM会阻塞渲染,只有当CSSOM构建完毕后才会进入下一个阶段构建渲染树。
通常情况下DOM和CSSOM是并行构建的,但是当浏览器遇到一个script
标签时,DOM构建将暂停,直至脚本完成执行。但由于JavaScript可以修改CSSOM,所以需要等CSSOM构建完毕后再执行JS。