前言
当我们去探索浏览器运行原理的时候,我们倾向于执行多个例子去推断其内在的设计;在很长一段时间内,我也是这么去探索浏览器这个黑盒的,但这么做始终只是验证例子本身而不能断言浏览器实际的行为。为了追求真理(误),决定写这个系列的文章,从源头探索浏览器的行为准则。
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等事件的触发时机。
需要注意一些点:
- 本文使用Chrome作为测试浏览器
- 参考资料来自于HTML Standard、Chromium Design Document
由于上述原因,在非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保留字符如:
- 还有很多我没想到的!
而我们接收到的是一串无状态的字符串,为了方便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呢?
- 一个含有href、type为text/css且media值为空或者符合当前媒体查询的link标签
- 一个使用了@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是如何加载执行的呢?
- 当HTML Parser遇到这种类型的脚本时,会退出当前的Parser任务,并将来自HTML Parser的所有任务冻结(冻结的效果是Event Loop不会执行HTML Parser的任务,也就是我们常说的HTML Parser被阻塞了)。
- 并行的执行如下步骤(此时主线程在执行除了HTML Parser的其他任务):
- 不断查询脚本是否加载完毕,查询script-blocking style sheet counter是否等于0,直到两个条件都为true
- 恢复之前的Parser任务并推入到event loop中
- 解冻HTML Parser的任务
- 执行脚本
通过上面步骤,我们看到在这个场景下,样式的加载解析是会阻塞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加载完成后任意一个时机。
如果大伙觉得写得还行,希望能点个赞,对下一篇感兴趣可以加个关注😁