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

前言

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

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

读完本文能够得到的收获

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

须要注意一些点:html

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

浏览器是如何解析HTML

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

Dom和HTML Parser

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

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

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

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

而HTML Parser产出的是Dom,而不是实际的界面,且Dom的变化并不会马上致使绘制(相似react调用setState)。github

因此当HTML Parser阻塞意味着暂停产出新的节点(eg: HTMLElement),有些时候HTML Parser阻塞并不表明浏览器没法绘制界面。(若是已经存在部分Dom和Css我为何不能绘制?)web

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

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/…

相关文章
相关标签/搜索