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

September 20, 2018 by Tian Zhi

除了知道如何用代码实现一个页面之外,最重要的是明白这个页面是如何被浏览器解析和渲染,并最终呈现在我们面前的

前言

回顾自己的前端学习历程,现在都还不能深入理解浏览器对HTML文档的解析和渲染过程。最近被问到HTML的重绘(Repaint)和重排(Reflow),才想起自己根本没仔细研究过它。

作为前端工程师,我们每天写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转换成像素,显示在屏幕上

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

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

progressive rendering

构建DOM

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

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

后面我们将会提到,CSSOM则必须等到所有字节收到才开始构建

full process

构建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 tree

需要注意的是,上面并不是一颗完整的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中

render tree construction

构建过程遵循以下步骤

  1. 浏览器从DOM树开始,遍历每一个“可见”节点
  2. 对于每一个"可见"节点,在CSSOM上找到匹配的样式并应用
  3. 生成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进行了测验

  1. 解析HTML构建DOM时,遇到JavaScript会被阻塞
  2. JavaScript执行会被CSSOM构建阻塞,也就是说,JavaScript必须等到CSS解析完成后才会执行(这只针对在头部放置<style><link>的情况,如果放在尾部,浏览器刚开始会使用User Agent Style构建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的情况

without script1

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

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

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

DOM构建(体现为parse html)

without script2

可以看出,浏览器解析到<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

without script3

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

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

页面首次出现图片

without script4

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

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

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

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

2. 引入JS

行内Script (Script位于html尾部)

inline script1

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

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

如果使用外部script脚本,这一时间会更长

外部Script (Script位于html尾部)

separate script file1

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

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

separate script file2

可以看到,增加了对JS文件的网络请求时间,一轮CRP时间更长了,对比上面的行内Script可能时间差异没有那么明显,是因为这个例子中的JS文件体积小,传输时间只比CSS多一点,主要决定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>,默认就是异步脚本

直接看图

async script1

async script2

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

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

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

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

设置style在html文件头部

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

without script1 link tag on top of html file

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

without script5

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

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

设置style或者link在尾部

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

style tag bottom

再看设置<link>在尾部,Recalculate Style一共出现3次

link tag bottom

先总结实验结果

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

然后我们需要了解Chrome Dev Tools Performance Tab的几个关键过程

  1. 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的构建

  1. 对于<style>里的CSS,解析过程发生在Recalculate Style中,而<link>获得的CSS,解析过程是单独的,叫做Parse CSS (和Parse HTML类似)
  2. 同时,要明确浏览器还有一个默认的User Agent Style,我们的Style只是对其进行一个覆盖

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

  1. 浏览器如果发现<head>里存在<link>,则会等待CSS网络请求完成并解析好之后才开始Render Tree,至于第一次的Recalculate Style,我猜想是默认的User Agent Style,此时CSSOM已经开始构建了,而接收到CSS文件,我们设置的Style会对默认的Style进行覆盖。这里第一次Recalculate Style只包含CSSOM构建,第二次则包含了CSSOM更新以及Render Tree构建
  2. <style>放在头部,浏览器因为可以马上拿到CSS,就可以马上进行解析,此时User Agent Style的解析和我们自定义的Style解析合并,Recalculate Style包含了CSSOM构建和Render Tree构建
  3. <style>放在尾部,和放在头部类似。只不过晚点发现CSS,但是由于是行内<style>,还是可以马上解析
  4. <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的实践

疏漏之处,欢迎指正

参考

  1. https://developers.google.com/web/fundamentals/performance/critical-rendering-path/ (Recommended)
  2. Website Performance Optimization - Udacity
  3. https://stackoverflow.com/questions/5797014/why-do-browsers-match-css-selectors-from-right-to-left/
  4. https://www.youtube.com/watch?v=PkOBnYxqj3k

Feel free to leave me a message @tianzhich