图解页面加载

最近对页面加载整个流程的细节有点遗忘,抽空利用 Chrome 的 Performance 观察了几遍,记录一下。

我们带着下面几个问题:

  • script 会阻止 DOM 的构建吗?
  • css 会阻止 JS 执行吗?css 会阻止 DOM 的构建吗?
  • SPA 中为什么 css 放 header,script 放在 body 后?骨架图是什么原理?
  • DomContentLoaded、readystate、Load 的关系是什么?
  • DOM、CSSOM、Render Tree 之间到关系是什么?

总览

我们选用了下面这个简单的 SPA 为例,利用 Chrome 录制了页面加载的全部过程。这是一个标准的 SPA 首页,在 head 中含有一个 CSS 资源,在 body 的最后含有一个 JS 脚本。同时,body 中还有一小段起到“骨架图”作用的片段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>首屏测试</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link href="/assets/styles/index.3c893753.css" rel="stylesheet" />
</head>
<body>
<div id="loading">
<div id="loadAnimate">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<style>
#loading {
background-color: rgba(51, 51, 51, 0.4);
height: 100%;
width: 100%;
position: fixed;
z-index: 1;
margin-top: 0;
top: 0;
left: 0;
}

#loadAnimate {
position: absolute;
top: 50%;
left: 50%;
width: 42px;
height: 42px;
margin: -21px 0 0 -21px;
animation: loadAnimate 5s infinite linear;
}

#loadAnimate span {
width: 20px;
height: 20px;
border-radius: 5px;
position: absolute;
background: red;
display: block;
animation: loadAnimate_span 1s infinite linear;
}

#loadAnimate span:nth-child(1) {
background: #00b4ed;
}

#loadAnimate span:nth-child(2) {
left: 22px;
background: #27cbff;
animation-delay: 0.2s;
}

#loadAnimate span:nth-child(3) {
top: 22px;
background: #72ddff;
animation-delay: 0.4s;
}

#loadAnimate span:nth-child(4) {
top: 22px;
left: 22px;
background: #b1ecff;
animation-delay: 0.6s;
}

@keyframes loadAnimate {
from {
transform: rotate(0deg);
}

to {
transform: rotate(360deg);
}
}

@keyframes loadAnimate_span {
0% {
transform: scale(1);
}

50% {
transform: scale(0.5);
}

100% {
transform: scale(1);
}
}
</style>
<script type="text/javascript">
function removeLoading() {
var loading = document.getElementById("loading");
loading.parentNode.removeChild(loading);
document.on`readystatechange` = null;
}
var timeoutHandle;
document.on`readystatechange` = function () {
console.log(document.readyState);
if (document.readyState === "interactive") {
timeoutHandle = setTimeout(function () {
removeLoading();
}, 5000);
} else {
if (document.readyState === "complete") {
removeLoading();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
};
</script>
<div id="root"></div>
<script
type="text/javascript"
src="/assets/scripts/index.a3c6208c.js"
></script>
</body>
</html>

我们点击 Performance 面板的Start profiling and reload page按钮,等它执行完毕,得到了下面的记录:

白屏到 FP

这个阶段从发出请求获取 HTML 开始,到页面的第一次绘制为止,经历了资源下载、HTML 解析、DOM 构建、获取 CSS、获取 JS、CSSDOM 构建、JS 脚本编译和执行等过程。

解析 HTML

下面截图展示的四个片段,是本次页面加载 Chrome 记录的所有Parse HTML片段(图中仅显示了 HTML 的解析,DOM 的构建没有明确标出)。我们可以发现,HTML 的解析在接收到部分数据流后就开始了,并不需要等待整个文档加载完成。同时,在解析到需要外部资源时,便会请求这些资源。
下面三个截图发生在 FP 之前,代表了三个时刻:

  1. 解析到link标签,请求该 CSS 资源。

  1. 解析到 body 中的第二个script资源,请求该资源。按照浏览器的执行过程,执行到第一个script时,DOM 暂停构建,直到该脚本加载和执行完毕。此处我们发现,HTML 的解析依旧会继续,解析到了一个外部的脚本资源,便发出请求去获取。解析 HTML 和构建 DOM 关联密切,但不是完全等价。

  1. FP 阶段。CSSOM 构建完成,执行内联script。在这个阶段后不久,我们的首屏动画就显示出来了。

  1. 在 FP 之后,浏览器等待index.a3c6208c.js下载完成,并进行解析和执行。执行完脚本后,浏览器便完成了初始文档的解析和 DOM 树的构建,随后触发了 DOMContendLoaded 事件。额外的,上述脚本又穿插了一系列资源,它们都立刻被请求了。

构建和暂停

第三和第四个片段中,我们发现Parse HTML前都有一段Evaluate Script的时刻,这即是我们平常所说的遇到 JS 脚本,则暂停 DOM 构建,等待 JS 执行完成。
而第三阶段在脚本执行前又有一段 CSSOM 构建的过程,这便是 JS 脚本会等待 CSSOM 构建完成的一个例子。
我们也会发现,在等待第一个script脚本执行的过程中,第二个外联script脚本的请求却已经发出了。这是一个浏览器的优化,即使在 DOM 树暂停构建时,后面的资源也会被提前加载。
Chorme 记录的数据似乎没有明显的标志出 DOM 的构建(尚有疑问:解析 HTML 的同时,DOM 解析也在进行吗?)。

对于 script,有两个属性可以使它不阻止 DOM 构建。设置了defer的外部脚本,不会在被解析到时立刻执行。浏览器会先下载它,然后在 HTML 文档全部解析后,DCL 事件触发前执行。设置了async的外部脚本,会在解析 HTML 的时候同步下载,并且一旦它下载完毕,就会立刻执行;但是它在下载的过程中,解析 HTML 的工作还在执行。
特别的,对于type=module的 script,defer属性是默认自带的(且不能设为 false),它总是在文档全部解析后才会执行;async属性将会使这类脚本在文档解析的同时下载其本身和所有依赖,一旦完成了下载,就立刻执行。更详细的内容,可以参考这里

对于 css,如果它被设置为media=print,那它也就不会阻止 JS 的执行了。其他特殊媒体查询可以查询这里。

FP 需要什么

在第三个Parse HTML片段,我们发现浏览器第一次执行了LayoutPaintComposite,不久之后,我们就看到了首屏的 loading 动画。这个阶段里,也是第一出现了Parse Stylesheet,之后,CSSOM 便准备就绪了。至此,我们拥有了 CSSOM 和部分 DOM。浏览器为了减少白屏等待的时间,会依照目前已有的资源,生成一颗render tree,一旦render tree准备就绪了,浏览器就可以绘制页面了。

DCL 和 Onload

在 FP 之后的一段时间内,DOM 的解析依旧没有恢复,这是因为第二个 JS 脚本还没有下载完成。这段时间内,浏览器只能一直等待,页面上仅展现 FP 阶段的内容。当脚本文件完成下载、解析和执行后,浏览器恢复了 DOM 的构建。在所有的 HTML 解析完成、DOM 树构建完成后,浏览器便触发了DCL事件。同时,这个 JS 又给文档添加了额外的 JS/CSS 资源,在这部分资源全部获取并解析后,Window 的 Onload 事件才会被触发。

DCL 和 Onload 事件有什么区别呢?

  • 在触发时机上。当HTML被完全加载以及解析时,DCL 事件便会触发,而不必等待样式表,图片或者子框架完成加载。Onload 则需要等待整个页面所有资源都加载完毕,才会被触发。
  • 除了 window 的 load 事件外,image/JS/CSS/XMLHttpRequest 都有其 load 事件。

readyState 有 4 个值,会在readystatechange事件中触发。

  • uninitialized - 还未开始载入
  • loading - 载入中
  • interactive - 已加载,文档与用户可以开始交互
  • complete - 载入完成

它们之间的关系是:

  1. readystate: interactive
  2. DOMContentLoaded
  3. readystate: complete
  4. load

FCP、FMP、LCP

这几个概念是 lighthouse 提出的性能指标,用来衡量 web 应用的性能。

  • FCP First Contentful Paint
  • FMP First Meaningful Paint
  • LCP Largest Contentful Paint
  • lighthouse 是怎么定义的这些指标?可以从 lighthouse代码中查看。
  • Lighthouse 测试内幕

有关 CSSOM

CSSOM 即 CSS Object Model,是一颗包含 CSS 有关信息的树,和 DOM 结构相似。它也提供了一系列的 API 以供 JS 来操作 CSS。

CSSStyleSheet

我们可以通过document.styleSheets来查询本文档应用的 CSS。一般的,我们可以通过外部样式表、内部样式表、内联样式三种方式引入 CSS 规则,那么它们都能通过document.styleSheets查询到吗?我们用下面的片段来进行试验。

1
2
3
4
5
6
7
8
9
10
<html>
<body style="color: red;">
text
</body>
<style>
body {
color: rgb(139, 195, 74);
}
</style>
</html>

可以发现,内联样式是不会写入document.styleSheets中的。而外部样式表和内部样式表都会被加入document.styleSheets中,并且有一个ownerNode属性表明它来自head还是style标签。根据 HTML 文档,内联 style 会返回一个CSSStyleDeclaration对象,挂载在该节点的style属性中;而 link、style 标签则会返回一个CSSStyleSheet对象,挂载在document.styleSheets中;同时,还可通过该节点的sheet属性访问。

CSSStyleDeclaration

这是我们最常打交道的对象,它可以从三个地方来访问:

  1. HTMLElement.style。用来处理 HTML 元素的内联 CSS。
  2. document.styleSheets[0].cssRules[0].style。用来处理外/内部样式表中的 CSS。
  3. Window.getComputedStyle(HTMLElement)。该方法返回的只读的CSSStyleDeclaration对象。

更多的内容可以阅读 PDF 中的文件。

TL;DR

  • script 会阻止 DOM 的构建。
  • 页面加载时如果存在 CSS 资源,则必须等 CSSOM 全部构建完成,才会执行 script,因为 JS 可能查询、修改 CSSOM。如果存在多个 CSS 资源,因为后面的规则可能覆盖前面的规则,所以必须等待全部 CSS 加载完成,才会进入下一个流程。
  • DOM 和 CSSOM 结合在一起,才能生成 Render Tree。有了 Render Tree 之后,浏览器就可以进行绘制了。DOM 的构建会被 JS 阻止,而 JS 执行又需要查询 CSSOM。所以 script 和 css 都是 render block 的资源。所以我们应当尽早准备好 CSS 资源,尽量把 JS 资源放在文档的后面来解析。骨架图也是基于这个原理。我们可以参照“优化关键渲染路径”,了解和优化 HTML、CSS 和 JavaScript 之间的依赖关系谱。

参考