本文旨在介绍浏览器的事件循环机制,并结合 HTML Standard
分析它在HTML文档解析任务(HTML Parsing)
中的具体体现,以及脚本的加载和执行对解析任务和页面渲染造成的具体影响。
一、概念介绍
事件循环 (Event Loop)
使浏览器可以让用户交互,脚本执行,页面渲染,处理网络请求响应等各种功能协调有序运行的重要机制,不断循环执行 任务->微任务->渲染 这一流程。
任务队列 (Task Queue)
一个事件循环拥有一或多个任务队列(Task Queue
), 而每一个任务队列是一个 Set
而非 Queue
,因为事件循环运行模型是每次运行所选任务队列中的第一个可运行的任务,而非第一个入队的任务。
任务 (Task)
任务队列中存储且将被执行的逻辑被称为任务,典型的任务如:
- HTML 文档解析(
HTML Parsing
) - 异步请求资源到达后的处理
- 用户交互事件的响应
微任务队列 (Microtask Queue)
每个事件循环拥有一个微任务队列,用来存储微任务(Microtask
)的队列,是一个真正的 Queue
, 所以微任务是先进先出的。
微任务 (Microtask)
不同于任务(Task
) , 微任务存储于微任务队列中,并且当事件循环到达微任务检查点时逐个执行微任务队列中的微任务。
二、事件循环机制运行模型
事件循环机制不间断执行如下步骤:
-
如果至少有一个拥有可执行任务的任务队列,则选择一个任务队列,从中提取出一个可执行的任务并执行
-
当任务执行完成后,进入微任务执行检查点(Perform a microtask checkpoint) :
- 将微任务执行队列中的所有微任务依次执行直到微任务队列为空
-
更新渲染 (Update the rendering),浏览器会根据更新频率,GPU状态等判断是否需要更新视图,如果需要则更新视图。
以HTML文档解析(HTML Parsing
) 这一任务举例:在浏览器收到HTML文档,事件循环机制选择了 HTML解析 这一任务来执行,HTML parser
开始对HTML文档进行解析,解析的目的是将 HTML文档 通过 Tokenization
和 Tree Construction
等步骤转化为 DOM
树,在解析任务完成之后,开始检查并执行微任务,再之后浏览器便开始更新渲染,渲染完成后页面得以被呈现在用户面前。
特别地,如果在解析过程中,遇到了脚本结束标签: </script>
, 并且该脚本的开始标签 <script>
并没有 defer , module, type="module"
这些特殊属性,那么该脚本的网络请求与执行都会阻塞 HTML 解析任务, 事件循环很可能会执行一次微任务检查(取决于JavaScript执行栈是否为空),然后挂起解析任务以等待脚本执行完成,详见下文。
三、HTML文档解析与首屏渲染
文档解析与脚本加载
上文提到,HTML 文档的解析工作是一个典型的任务(Task
),而根据事件循环机制的运行模型,如果想进行页面的渲染,至少要等到任务执行完毕并且将可能存在的微任务执行完成。那么如果HTML解析被脚本的获取和执行阻塞从而耗时很长,事件循环一直卡在当前任务,无法执行到更新渲染步骤,那么页面是否会一直保持白屏?
我们用下面的代码测试一下,其中 interval.js
直接被服务器返回,timeout.js
延迟1秒钟返回:
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>First-Contentful-Paint</h1>
<script src="http://localhost:3000/static/interval.js"></script>
<h2>After Interaval.js executed</h2>
<script src="http://localhost:3000/static/timeout.js"></script>
<h2>After Timeout.js executed</h2>
</body>
</html>
运行代码,观察到在 interval.js
返回并执行后且在 timeout.js
返回之前,页面已经有了内容:
使用开发者工具中的Performance 工具,First Contentful Paint (首次内容渲染) 确实出现在 DOMContentLoaded 事件触发之前:
其中 First ContentFul Paint
代表某次事件循环的渲染步骤(update the rendering
) 首次渲染出了文字或图片内容。
DOMContentLoaded
事件代表HTML文档解析基本完成并且脚本(不含拥有 async 属性的脚本)请求并执行完毕。
这证明事件循环并没有按照 任务->微任务->渲染 这一简单流程运行,而是在任务进行一半时进行了渲染,事件循环被打乱了吗?
Spin the event loop
如上文所说,事件循环机制是用来协调脚本执行,页面渲染等功能有序运转的重要功能,如果存在一种情况可以改变它的运行机制,HTML规范中应该有所体现。查阅规范,发现在HTML解析遇到</script>
结束标签时,如果有阻塞脚本的样式表或者脚本没有处于可以被解析器执行的状态时,会进行一种叫做 spin then event loop
的操作:
在上文代码中,timeout.js 需要一秒钟延迟才会请求成功,那么HTML在解析到
xml
<script src="http://localhost:3000/static/timeout.js"></script>
的</script>
结束标签时,脚本没有被加载完成,自然没有处于可以被执行状态,所以会执行 spin then event loop
,同时,我们注意到规范中提到了
If the parser's Document has a style sheet that is blocking scripts
证明有些样式表是可以阻塞脚本的,而脚本的执行会阻塞HTML解析,所以其实样式也会间接的阻塞HTML解析。
既然 spin then event loop
会影响事件循环的运转,我们继续查阅其规范:
spin the event loop 操作为:
- 保存事件循环正在执行的任务相关信息,保存
JavaScript
执行栈。 - 执行微任务检查点
- 停止当前任务,并且等待给定的条件达成之后,恢复
JavaScript
执行栈,将这个任务后续的步骤放入事件队列继续执行。
简单地说就是将当前执行的任务先挂起并清空微任务队列,直到某些条件完成之后再重新加入任务队列继续执行。
于是,在HTML文档解析到</script>
结束标签时,由于 timeout.js 的请求没有完成,导致整个 HTML 解析任务 被从事件循环中挂起,以方便浏览器将现有的部分DOM渲染出来,并且由于解析任务被挂起,使事件循环能选择其余的任务并且得以按照 任务->微任务->渲染 这一流程继续正常运转而不是一直等待HTML解析任务的完成。
为了验证HTML解析任务真的被挂起,并且事件循环可以选择其他任务执行,我们在 interval.js
中编写如下代码,使用定时器创建任务,每10ms打印进行一次打印,如果解析任务真的被挂起,事件循环便可以选择定时器创建的打印任务并执行微任务检查点从而执行微任务:
JavaScript
function microTaskFunc() {
console.log('Microtask execute at ' + performance.now())
}
setInterval(() => {
console.log('Task execute at ' + performance.now())
Promise.resolve().then(() => microTaskFunc())
} , 10)
运行结果如下:
可以看到,在 <h2>After Timeout.js executed</h2>
被渲染并显示之前,打印已经出现了,打开 Performance 也可以观测到,interval.js
触发的定时器任务和微任务确实在 timeout.js
加载并执行之前以及在 First Contentful Painting
之前就已经执行了:
Speculative HTML parsing
如上文所说,脚本的请求和执行会阻塞HTML的解析,但是规范中存在一种机制,可以让浏览器在 等待阻塞HTML解析脚本的加载并执行 的这段时间内"推测性"的分析HTML,找出存在于HTML内的其余脚本,样式表或其他资源并在这段时间内发出请求以充分利用时间 , 详见 Speculative HTML parsing 。
让我们将代码中的 timeout.js
和 interval.js
互换一下位置,并且加入一个样式表:
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>First-Contentful-Paint</h1>
<script src="http://localhost:3000/static/timeout.js"></script>
<h2>After Timeout.js executed</h2>
<script src="http://localhost:3000/static/interval.js"></script>
<h2>After Interaval.js executed</h2>
<link rel="stylesheet" href="http://localhost:3000/static/index.css">
</body>
</html>
打开浏览器查看请求状态:
可以看到浏览器在 timeout.js
仍在请求时,通过 Speculative HTML parsing
机制对HTML文档中处于后面的资源进行了请求,充分利用了时间。
脚本分类
<script>
标签的 defer , async
等属性对于脚本的请求和执行是否阻塞HTML解析任务有着不同的影响,HTML 标准总结了一个图表:
这张图表明带有 defer , async , type="module"
的脚本请求时不会阻塞文档解析,并且 async
属性的脚本是请求成功后即执行,defer
属性是会推迟到文档解析结束时执行,有关脚本类型的具体区别,以及 css 如何阻塞脚本执行,有一篇很好的文章供参考: HTML Standard系列:浏览器是如何解析页面和脚本的
四、事件循环与 JavaScript
HTML Standard 在 8.1.6 规定了提供给 JavaScript
规范使用一些钩子方法(JavaScript specification host hooks
),其中的一些方法得以让 JavaScript
可以将回调函数包装成为任务或微任务并且推入任务队列或微任务队列,比如 HostEnqueuePromiseJob 可以将微任务推入微任务队列,它是 Promise
实现的关键: