浏览器解析HTML的流程

很早之前就对浏览器加载整个页面文档以及相关的执行顺序十分好奇,但是却一直没有深入。尤其是JavaScript这块,只知道JavaScript是单线程模型,却对于这个概念却一直没有很理性的认识。想到之前看见一篇关于浏览器中定时器的文章,介绍了浏览器中setTimeout的机制,才发现自己对关于页面上JavaScript的执行顺序给弄混了。 最近正重新阅读犀牛书,在第13章“Web浏览器中的JavaScript”中,终于得到了一些答案。

<!--more-->

参考文章:

1. 处理网页

根据我的理解,浏览网页的过程可以简化为:在浏览器地址栏输入地址,访问某台主机(通过DNS域名解析或直接是IP地址),通常这台主机上运行着web服务器,根据我们输入的地址调用相关路径的程序(可能是某个.php文件),这个程序会调用数据库,然后使用返回的数据生成HTML页面,最后返回给发起请求的浏览器,浏览器解析这个HTML文档,并呈现给用户。 整个“从地址栏输入地址到看见整个网页”的过程非常复杂,这也是我这几个月以来需要弄明白的东西。这篇文章可以算作是了解这个流程中“浏览器接受到HTML文档到呈现出整个页面”这段时间发生的事情。

浏览器接收到HTML文档之后,会经历三个阶段:加载解析渲染,最后才呈现整个页面。令人头疼的是,这三个阶段并不是按顺序线性执行或者各自独立执行的,有时会出现交叉工作的情形。

1.1. 渲染页面

浏览器需要解析HTML构建DOM树,解析CSS文件构建CSS规则树(这个只是firefox下的一个概念,不过先拿来用吧),然后再关联DOM树和规则树,构建渲染树,接着对渲染树中的每个节点进行布局,最后再绘制这个页面。 浏览器如何渲染页面是也是一个很复杂的问题,今天的重点了解浏览器是如何加载和解析外部文件的。

1.2. 加载外部文件

通常,一个HTML文档不仅仅只是文本,还包含样式表,js脚本和其他多媒体资源,其中:

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

1.3. 解析样式表和脚本

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

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

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

2. 浏览器中的JavaScript

浏览器中的JavaScript最主要的功能就是DOM编程

2.1. 嵌入方式

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

<a href="javascript:window.history.back();">返回</a>

或者在html标签的事件属性中执行JS代码

<button onclick="alert('hello')">click</button>

后面两种嵌入JS代码的方式现在已经很少使用了。并且他们需要在某些特定的情形下才会执行,后面会提到,我们先关注最普通的JS代码。

可以把普通的带有src属性的外部script标签看作是外部文件的JS代码内容直接出现在标签内一样(当然需要外部文件加载成功),浏览器从根据script标签的出现顺序依次解析和执行相关js代码,整个文档上的全部script标签,共用一个window全局对象,共用一个document对象,共用全局变量和函数。

2.2. 运行机制

JavaScript是单线程执行的,这里的单线程指的是任意时间都有且只有一个线程运行javascript代码。具体来说,JS程序的执行有两个阶段:

  • 第一阶段,在载入文档阶段(即文档没有被完全解析),script中的代码通常是按照他们在文档中出现的顺序执行,即从上到下,按照他在条件,循环,以及其他控制语句中出现的顺序执行。
  • 当文档载入完成,且所有脚本执行完成之后,JavaScript执行就会进入他的第二个阶段,这个阶段是异步的且有事件驱动的。通常是由第一阶段注册的事件处理程序来响应异步发生的事件。

此外,在第一阶段调用的诸如定时器之类的也会再第二阶段执行(详情见:你应该知道的setTimeout秘密)。

var start = new Date();
var end = 0;
setTimeout(function() {
  console.log(new Date() - start);
},  500);

while (new Date() - start <= 1000) {}

如果将这段代码放在头部,并且文档内容比较庞大的话,可能打印得到的结果会远远大于所指定的500ms,原因不仅仅是while的同步代码占用了1000ms,且解析文档也会花费时间。 也就是说,JavaScript必须将全部的同步代码执行完毕,才会进入第二阶段执行异步的代码。所谓的定时器,是指在全部同步代码执行完开始,最快将在指定的时间间隔后调用相应的回调函数,但是并不能保证肯定就是这个时间段开始执行,如果两个定时器具有相同的回调函数和时间间隔,他们的执行也不是同时进行的。

经常看见的一种做法是

setTiomeout(function(){
    // todo
},0)

相当于改变了函数体内代码的执行时机,使同步代码转为异步执行,然而浏览器默认有最低的时间间隔(一般不低于4ms)。

2.3. 脚本阻塞

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

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

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

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

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

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

实际上,还有另外两种解决脚本阻塞的方案:使用defer(延迟脚本)和async(异步脚本)。 一般地,defer和async属性都只能用在使用src引入外部脚本的script标签上。两个属性都像是对浏览器声明:这个script标签里面不包含document.write(),不会影响文档流,关于变量作用域的问题我们也已经考虑了,你尽管加载这个文件并同时后面解析文档(不用等待这个脚本下载并解析并执行完毕了)。其中:

  • defer表示推迟脚本到整个文档载入和解析完成之后再执行(最后还是按脚本引入的先后顺序执行)。
  • async表示异步加载脚本及解析和执行,而不阻塞后面文档的解析(外部文件下载完成后尽快执行,多个异步的脚本是谁先下载完谁先执行,因此是无序执行)

如果同时存在async和defer属性,浏览器将忽略defer属性。

2.4. 小结

现在我们了解了普通、阻塞的脚本,也了解了延迟、异步的脚本,并且知道了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事件之后,开始调用异步事件,以异步响应用户输入事件,网络事件和定时器到期等

3. 样式表阻塞

还必须注意的一个问题是,当HTML解析器遇见一个style标签时,会按顺序解析里面的样式;当HTML解析器遇见一个link标签,会发送一个外部样式表的请求。这样的后果是到导致后面的后面的JS代码:

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

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

好吧,上面的情形是我YY的。这个结论是我在JS和CSS的位置对其他资源加载顺序的影响这里看见的。我需要学习开发者工具的Timeline面板来查看具体的时间信息了,这里待我回头补充。 需要注意的是,CSS文档的加载和解析,阻塞的是脚本的执行而不是脚本的加载。而同步JS脚本的加载解析和执行,是会影响HTML解析器的工作,导致后面的所有资源都无法被加载。