Web 性能总结之脚本加载

本篇文档主要总结脚本和样式在不同位置加载时浏览器 HTML 解析的一些情况以及最佳实践经验

脚本是否阻塞 HTML 解析

如下代码,在 head 标签内部外链两段脚本,然后使用 Chrome 浏览器 Performance 进行分析,发现浏览器在执行到第 5 行的时候就会暂停解析 HTML 直到脚本下载完毕并执行完毕后再继续解析,其中脚本下载花费12.77ms,图中两个阶段为蓝色,由此可见 JS 的脚本会阻塞 HTML 解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>脚本阻塞HTML渲染</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.6/dist/vue.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js"></script>
</head>
<body>
<h1>hello world</h1>
</body>
</html>

img

仔细观察执行结果后发现脚本下载的时间比解析 HTML 稍早,这是为什么呢?
img

这是因为浏览器的渲染引擎有一个预解析的线程,在接受到 HTML 数据之后,预解析线程会快速扫描 HTML 数据中的关键资源,一旦扫描到了,会立马发出请求。——李兵《浏览器工作原理与实践》

还有一个奇怪的现象,脚本在下载完成后只执行了 vue.global.min.js 就继续从第 6 行开始解析 HTML 了,随后才执行 react.production.min.js ,这个又如何解释呢?
img

如果使用 defer 或者 async 来加载脚本是否有不同?

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>脚本阻塞HTML渲染</title>
<script async src="https://cdn.jsdelivr.net/npm/vue@3.3.6/dist/vue.global.min.js"></script>
</head>
<body>
<h1>hello world</h1>
</body>
</html>

使用 async 脚本加载情形:
img

使用 defer 加载脚本情形:
img
发现async 加载的脚本不阻塞 HTML 的解析了,脚本下载完成后会立即执行,从图中看到 DCL 事件被提前了很多。但在使用 defer 加载脚本不会立即执行,会在 DCL 事件前执行。

使用 preload 提前预加载脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>脚本阻塞HTML渲染</title>
<link rel="preload" href="https://cdn.jsdelivr.net/npm/vue@3.3.6/dist/vue.global.min.js" as="script">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js" as="script">
</head>
<body>
<h1>hello world</h1>
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.6/dist/vue.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js"></script>
</body>
</html>

img
由此可以看到两个资源几乎同时加载了,由于在 body 结束前我们又加载了脚本此时不会重复加载脚本,脚本被立即执行了。如果我们提前加载了脚本又不使用的话在 Chrome 浏览器会发出如下警告:
img

动态加载脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Script Add</title>
</head>
<body>
<h1>hello world</h1>
<script>
function addScript(src) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.src = src;
document.querySelector('head').appendChild(script);
}
window.onload = function() {
addScript('https://cdn.jsdelivr.net/npm/vue@3.3.6/dist/vue.global.min.js');
addScript('https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js');
}
</script>
</body>
</html>

img
由图可以看出使用 JS 动态创建脚本的方式也是不阻塞 HTML 解析的,而且加载完后也立即执行了。

样式是否会阻塞 HTML 渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Style script</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/theme-chalk/index.min.css">
<script>
for (let i = 0; i <= 1000; i++) {
console.log(i);
}
</script>
</head>
<body>
<h1>hello world</h1>
</body>
</html>

img
如上代码当通过外链的方式引入 CSS 时后面又紧跟脚本,脚本会等待样式加载完后再执行,所以需要尽量避免这种情况

最佳的脚本加载方式

  • 关键资源使用 preload 进行提前加载
  • 非关键资源在闲时使用动态加载的方式