HTML Standard系列:浏览器是如何解析页面和脚本的

4,521 阅读15分钟

前言

当我们去探索浏览器运行原理的时候,我们倾向于执行多个例子去推断其内在的设计;在很长一段时间内,我也是这么去探索浏览器这个黑盒的,但这么做始终只是验证例子本身而不能断言浏览器实际的行为。为了追求真理(误),决定写这个系列的文章,从源头探索浏览器的行为准则。

HTML Standard即由w3c制定的html规范,而我们实际使用的浏览器的核心,诸如chrome内部的webkit,则是html规范的实现。所有界面行为(不包括浏览器自身元件如书签)都由HTML规范所描述,并由webkit(或者其他浏览器引擎)实现。需要注意的是Javascript规范并不是由w3c制定的,HTML规范只定义了其中的Document和window对象,也就是常说的DOM和BOM,我们接下来探究的就是HTML的规范是如何定义的。

读完本文可以获得的收获

  • 粗略了解HTML Parser的每个流程和作用。
  • Css/JavaScript等资源加载和HTML parser之间的关系
  • DOMContentLoaded、window.onload、onreadystatechange等事件的触发时机

需要注意一些点:

由于上述原因,在非webkit内核浏览器测试本文用例可能会有问题。

浏览器是如何解析HTML

准确的说浏览器不只能解析HTML,还能解析包括XML文档、大部分图片格式、PDF文件等等,但是我们现在只关注HTML是如何被解析的。

Dom和HTML Parser

在开始探讨浏览器解析html的流程之前,先对Dom进行一下定义:

  • 用于浏览器内部的页面抽象表示(浏览器根据Dom构建的渲染树绘制页面)。
  • 暴露给JavaScript操作的接口。

本文将HTML文本生成Dom的过程称为解析(Parser),将Dom Tree + Css生成Layout Tree再进行绘制的过程称为渲染(Render)。

Dom对于浏览器而言,就像Virtual Dom于前端开发者,Dom并不是真实的视图(我们所看到的界面),但浏览器可以根据Dom Tree和Css计算出底层绘制指令。

而HTML Parser产出的是Dom,而不是实际的界面,且Dom的变化并不会立刻导致绘制(类似react调用setState)。

所以当HTML Parser阻塞意味着暂停产出新的节点(eg: HTMLElement),有些时候HTML Parser阻塞并不代表浏览器无法绘制界面。(如果已经存在部分Dom和Css我为什么不能绘制?)

让我们一步步揭开浏览器的神秘面纱。

HTML Parser 执行流程

当我们打开一个网页的时候,实际上浏览器发起了一个请求,最终将请求结果呈现给用户。作为开发者,我们关心的是:在这个过程中浏览器应该进行怎么样的准备工作?是如何去处理请求的响应的?带着这两个疑问,我们继续往下看。

那么浏览器是如何做的呢?浏览器在请求资源之初会初始化一个独立的上下文称为browsing context包含了:

  • 不包含任何Element的document(在请求返回前甚至不能知道document的类型)
  • 一个和document相对应的window对象
  • JavaScript运行环境,以保存脚本运行结果
  • 将this绑定到window上。

当请求的资源是一个HTML文件的时候(浏览器使用content-type识别),浏览器会初始化一个HTML Parser关联到当前的document,并将响应结果传入给HTML Parser进行解析,这是HTML Standard中规定的parser流程:

我们来一步步理解这流程的意义。

Byte Stream Decoder & Input Stream Preprocessor

我们从file system或者http response中拿到是字节流,拿到字节流后浏览器会尝试去decode,会根据如下的设定选取解码器

  • 根据http头部字段conten-type获取字符编码。
  • 根据文档的meta标签获取,例:<meta charset="UTF-8" / >
  • 如果上述两个都没有,浏览器会通过字节编码嗅探算法决定字符编码

此外规范还推荐使用兼容ascii编码的编码(例如utf-8)(vsc默认使用该编码保存文档)去编写HTML文档,因为规范使用ascii编码探测meta标签,进而获取文档的编码。

一个使用了错误编码解析的文档:

至此,我们的浏览器终于能正确的将字节流decode了,但是在处理字符构建Dom之前,还需要额外的预处理,称为Input Stream Preprocessor,这个步骤执行的做的事情仅仅是标准化换行符,因为在不同系统下使用文本使用的换行符是不一致的,例如Windows使用CRLF作为换行符,而类Unix系统使用LF作为换行符

除此之外,我们看到Script Execution步骤通过调用document.write回流到Input Stream Preprocessor(这也是为什么脚本执行会阻塞浏览器解析的原因),顾名思义在脚本执行阶段,document.write插入的内容会在注入到Input Stream中,并且作为下一个解析点。我们来看看效果:

<!DOCTYPE html>
<html lang="en">
...
<body>
  <p>
    parser first
  </p>
  <script>
    document.write('<p>parser second</p>')
  </script>
  <p>
    parser third
  </p>
</body>
</html>

可以看到document.write调用的输出要在脚本之后的标签的前面。

Tokenizer & Tree Construction

Tokenizer在编译器领域是比较常见的一个名词,直译过来就是令牌化的意思,我们考虑一下HTML文档中有多少种类型的字符

  • 文档注释
  • html标签
  • 要展示的文本内容
  • 内联的样式代码和脚本代码
  • html保留字符如:&nbsp;
  • 还有很多我没想到的!

而我们接收到的是一串无状态的字符串,为了方便HTML解析,我们需要将这一长串字符串,切分成一系列子串,并打上相应的标签,赋予对应的状态,一个个的传递给Tree Construction,这就是Tokenizer的职责。

Tree Construction有一系列的插入状态,确保node节点插入在合适的位置,如果一个节点出现在非法位置则会导致Parser Error(eg:在head内写了个span标签),Parser Error不一定会导致Parser终止,规范定义了一系列纠错机制

<!-- 一系列的插入状态保证了html解析成dom能生成如下的结构 -->
<!DOCTYPE html>
<html lang="en">
<head></head>
<body></body>
</html>

<!DOCTYPE html>
<html lang="en">
<head>
  <!--
    非法的节点类型,会导致Parser Error,根据纠错机制会忽略该节点。
    document.querySelector('#invalid')将会是null
  -->
  <span id="invalid">2</span>
</head>
<body></body>
</html>

Tree Construction最终会产出一个node节点并插入到Dom中,这时候我们就可以通过JavaScript去操作Dom了。

这样就完事了?好戏现在才开始! 在刚刚的叙述过程中,隐藏了对具体标签的解析方式,<script src="index.js" />和<link href="index.css" />怎么能一样呢?

接下来我们说说样式、脚本在HTML Parser执行过程中的具体表现。

样式、脚本和HTML Parser

坊间流传的最广的说法就是,样式加载不会阻塞HTML解析,而脚本会。

这句话太过模糊,毕竟样式有内联样式、外部样式等,同样脚本也有内联脚本、外部脚本,而外部脚本又有defer、async这些属性进行再次区分。

加载、解析样式

样式的解析并不属于HTML Parser的工作内容,对于HTML Parser而言只需要把link或者style标签插入到Dom中就完事了,所以对于style和link标签内的样式资源的加载和解析工作是通过并行的方式去执行的

所谓的并行就是将主线程(HTML Parser所在的线程)返回,并创建一个子线程(或其他并行的实现方式eg:fiber),将接下来的任务(eg: 下载样式和解析样式)放到子线程中执行。

到此为止,结论都指向样式加载解析不阻塞HTML解析,大部分情况下这个结论都是对的,说到这就要说下解析过程中的一个全局变量script-blocking style sheet counter。

这个变量会在如下场景发生变化:

  • 当一个script-blocking style sheet开始解析的时候couter++
  • 当一个script-blocking style sheet解析完成的时候couter--

那么什么样的样式资源叫script-blocking style sheet呢?

  1. 一个含有href、type为text/css且media值为空或者符合当前媒体查询的link标签
  2. 一个使用了@import语法引入外部样式资源的style标签
// script-blocking style sheet
<link rel="stylesheet" type="text/css" href="./index.css"/>
<style>
@import './index2.css';
</style>

现在只需要记住这个变量和脚本执行还有渲染有关系即可,具体联系我们接着看脚本的加载模式。

加载、执行JavaScript

这是一张流传比较广的script加载流程图,基本涵盖了大部分script加载对HTML Parser的影响,但还有部分细节的缺失,我们看看规范是怎么定义的。

对于没有defer/async属性的script,我们称之为pending parsing-blocking script,但需要注意的是:

  • type为module的script,defer属性默认为true
  • 不包括使用JavaScript动态插入的脚本

那么pending parsing-blocking script是如何加载执行的呢?

  1. 当HTML Parser遇到这种类型的脚本时,会退出当前的Parser任务,并将来自HTML Parser的所有任务冻结(冻结的效果是Event Loop不会执行HTML Parser的任务,也就是我们常说的HTML Parser被阻塞了)。
  2. 并行的执行如下步骤(此时主线程在执行除了HTML Parser的其他任务):
    1. 不断查询脚本是否加载完毕,查询script-blocking style sheet counter是否等于0,直到两个条件都为true
    2. 恢复之前的Parser任务并推入到event loop中
    3. 解冻HTML Parser的任务
  3. 执行脚本

通过上面步骤,我们看到在这个场景下,样式的加载解析是会阻塞pending parsing-blocking script的执行的,进而导致HTML Parser的阻塞

<!DOCTYPE html>
<html lang="en">
<head>
   <title>Document</title>
   <!--在我解析完成之前,pending parsing-blocking script别想执行-->
   <link rel="stylesheet" type="text/css" href="./index.css?lazy=1000" /> 
</head>
<body>
    <!--我是pending parsing-blocking script,我会阻塞Parser,
        但我要等到样式解析完成后才能执行-->
    <script src="./index.js"></script> 
    <!--我要等到👆的脚本执行完成后才能解析-->
    <span>hello</span>
</body>
</html>

为什么要样式解析要设计成阻塞这部分脚本执行的呢?考虑如下场景:

// index.css
.color {
    color: red
}
<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
   <link rel="stylesheet" type="text/css" href="index.css" /> 
</head>
<body>
    <p class="color">我的颜色是什么</p>
    <script>
        const element = document.querySelector('.class');
        const color = window.getComputedStyle(element).color;
        console.log(color) // rgb(255, 0, 0);
        // 如果script没有等待css加载完毕就执行,会导致脚本获取到错误样式
    </script>
</body>
</html>

async & defer

我们知道script标签还有async和defer两种影响它加装执行的属性,我们看看具体表现又是如何。需要知道的几点:

  • type为module的script,defer默认为true。
  • async优先级高于defer,即当async存在的时候,忽略defer属性。
  • 由JavaScript创建的script标签async默认为true。

当HTML Parser遇到带有defer标识且没有async标识的script标签,HTML Parser会将对应的script存在一个名为 list of scripts that will execute when the document has finished parsing队列中(有序),并且进行并行下载(不会阻塞主线程),但不会执行;而是等到HTML Parsing完成后,再回头执行这个队列,具体执行时机我们后面还会讲。

当HTML Parser遇到带async标识的script标签的时候,Parser依然会选择把它存起来先,存在一个名为set of scripts that will execute as soon as possible的集合中,然后开启并行下载,与defer不同的是,当async script加载完成后,会立刻寻找机会执行(event loop next tick);这样造成的结果是async script的运行时机不可预测,且是无序的(下载完的先执行);且当在HTML Parsing完成之前,async script下载完毕,依然会阻塞后续的Parser任务(但是async script下载期间不阻塞Parser)

还有一种类型的script list名为list of scripts that will execute in order as soon as possible,目前我探索出来的仅有如下情况符合这种类型的脚本:

// 由JavaScript创建,且aysnc为false的script element
const script = document.createElement('script')
script.async = false; // javascript 创建的script标签async默认为true
script.src = 'dy.js';
document.body.append(script);

对于这种类型的脚本,解析和执行形式和async类似,唯一不同的是这种类型的脚本,会按照添加的顺序执行,而async script是无序的;另外这两种脚本的执行都是不会被样式表的解析所阻塞的

到这里我们应当还有以下疑问:

  • 如果我的脚本async script到解析结束都没下载完,我如何确认一个能够使用async script脚本的时间?
  • 我的defer脚本到底啥时候执行?啥时候能用?window发出onload事件后可以用了吗?

解析完成后的工作以及window.onload触发的时机

HTML Parser在完成解析工作后,还有一些事项需要收尾,比如触发一些事件,把没跑的脚本给跑了等等;需要留意的是HTML Parser解析工作完成后不代表样式表的解析工作完成了,毕竟解析样式表的工作不属于Parser,这意味着script-blocking style sheet counter有可能大于0

一个前置知识document.readyState可以是三个值之一:lodaing、interactive、complete,加载文档时是loading,当状态发生改变时会触发document.onreadystatechange事件。

解析完HTML文本后Parser会执行以下步骤:

  • 将document.readyState设置成interactive,触发onreadystatechange事件(此时意味着所有DOM元素都可以被操作了)。
  • 暂停本任务(本任务指的就是当前Parser,暂停后会运行存在event loop中的其他任务,直到后续条件达成)直到script-blocking style sheet counter为0,且defer脚本下载完毕,然后执行所有的defer script
  • 触发DOMContentLoaded事件,此时可能存在部分async script没有下载完
  • 暂停本任务,直到所有的async script和list of scripts that will execute in order as soon as possible内的脚本下载完毕,然后执行这些脚本,对于后者列表内的脚本有序的,但是这两个之间的脚本可能交错执行。
  • 暂停本任务,直到所有dom元素的onload/onerror事件全部触发(eg:img)
  • 将document.readyState改成complete触发window.onload(此时意味着所有脚本可用,所有DOM节点都触发了onload/onerror事件)
  • 到这里HTML Parser的任务就结束了,接下来会将控制权返回给event loop。

写在最后

部分总结

  • 对于加载了外部资源的样式表,会阻塞除了动态插入和拥有async属性之外的脚本的执行。
  • async脚本和动态插入的sync脚本加载完成后立刻执行且时间不可测,前者无序,后者有序
  • 没有async、defer属性的脚本会阻塞Parser进行下载,所有的脚本执行都会阻塞Parser。
  • Parser会在解析完所有DOM之后,执行defer script
  • document在complete之前,会运行完所有的脚本

感想和预告

了解这些底层逻辑有助于我们,在进行编译时优化时拥有方向,处理首屏加载问题上可以认识到是什么在阻塞浏览器的加载。

其实对于script而言,最优的加载方式早就写在各大论坛的各大博客上了,就是挂在HTML的最后,但是当我们要做一些特殊操作的时候,突然需要script不被放在头部的样式阻塞,或者并行加载一些依赖,这些知识就派上了用场。

说完这些,看完的同学可能会发现文中频繁出现event loop和任务的字眼,其实本质上HTML Parser就是跑在event loop上的一个任务,其实event loop的逻辑在HTML Standard中的规范相当复杂,它不仅仅是调度执行JavaScript的任务,它基本调度了页面中的所有任务。

我的下一篇文章应该会是根据HTML Standard的描述去解析event loop,其中会包括event loop调度模式,会包括本来中提到的任务暂停机制,还有大家应该感兴趣的渲染时机的问题,其实本文没有提到渲染的问题,但其实渲染可能出现在CSS加载完成后任意一个时机。

如果大伙觉得写得还行,希望能点个赞,对下一篇感兴趣可以加个关注😁

例子下载:github.com/MinuteWong/…