侧边栏

浏览器解析HTML的流程

发布于 | 分类于 前端/前端业务

很早之前就对浏览器加载整个HTML文档以及相关的解析和渲染流程十分好奇,但是却一直没有深入。

本文将整理浏览器加载和HTML中的一些细节问题。

参考文章:

请求网页

整个“从地址栏输入地址到看见整个网页”的过程非常复杂,这也是我这几个月勉强弄明白的东西。

根据我的理解,获取网页内容过程可以简化为:

  • 在浏览器地址栏输入地址,访问某台主机(通过DNS域名解析或直接是IP地址),
  • 通常这台主机上运行着web服务器,根据我们输入的地址调用相关路径的程序(可能是某个.php文件),这个程序会调用数据库,然后使用返回的数据生成HTML页面,
  • 最后返回给发起请求的浏览器,浏览器解析这个HTML文档,并呈现给用户。

这篇文章可以算作是了解这个流程中“浏览器接受到HTML文档到呈现出整个页面”这段时间发生的事情。

浏览器接收到HTML文档之后,会经历三个阶段:加载解析渲染,最后才呈现整个页面。

令人头疼的是,这三个阶段并不是按顺序线性执行或者各自独立执行的,有时会出现交叉工作的情形。

加载与解析

参考:

浏览器需要先解析HTML构建DOM树,解析CSS文件构建CSS规则树。

解析HTML指的是将HTML转化成有意义的结构。解析是一个迭代的过程,类似于将一条英语句子翻译成中文的过程。

HTML的解析分为标记化和树构建两个阶段。其中标记会对应词法分析,将解析的多个标记传递给树构建器进行处理,规范中定义的每个标记所对应的DOM元素会在树构建起接受到相应的标记时所创建,并添加到DOM树中。

通常,一个HTML文档不仅仅只是文本,还包含CSS样式表,JS脚本和其他多媒体资源(img、video、audio等),其中:

  • 样式表需要加载和解析,样式表可以使用style标签嵌套在页面内,也可以使用link标签的href属性引入外部样式表
  • js脚本需要加载,解析并执行,js可以使用script标签嵌套在页面内,也可以使用script的src属性引入外部脚本
  • 其他如img,video等多媒体资源需要被加载

当浏览器从上到下解析整个HTML文档时,

  • 如果遇见内联的CSS样式表,就会立即解析(但不一定会立即渲染出样式);如果遇见内联的JS脚本,就会立即解析和执行;
  • 如果遇见外部URL资源,就会发送请求加载对应文件(现代浏览器可能会同时发送多个请求加载文件,但是同一个域名的资源有最大请求并发数量限制)。

DOMContentLoaded与load

参考

当文档中没有脚本时,浏览器解析完文档便能触发 DOMContentLoaded 事件;

如果文档中包含脚本,则脚本会阻塞文档的解析,而脚本需要等位于脚本前面的css加载完才能执行。

在任何情况下,DOMContentLoaded 的触发不需要等待图片等其他资源加载完成。

页面上所有的资源(图片,音频,视频等)被加载以后才会触发整个页面的load事件,简单来说,页面的load事件会在DOMContentLoaded被触发之后才触发。

CSS样式表

当HTML解析器遇见一个style标签时,会按顺序解析里面的样式;当HTML解析器遇见一个link标签,会发送一个外部样式表的请求。

吸取了早年HTML的教训,CSS文档是上下文无关的语法,可以在各种解析器下进行正确解析。webkit采用的是自下而上的解析器,而firefox采用的是自上而下的解析器。

这两种解析器都会将CSS文件解析成stylesheet对象,每个对象都包含对应的CSS规则。

CSS并不会影响HTML的解析,即不会影响DOM树的生成,但会影响DOM树的渲染,因此渲染本身就需要依赖CSS的样式结果,这在后面的渲染章节会进一步介绍。

在解析CSS时,后面的JS代码会被阻塞:

  • 如果是内联脚本,则必须等待前面的样式表加载和解析完成才会执行
  • 如果是外部脚本,浏览器会发送下载外部JS脚本文件的请求(CSS文件和JS文件可能同步下载),但即使js文件已经返回,也必须等待前面的CSS样式表加载和解析完成

这么限制的原因是JS执行依赖最新的CSS渲染(或者说最精确的样式信息)。浏览器心想我这里正在加载一个样式表,不做点什么的话,万一后面的脚本向我要这个元素的宽度,我还没解析,啥都都不知道,怎么告诉你嘛,干脆JS先别执行,等我知道最新的样式了你再问准没错。这样,即使后面的脚本不去查询元素的样式,脚本的运行还是会被CSS文档的加载阻塞。

因此有些对性能要求非常挑剔的页面,是通过内联样式表的形式来加快速样式的解析和脚本的执行的(避免加载耗时)。

同步JS脚本

可以直接在script标签中书写JS代码(内联脚本),也可以通过指定script标签的src属性来加载外部js文件(外联脚本),当然,这两者不能同时使用一个script标签实现,带有src属性的script标签会忽略标签内部的信息。

可以把普通的外联脚本看作是外部文件的JS代码内容直接出现在标签内一样(当然需要外部文件加载成功),浏览器从根据script标签的出现顺序依次解析和执行相关js代码。

整个HTML文档上的全部script标签,共用一个window全局对象,共用一个document对象,共用全局变量和函数。

如果遇见普通的script(无async或defer)时,不论他是内联脚本还是外部脚本,都会阻塞浏览器进一步解析HTML文档(即暂时无法处理这个脚本后面其他需要加载的URL),而必须等待该标签代表的脚本文件执行完毕(如果是外联的脚本,甚至需要等到这个js文件加载成功并执行完毕。

JavaScript运行时有同步和异步两个阶段,在同步阶段,代码从上到下按顺序执行;在异步阶段遵循EventLoop的机制(这个后面会单独开一片文章)。

同步的JS代码会在浏览器解析脚本的阶段边解析边运行,这个过程会阻碍浏览器继续解析后续的HTML文档。

最主要的原因是:JS可能影响后续的文档,可能向文档流中插入信息,也可能改变后续script脚本的全局变量。因此浏览器干脆在解析和执行script标签的时候,阻塞后续文档的解析。

举个例子,普通的JS脚本无法操作出现在他后面的DOM结构,他只能通过document.write()方法来生成文档内容。document.write()可以看作是向<body>标签中输出内容,如果文档还没有解析完成,则使用write方法会向文档中追加内容;如果当文档已经解析完成(触发onload事件,这个后面会提到)后直接调用write方法,会覆盖整个文档!!

js
window.onload = function(){
	 document.write("<p>clear</p>")
}

这是因为文档解析完毕之后,文档流已经关闭了,这个时候执行write(方法会自动调用document.open()方法来创建一个新的文档流,并写入新的内容,再通过浏览器展现,这样就会覆盖原来的内容,导致整个浏览器的重绘。

即使在文档解析时使用write方法不会覆盖整个文档,也可能影响后续文档的生成。如果先解析后面的文档再执行前面的js脚本,则可能导致后面文档解析没有意义(比如后面的指定下载的资源是没有必要的),为了防止在JS中可能包含的document.write()方法影响后面的文档内容,所以浏览器会在遇见同步的script脚本时,阻塞后续文档的解析和渲染。

这种阻塞机制带来的最大问题是,如果需要加载和执行的脚本文件很多,则页面在渲染完成之前会出现长时间的空白,如果是需要加载外部文件然后再解析和执行的js脚本来说,阻塞的时间可能会更长,这也是为什么大多数教程说将script脚本放在页面底部,同时将初始化的代码放在onload或者DOMContentLoaded事件中。

下面是一个简单的测试代码

html
<style>
    h1 {
        color: red;
    }
</style>
<h1>hello</h1>
<script>
    const now = +new Date()
    while (+new Date() - now < 5000) {
        // todo
    }
    console.log('done')
</script>

访问这个页面的时候可以发现,页面需要空白至少5秒之后,才能看见渲染出来的红色h1标签。

async和defer脚本

参考:Script - MDN

script标签上的asyncdefer属性,决定了脚本加载时采用同步方式还是异步方式。

这两个属性都像是对浏览器声明:这个script标签里面不包含document.write(),不会影响文档流,关于变量作用域的问题我们也已经考虑了,你尽管加载这个文件并同时后面解析文档(不用等待这个脚本下载并解析并执行完毕了)。

  • 如果不加上这两个属性,默认为同步加载脚本,加载和执行时会阻塞页面的渲染,即浏览器按顺序解析DOM树及脚本,遇见脚本会阻塞DOM树生成并执行脚本。
  • async 和 defer 方式是用异步的方式加载脚本,不会阻塞页面渲染,它们之间的不同在于何时执,
    • async 方式是加载后马上执行,
    • defer 方式是加载后等所有 DOM 都渲染好触发 DOMContentLoaded 事件之前执行,
    • 所以 async 方式里面的脚本都是乱序执行,defer 方式加载的代码都是按序执行的,按序执行对有依赖的代码非常重要。
    • 若两个属性同在,会忽略defer而遵从async

还可以在网页渲染完成之后,通过JS动态生成script加载脚本文件,这种显然是不会阻塞浏览器渲染的。

小结

整理一下结论

  • CSS文档的加载和解析,阻塞的是脚本的执行而不是脚本的加载。
  • 同步JS脚本的加载解析和执行,是会影响HTML解析器的工作,导致后面的所有资源都无法被加载。

现在我们了解了普通、阻塞的脚本,也了解了延迟、异步的脚本,并且知道了JavaScript执行的两个阶段,现在,让我们总结一下页面中整个JavaScript脚本从加载到解析再到执行的整个流程(摘抄自《JavaScript权威指南》13.3.4节)。

  • 首先浏览器创建了Document对象,然后开始解析Web页面,解析HTML元素和它们的文本内容添加Element对象和Text节点到文档中。此时document的readyState属性的值是loading;
  • 当HTML解析器遇到没有async和defer属性的script标签,它把这些元素添加到文档中,并执行行内或外部脚本,这些脚本会同步执行,并且在脚本下载和解析时解析器会暂停,这样脚本就可以使用document.write()来把文本插入到文档流中,在HTML解析器恢复工作之后,这些文本将成为文档的一部分。此外,这些脚本也可以操作他们之前的DOM结构。
  • 当浏览器遇见设置了async的脚本,它开始下载文本,并继续解析文档,脚本会在他下载完成后尽快执行,但是解析器没有停下来等他下载;当浏览器遇见设置了defer的脚本,他开始下载文本,并继续解析文档,但是defer脚本即使下载完成也不会立即执行
  • 当HTML文档完成解析,docuemnt的readyState属性变成了interactive(这个词的意思是互动)。此时所有设置了defer属性的脚本,按照他们在文档里的出现顺序执行,意味着延迟脚本可以访问完整的文档树。此时,可能存在刚下载完毕的async脚本,他们也会执行。
  • 所有的脚本执行完毕(不包括async异步脚本),document对象触发DOMContentLoaded事件,这标记着JavaScript运行从第一阶段(同步执行阶段)转换到第二阶段(异步事件驱动阶段)。此时,虽然文档已经全部完成解析,但是浏览器可能还在等待其他内容载入,如图片等,当这些内容完成载入,且所有的异步脚本都完成载入且执行完毕,document对象的readyState属性改变为complete,window对象触发load事件。
  • window对象触发onload事件之后,开始调用异步事件,以异步响应用户输入事件,网络事件和定时器到期等

渲染页面

参考:

在上面的解析阶段,浏览器首先解析三个东西:

  • HTML/SVG/XHTML,产生一个DOM Tree
  • CSS,产生CSS Rule Tree
  • Javascript,主要是通过DOM APICSSOM API来操作DOM TreeCSS Rule Tree

解析完成后,浏览器引擎会通过DOM TreeCSS Rule Tree 来构造 Rendering Tree

元素节点在添加至渲染树的时候并不包含位置和大小等几何信息,计算这些值的过程就称为布局layout或重排reflow

最后通过调用操作系统Native GUI的API绘制。

下面展示了大概的渲染过程

渲染树

渲染树是由可视化元素按照其显示顺序组成的树,即文档的可视化表示,其作用是使浏览器按照正确的顺序绘制内容。

渲染树中的每个元素都代表一个矩形的区域,通常对应相关节点的CSS框,包含了宽高位置等几何信息。

渲染树中的元素与DOM树中的元素相对应,但并非一一对应,因为一些像headdisplay:none等非可视化的DOM元素就没必要放在渲染树中了。

构建渲染树时,需要根据CSS Rule Tree,通过计算每一个渲染树元素的样式属性来计算他的可视化属性。

在计算某个特定DOM节点的样式上下文时,首先计算规则树中的对应路径,并沿此路径应用CSS规则,在新的样式上下文中填充结构。

如果不进行优化,则在计算过程中会造成很大的性能问题。为每一个选择器对应的元素节点遍历整个规则列表来寻找匹配规则是一项十分浩大的工程,因此,选择器的嵌套与组合十分重要。在样式表解析完成之后,浏览器会根据选择器将CSS规则加到某个哈希表中,这些哈希表中的选择器各不相同,包括ID,类,标签选择器等。这种处理会大大简化匹配规则。

在某个DOM节点上同时应用多个重复的样式时会根据选择器的权重值进行排序,并使用优先级高的样式。

在绘制阶段,系统会遍历整个渲染树,并调用API将渲染树的整个内容显示在屏幕上,这里涉及到操作系统原声GUI的API,暂时还没有深入。

重绘与回流

HTML采用基于流的布局模型,因此处于流中靠后位置的元素不会影响到位置靠前的元素的几何特性。

布局分为整体布局和增量布局两种:前者需要对整个页面进行重新布局,后者对标记了dirty的所有元素进行重新布局。

dirty是为了避免一个细小的改动导致整个页面进行重新布局,而采用了的一种处理系统,如果渲染树中的某个元素发生了更改,或者其自身及后代标记了dirty,则表示需要重新布局。

有两种需要改变样式的场景:Repaint和Reflow。

repaint重绘

repaint表示页面上某个元素的的非定位样式需要重新绘制,比如利用JS改变了元素节点的背景颜色,此时不需要重新布局。

reflow回流

reflow表示元素节点的几何尺寸发生了变化,此时需要重新计算并构建渲染树,即此时需要重新布局,成本比repaint的成本高得多的多

需要注意的是repaint和reflow可能同时发生在同一个元素节点上。

reflow和repaint可能会严重影响性能,因此需要尽可能的减少reflow和repaint操作,下面是几条减少Repaint和Reflow的建议

  • 不要一条一条地修改DOM的样式,如果修改的样式过多可以将样式统一在某个类中,然后直接更改元素节点的className;
  • 使用临时变量保存DOM节点,而不是每次都直接对DOM节点进行操作(减少元素节点的读写),在JS性能与浏览器性能方面都能得到一些优化;
  • 尽可能修改层级比较低的DOM,缩小操作的影响范围;
  • 放弃使用table进行布局,因为一个很小的改动都会造成整个table的重新布局

浏览器帧

现在的前端应用越来越复杂了,JS除了操作DOM之外,还需要处理很多业务逻辑,因此需要了解浏览器绘制帧原理

参考:看了就会的浏览器帧原理

可以看见requestAnimationFrame是在帧开始的时机执行的,而requestIdleCallback是在帧末尾的空闲时间执行的,了解这些知识是编写高效应用的基础,后面会进一步深究

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。