在梳理整个过程之前,大家先来思考两个问题。代码如下:
html
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
<script>
console.time('DOMContentLoaded')
document.addEventListener("DOMContentLoaded", function () {
console.timeEnd('DOMContentLoaded')
});
</script>
<!-- sleep参数代表多长时间后资源返回,单位ms -->
<script src="http://localhost:9999/experiment/html-load/main2.js?sleep=2000"></script>
<script src="http://localhost:9999/experiment/html-load/main2.js?sleep=2001"></script>
</head>
<body>
<div id="app"><div>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=4000"></script>
<div>test</div>
</body>
</html>
js
// main.js
document.getElementById('app').innerHTML = '123456789'
debugger
- 在 index.html 中我们一共有3个资源请求,这3个资源请求一共耗时多久?
- 在 main.js 中能获取到对应的 dom 吗?
一、常见误区
- 误区一:浏览器在解析html时是逐行解析的,脚本需等到解析到该位置才开始下载
- 误区二:解析完整个html文件才会生成 dom 进行渲染
预加载所有资源
浏览器解析DOM时,虽然会一行一行向下解析,但是它会预先加载具有引用标记的外部资源(script、link等)。这样可以避免在解析到对应的资源位置时才发起请求,从而减少请求延迟。
从上图可以看出,在初始时就对所有外部资源进行了加载。总耗时也是 4s。
渐进式渲染
渐进式渲染指的是,在浏览器逐步解析HTML文档的过程中,逐步构建页面的DOM树和CSSOM树,而不是一次性全部解析完成再去渲染。因为浏览器的解析和渲染由两个不同线程来完成,所以可以实现边解析边渲染。这样可以避免在解析到所有资源之前就开始渲染页面,从而减少页面的空白时间。

从上图可以看出,在执行 main.js 文件时,就已经可以获取到 app Dom 并进行操作。而后面的 div 还没有解析渲染。
二、解析 js 资源
2.1 基本阻塞机制
-
解析中断:
- 当HTML解析器遇到
<script>
标签时,会立即暂停DOM树的构建 - 浏览器必须等待脚本下载(如果是外部脚本)并执行完成后才能继续解析
- 当HTML解析器遇到
-
执行依赖:
- JavaScript可能操作DOM,因此需要完整的上下文环境
- 执行时需要确保之前的DOM节点已正确构建
-
同步特性:
- 默认情况下脚本是同步执行
- 浏览器必须按文档顺序依次处理每个脚本
2.2 不同位置的阻塞表现
-
head中的脚本:
- 阻塞后续DOM构建
- 延迟整个页面的首次渲染
- 示例:
<head><script src="a.js"></script></head>
-
body中的脚本:
- 阻塞后续DOM节点的解析
- 不阻塞已解析内容的渲染
- 示例:
<body><div>test</div><script src="b.js"></script></body>
-
内联脚本:
- 立即执行,阻塞所在位置的解析
- 示例:
<script>console.log('inline')</script>
三、解析 css 资源
3.1 css不阻塞解析,但是会阻塞渲染
先来看段代码,在 html 中我们引用了一个 css 文件,并给 css 文件设置了一个 6s 的延时加载。
html
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
<script>
console.time('DOMContentLoaded')
document.addEventListener("DOMContentLoaded", function () {
console.log(document.getElementById('app'))
console.timeEnd('DOMContentLoaded')
});
</script>
<link rel="stylesheet" href="http://localhost:9999/experiment/html-load/css.css?sleep=6000">
</head>
<body>
<div id="app">123</div>
</body>
</html>
运行结果如下:

从上图可以看出,0.4ms 时就触发了 DOMContentLoaded 事件并且获取到了 app 元素。但是浏览器并没有将 dom 节点渲染出来。这说明 css 资源不会阻塞 dom 解析,但是会阻塞渲染。
这里测试的是 head 标签中的 css 资源请求。而在 body 中引用的 css 资源会因不同的浏览器有不同的表现。在某些浏览器中不会阻塞渲染,这就会导致闪屏现象。因为在这种情况下会渲染两次:一次是原始样式,一次是拿到css资源后渲染的。
3.2 css 会阻塞 js 执行
html
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
<script>
console.time('DOMContentLoaded')
document.addEventListener("DOMContentLoaded", function () {
console.log(document.getElementById('app'))
console.timeEnd('DOMContentLoaded')
});
</script>
<link rel="stylesheet" href="http://localhost:9999/experiment/html-load/css.css?sleep=6000">
</head>
<body>
<div id="app"></div>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=0"></script>
</body>
</html>
在html文件中,我引入了一个加载耗时 6s 的 css 文件,并且在 body 中引用了一个耗时 0s 的 js 文件。文件代码如下:
css
#app {
color: red;
}
js
document.getElementById('app').innerHTML = '123456789'
// 这里加了一个 debugger 是为了方便调试。
debugger
运行结果如下:

页面过了大概 6s 才进入 debugger。并且可以从上图可以看出,在执行 main.js 文件的时候,浏览器已经渲染出了 app 元素,并且此时的颜色已经变成了红色。这说明 css 资源阻塞了 js 执行。这也是考虑到 js 脚本可能有获取 dom 元素的样式属性等内容,因为将 css 资源都加载完毕后才执行 js。
因此也建议将 css 资源放在 js 资源之前获取,尽早获取到 css 资源也可以尽快渲染出页面,同时防止阻塞 js 脚本执行。
四、async 和 defer
async 和 defer 都是优化脚本加载的HTML属性,async 使脚本异步加载后立即执行,defer 使脚本异步加载但延迟到DOM解析完成后执行。
4.1 async特性
- 异步加载:不阻塞HTML解析
- 立即执行:下载完成后立即执行
- 执行顺序不确定:取决于下载完成时间
- 适用场景:独立脚本,不依赖DOM或其他脚本

4.2 defer特性
- 异步加载:不阻塞HTML解析
- 延迟执行:DOM解析完成后按文档顺序执行
- 保证执行顺序:保持脚本在文档中的顺序
- 适用场景:依赖DOM或需要按顺序执行的脚本

这里大家可以探讨下,使用了 defer 的脚本标签是否等效于将普通的 scpirt 标签放在文档末尾?
4.3 主要区别
特性 | async | defer |
---|---|---|
执行时机 | 下载完立即执行 | DOM解析后执行 |
顺序保证 | 无 | defer之间的代码执行顺序与在文档中出现的顺序相同 |
DOMContentLoaded | 可能阻塞 | 阻塞 |
典型用例 | 统计脚本 | 依赖DOM的脚本 |
这里解释下为什么 async 可能阻塞而 defer 会阻塞 DOMContentLoaded 事件的触发。首先 async 特性是脚本是下载完立即执行,如果脚本事件下载过长,DOMContentLoaded 事件可能已经触发完毕,这时候执行脚本也不会阻塞。而 defer 的特性是延迟到 dom 解析完毕,DOMContentLoaded 事件触发之前执行,也就是说只要 defer 的代码没下载、执行完,特性是脚本是下载完立即执行,如果脚本事件下载过长,DOMContentLoaded 事件都触发不了。
五、preload 和 prefetch
preload 用于提前加载当前页面必需的关键资源,prefetch 用于预取后续页面可能需要的资源。二者都是资源提示,可优化页面加载性能。在介绍二者之前,需要先了解下浏览器对不同资源加载的优先级。
5.1 浏览器资源请求优先级
浏览器对常见资源的加载优先级如下(从高到低):
- Highest: HTML文档、CSS资源、字体
- High: head 里的 js 脚本
- Medium: body 里的 js 脚本
- Low: 使用了 defer 的 js
- Lowest: 使用 prefetch 的资源
更多了解可以参考这篇文章浏览器页面资源加载过程与优化
来段代码测试下
html
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1000"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1001"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1002"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1003"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1004"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1005"></script>
<link rel="stylesheet" href="http://localhost:9999/experiment/html-load/css.css?sleep=1000">
</head>
<body>
<div id="app"></div>
</body>
</html>
在 html 文件中,我引入了6个 js 文件和1个css文件,并且把css文件放在了最后。因为我是用node 起的本地服务,用的 http1.1 的协议,每个域名限制并发数为6,因此可以测试下资源加载顺序。

可以看到虽然 css 资源顺序放在最后,但是浏览器还是先加载了。
不过并非每次结果都是这样,有时候还是会按顺序加载资源。这是我没搞明白的,有懂得大佬可以帮忙解释下。

5.2 preload
- 立即加载:以高优先级立即请求资源
- 当前页面使用:资源用于当前页面
- 必须指定类型 :需通过
as
属性声明资源类型 - 适用场景:关键CSS/JS、字体、首屏图片
5.2.1 不阻塞 dom 解析和渲染
来段代码测试下。在 html 中使用 preload 加载了一个 js 文件,然后在 js 文件中打印了一句话, 同时还有一个 css 文件
html
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
<script>
console.time('DOMContentLoaded')
document.addEventListener("DOMContentLoaded", function () {
console.timeEnd('DOMContentLoaded')
});
</script>
<link rel="preload" as="script" href="http://localhost:9999/experiment/html-load/main.js?sleep=1000">
<link rel="preload" as="style" href="http://localhost:9999/experiment/html-load/css.css?sleep=1000">
</head>
<body>
<div id="app">123</div>
</body>
</html>
js
// main.js
console.log('main.js')
css
/* css.css */
#app {
color: red;
}
运行结果如下

上文我们提到过,js 资源会阻塞 dom 解析 而 css 资源会阻塞页面渲染。但是上图可以看到虽然 js、css 资源要 1s 后才返回,但是页面依然很快就进行了渲染并且打印出了 DomContentLoaded 。这说明 preload 并不会阻塞 dom 解析和渲染。
5.2.2 只下载不执行
从上图可以看到,控制台并没有打印出我们 main.js 里的打印语句,并且样式表文件也没有执行。同时弹出警告说我们使用了 preload 提前下载资源但是没有执行。这是因为 preload 只下载不执行。
5.2.3 高优先级提前加载
preload 很多人理解是提升资源加载的优先级 ,包括很多文章也这么解释。但是这个词理解下来就是,我本来是一个优先级较低的资源,或者说在html文件里位置靠后的资源,我使用了这个提示符后就能先于其它文件进行加载。这么理解的话其实就是陷入了一个误区。我们来测试一下。
我在 html 文件中有5个普通 js 请求,1个 css 请求,以及位置最后的使用了 preload 的 js 资源请求。
html
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1000"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1001"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1002"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1003"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1004"></script>
<link rel="stylesheet" href="http://localhost:9999/experiment/html-load/css.css?sleep=1005">
<link rel="preload" as="script" href="http://localhost:9999/experiment/html-load/main.js?sleep=1006">
</head>
<body>
<div id="app">123</div>
</body>
</html>
代码运行结果如下:

从上图可以看到,虽然使用了 preload 资源提示符,但是该资源依然是最后才进行请求的,它并没有先于其它资源进行加载。
而真正理解 preload 的用法的其实是 高优先级提前加载 。就是说在 html 文件中并没有明确指示要去下载的资源(不在第一现场),而是某个资源请求执行后,在这个文件里再去请求的资源(在第二现场)。如果这个文件对于首屏加载十分重要,那么我们就可以使用 preload 将它放在 html 中以高优先级去加载,这样就不需要再去等,只管执行就好了。
举个例子,我们 html 文件中引用了 main.js 文件,而在 main.js 文件中又引用了 main2.js 文件。
html
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
</head>
<body>
<div id="app">123</div>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1000"></script>
</body>
</html>
js
// main.js
import('http://localhost:9999/experiment/html-load/main2.js?sleep=1000').then(() => {
console.log('main2.js')
})
js
// main2.js
document.getElementById('app').innerHTML = 'main2.js'
运行结果如下:
可以看到 main2.js 需要等到 main.js 下载执行后才会去请求。而 main2.js 对于渲染首屏又至关重要,这时候就可以使用 preload 提前去下载该资源。
html
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
<link rel="preload" crossorigin href="http://localhost:9999/experiment/html-load/main2.js?sleep=1000" as="script">
</head>
<body>
<div id="app">123</div>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1000"></script>
</body>
</html>

这时候 main2.js 就被提前到页面首个生命周期进行加载了。利用好这个,就能提升首屏的渲染时间。
preload的核心价值在于优化关键渲染路径,通过提前加载首屏必需资源来提升页面性能。现代浏览器普遍具备预测解析能力,能够自动预取HTML中显式声明的外链资源(如入口脚本和样式文件),因此这些资源无需额外preload。然而,对于CSS或JavaScript中动态引用的关键资源(如字体文件),由于它们只有在相关代码执行后才会被加载,使用preload显式声明可以确保这些资源尽早获取,有效避免渲染延迟。
preload资源会被浏览器缓存,但遵循HTTP缓存规则。若后续实际请求时缓存未过期,则直接复用预加载的资源,避免二次下载。注意相同URL的资源会被视为同一资源,因此建议为版本化资源添加hash指纹。
当preload跨域资源时,需要正确设置crossorigin属性,否则会出现以下现象:1) 浏览器会发起两次请求,第一次预加载请求被丢弃;2) 资源实际加载时间延长。对于同域资源,省略该属性则不会产生这些问题。简单来说,crossorigin不匹配会导致资源重复加载和性能损耗。
5.3 prefetch
- 空闲时加载:在浏览器空闲时低优先级加载
- 未来页面使用:资源可能用于后续导航
- 缓存策略:资源会被缓存供后续使用
- 适用场景:下一页面的资源、非关键功能
prefetch 和 preload 特性类似,不阻塞 dom 解析和页面渲染,只下载不执行。并且也用于提前下载资源,但是它是以低优先级的方式去加载资源,也就是等浏览器空闲的时候再去加载资源。但是具体是什么时机呢?是等浏览器所有资源都请求完了就去下载还是只要位置出现了空缺(http1.1并发数是6)就下载呢?
我们来段代码测试下。这里我在 head 放置了4个普通 js 资源请求和一个 prefetch 请求,body里放置了3个普通js资源请求。
html
<!DOCTYPE html>
<html>
<head>
<title>dom-render</title>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=1000"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=2000"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=3000"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=4000"></script>
<link rel="prefetch" href="http://localhost:9999/experiment/html-load/main2.js?sleep=5000" as="script">
</head>
<body>
<div id="app">123</div>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=6000"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=6001"></script>
<script src="http://localhost:9999/experiment/html-load/main.js?sleep=6002"></script>
</body>
</html>
运行结果如下

我们可以看到,prefetch 资源请求优先级为 Lowest, 并且它的请求时间是在 sleep=4000 的资源之后开始下载。我们来计算下,并发数为6个情况下,1000、2000、3000、4000、6000、6001 先执行请求。1s 后1000的请求完毕, 6002的发起请求。2s 后2000的请求完毕,这时候就已经出现空缺了,但是 pretch 的资源并没有马上发起请求,而是等到 4000 的请求完毕才发起的请求,此时 body 里的资源也没请求完毕。也就是说pretch的时机是在head里的资源请求完毕并且浏览器有空闲的时候发起的请求。如果 body 里的请求数较多,它依然要排在后面的。
5.4 主要区别
特性 | preload | prefetch |
---|---|---|
优先级 | 高 | 低 |
使用时机 | 当前页面 | 未来可能页面 |
资源类型 | 必须指定 | 可省略 |
典型用例 | 关键资源 | 预取下一页资源 |
最佳实践:preload用于关键路径资源,prefetch用于预测性加载。错误使用preload可能导致资源竞争,而过度prefetch可能浪费带宽。
探讨
使用了 defer 的脚本标签是否等效于将普通的 scpirt 标签放在文档末尾?
这里我认为二者是几乎等效的。如果大家有其它不同想法,欢迎探讨。
- 下载时机 :现代浏览器下会预先加载具有引用标记的外部资源,所以二者都会同时去下载。但是普通脚本的优先级高于 defer,会先于 defer 脚本下载。不过下载完了也不会执行,所以没什么影响。
- 执行时机 :二者的执行时机是相同的。文档末尾的脚本是在最后执行的,而 defer 脚本会在文档解析完成后,DomContentLoaded 事件触发之前执行,二者是差不多的。
- dom阻塞 :文档末尾的脚本执行时,在它之前的 dom 都已经解析完毕,所以也不存在阻塞问题,而 defer 脚本本身也是在文档解析后执行,所以也不会阻塞。
这里给大家推荐一款基于AI进行代码审核的插件,插件地址,希望大家能star并提一些改进意见。文章地址