前端性能优化基石:HTML解析与资源加载机制详解

在梳理整个过程之前,大家先来思考两个问题。代码如下:

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
  1. 在 index.html 中我们一共有3个资源请求,这3个资源请求一共耗时多久?
  2. 在 main.js 中能获取到对应的 dom 吗?

一、常见误区

  1. 误区一:浏览器在解析html时是逐行解析的,脚本需等到解析到该位置才开始下载
  2. 误区二:解析完整个html文件才会生成 dom 进行渲染

预加载所有资源

浏览器解析DOM时,虽然会一行一行向下解析,但是它会预先加载具有引用标记的外部资源(script、link等)。这样可以避免在解析到对应的资源位置时才发起请求,从而减少请求延迟。

  从上图可以看出,在初始时就对所有外部资源进行了加载。总耗时也是 4s

渐进式渲染

渐进式渲染指的是,在浏览器逐步解析HTML文档的过程中,逐步构建页面的DOM树和CSSOM树,而不是一次性全部解析完成再去渲染。因为浏览器的解析和渲染由两个不同线程来完成,所以可以实现边解析边渲染。这样可以避免在解析到所有资源之前就开始渲染页面,从而减少页面的空白时间。

从上图可以看出,在执行 main.js 文件时,就已经可以获取到 app Dom 并进行操作。而后面的 div 还没有解析渲染。

二、解析 js 资源

2.1 基本阻塞机制

  1. 解析中断

    • 当HTML解析器遇到<script>标签时,会立即暂停DOM树的构建
    • 浏览器必须等待脚本下载(如果是外部脚本)并执行完成后才能继续解析
  2. 执行依赖

    • JavaScript可能操作DOM,因此需要完整的上下文环境
    • 执行时需要确保之前的DOM节点已正确构建
  3. 同步特性

    • 默认情况下脚本是同步执行
    • 浏览器必须按文档顺序依次处理每个脚本

2.2 不同位置的阻塞表现

  1. head中的脚本

    • 阻塞后续DOM构建
    • 延迟整个页面的首次渲染
    • 示例:<head><script src="a.js"></script></head>
  2. body中的脚本

    • 阻塞后续DOM节点的解析
    • 不阻塞已解析内容的渲染
    • 示例:<body><div>test</div><script src="b.js"></script></body>
  3. 内联脚本

    • 立即执行,阻塞所在位置的解析
    • 示例:<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

asyncdefer 都是优化脚本加载的HTML属性,async 使脚本异步加载后立即执行,defer 使脚本异步加载但延迟到DOM解析完成后执行。

4.1 async特性

  1. 异步加载:不阻塞HTML解析
  2. 立即执行:下载完成后立即执行
  3. 执行顺序不确定:取决于下载完成时间
  4. 适用场景:独立脚本,不依赖DOM或其他脚本

4.2 defer特性

  1. 异步加载:不阻塞HTML解析
  2. 延迟执行:DOM解析完成后按文档顺序执行
  3. 保证执行顺序:保持脚本在文档中的顺序
  4. 适用场景:依赖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 浏览器资源请求优先级

浏览器对常见资源的加载优先级如下(从高到低):

  1. Highest: HTML文档、CSS资源、字体
  2. High: head 里的 js 脚本
  3. Medium: body 里的 js 脚本
  4. Low: 使用了 defer 的 js
  5. 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

  1. 立即加载:以高优先级立即请求资源
  2. 当前页面使用:资源用于当前页面
  3. 必须指定类型 :需通过as属性声明资源类型
  4. 适用场景:关键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

  1. 空闲时加载:在浏览器空闲时低优先级加载
  2. 未来页面使用:资源可能用于后续导航
  3. 缓存策略:资源会被缓存供后续使用
  4. 适用场景:下一页面的资源、非关键功能

prefetchpreload 特性类似,不阻塞 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并提一些改进意见。文章地址

参考文章

预加载关键资源,以提高加载速度
浏览器是如何解析html的?
关于 JS 与 CSS 是否阻塞 DOM 的渲染和解析

相关推荐
小飞悟2 分钟前
一打开文章就弹登录框?我忍不了了!
前端·设计模式
烛阴9 分钟前
Python模块热重载黑科技:告别重启,代码更新如丝般顺滑!
前端·python
Azxcc01 小时前
Linux内存系统简介
linux·性能优化·内存子系统
吉吉611 小时前
Xss-labs攻关1-8
前端·xss
拾光拾趣录1 小时前
HTML行内元素与块级元素
前端·css·html
小飞悟1 小时前
JavaScript 数组精讲:创建与遍历全解析
前端·javascript
喝拿铁写前端1 小时前
技术是决策与代价的平衡 —— 超大系统从 Vue 2 向 Vue 3 演进的思考
前端·vue.js·架构
拾光拾趣录1 小时前
虚拟滚动 + 加载:让万级列表丝般顺滑
前端·javascript
然我2 小时前
数组的创建与遍历:从入门到精通,这些坑你踩过吗? 🧐
前端·javascript·面试
豆豆(设计前端)2 小时前
如何成为高级前端开发者:系统化成长路径。
前端·javascript·vue.js·面试·electron