脚本加载机制

深入理解 script 标签的加载机制:defer、async、module、preload 等属性的作用,以及它们对页面解析、渲染和性能的影响。

脚本加载机制

一、脚本加载的重要性

1.1 JavaScript 与页面渲染的关系

当浏览器遇到 <script> 标签时,默认行为是暂停 HTML 解析、获取脚本、执行脚本,然后继续解析 HTML。这个过程被称为"解析器阻塞"(parser blocking)。在慢网络环境下,一个体积较大的脚本可能导致页面长时间空白,严重影响用户体验和 Core Web Vitals 指标。

理解脚本加载机制是性能优化的基础。同一个脚本,使用不同的加载属性可能产生截然不同的性能表现。

1.2 脚本加载属性全景图

属性 并行获取 执行时机 执行顺序 DOM 就绪 渲染阻塞
立即 出现顺序
async 下载完成后立即 无序 是(执行时)
defer DOM 解析完成后 出现顺序
type="module" DOM 解析完成后 出现顺序

二、defer 属性

2.1 行为解析

defer 是最常用的脚本加载优化属性。它告诉浏览器:

  1. 并行获取:脚本的获取与 HTML 解析并行进行,不阻塞解析器
  2. 延迟执行:脚本在 DOM 树构建完成后、DOMContentLoaded 事件之前执行
  3. 保持顺序:多个 defer 脚本按照它们在文档中出现的顺序执行
<!-- 建议将所有不依赖 DOM 的脚本都加上 defer -->
<script defer src="js/vendor/jquery.js"></script>
<script defer src="js/main.js"></script>
<script defer src="js/analytics.js"></script>

上述三个脚本会并行下载,但会按顺序执行——即使 jquery.js 下载较慢,其他脚本也会等待它先执行。

2.2 defer 的实际应用

// main.js - 假设依赖 jQuery
import $ from 'jquery';

$(document).ready(function() {
  $('#app').show();
});
<head>
  <meta charset="UTF-8">
  <title>我的应用</title>
  <!-- jQuery 先出现,保证在依赖它的脚本之前加载 -->
  <script defer src="jquery.min.js"></script>
  <script defer type="module" src="main.js"></script>
</head>

2.3 defer 的注意事项

  • 仅对外部脚本(带 src 属性)有效,对内联脚本无效
  • 模块脚本默认具有 defer 行为,不需要也不应该再加 defer
  • 如果 script 既有 async 又有 defer,浏览器会将其视为仅 async

三、async 属性

3.1 行为解析

asyncdefer 的关键区别在于执行时机执行顺序

  1. 并行获取:与 defer 相同,脚本获取不阻塞解析
  2. 立即执行:一旦脚本下载完成,立即暂停解析并执行
  3. 无序执行:多个 async 脚本哪个先下载完成就先执行
<!-- 适合独立的、不依赖 DOM 的分析脚本 -->
<script async src="analytics.js"></script>
<script async src="error-logger.js"></script>

3.2 async vs defer 的选择

// 判断标准:脚本是否依赖 DOM 或其他脚本

// ✅ 用 defer:脚本依赖 DOM 或相互依赖
// - jQuery 插件、DOM 操作库
// - 遵循一定加载顺序的业务代码
<script defer src="jquery.js"></script>
<script defer src="plugin.js"></script>
<script defer src="app.js"></script>

// ✅ 用 async:脚本完全独立,不依赖 DOM
// - 分析/统计脚本
// - 错误日志
// - 第三方 widget(不依赖页面 DOM)
<script async src="ga.js"></script>
<script async src="mixpanel.js"></script>

3.3 async 的陷阱

<!-- ❌ 错误:依赖顺序的脚本使用 async -->
<script async src="jquery.js"></script>
<script async src="jquery.plugin.js"></script>
<!-- jquery.plugin.js 可能先于 jquery.js 执行,导致错误 -->

<!-- ✅ 正确:要么都用 defer,要么把依赖脚本合并 -->

四、type="module"

4.1 模块脚本的特性

type="module" 将脚本作为 JavaScript 模块处理,具有以下特性:

  1. 默认 defer:模块脚本自动 defer,不需要也不应该加 defer 属性
  2. 按序执行:模块及其所有依赖按引用顺序执行
  3. 静默 CORS:跨域模块脚本必须通过 CORS 验证
  4. 静默 strict:模块脚本默认在严格模式下执行
<!-- 模块脚本 -->
<script type="module" src="main.js"></script>

<!-- 内联模块 -->
<script type="module">
  import { something } from './module.js';
  something();
</script>

4.2 模块脚本与 nomodule 回退

<!-- 现代浏览器加载模块 -->
<script type="module" src="main.js"></script>

<!-- 旧浏览器忽略 type="module",加载 fallback -->
<script nomodule src="bundle.js"></script>

这个模式在早期用于渐进增强,但随着旧浏览器退出市场,单独使用 type="module" 通常已足够。

4.3 模块脚本与 defer 的区别

// defer 脚本
// - 可以访问 DOM
// - 按文档顺序执行
// - 执行在 DOMContentLoaded 之前

// module 脚本
// - 可以访问 DOM
// - 按 import 依赖图顺序执行
// - 执行在 DOMContentLoaded 之前
// - 始终以 defer 方式加载

// 实际上,module 脚本基本等同于 defer + 按依赖顺序执行

五、fetchpriority 属性

5.1 作用与取值

fetchpriority(部分浏览器支持)允许开发者指定脚本的相对获取优先级:

含义
high 高优先级获取
low 低优先级获取
auto 浏览器自行决定(默认)
<!-- 关键脚本高优先级 -->
<script src="critical.js" fetchpriority="high"></script>

<!-- 非关键脚本低优先级 -->
<script src="analytics.js" fetchpriority="low"></script>

5.2 使用场景

<!-- 首屏关键:LCP 相关的脚本 -->
<script src="/js/interaction.js" fetchpriority="high"></script>

<!-- 延迟加载:非首屏交互 -->
<script src="/js/charts.js" fetchpriority="low"></script>

<!-- 中等优先级:常规依赖 -->
<script src="/js/vendor.js"></script>

六、blocking 属性

6.1 render 阻止行为

blocking 属性(较新特性)允许显式控制脚本是否阻止渲染:

<!-- 阻止渲染的脚本(默认行为) -->
<script blocking="render" src="critical-path.js"></script>

<!-- 非阻塞脚本 -->
<script src="deferred.js"></script>

这个属性主要在 <head> 中的脚本使用,可以精确控制首屏渲染时机。

6.2 动态脚本的 blocking

// 动态添加的脚本默认不会阻止渲染
// 除非显式设置 blocking 属性

const script = document.createElement('script');
script.src = 'analytics.js';
// script.blocking = 'render'; // 取消注释则会阻止渲染
document.body.appendChild(script);

七、预加载与性能优化

7.1 preload 与脚本

<head>
  <!-- 预加载关键脚本 -->
  <link rel="preload" href="critical.js" as="script" />
</head>
<body>
  <!-- 稍后实际使用 -->
  <script src="critical.js"></script>
</body>

preload 允许在解析 HTML 早期就声明性地预获取脚本,使其在实际 <script> 标签执行前就开始下载。

7.2 modulepreload

<!-- 预加载模块脚本及其依赖图 -->
<link rel="modulepreload" href="main.js" />
<link rel="modulepreload" href="dependency.js" />

对于复杂模块应用,modulepreload 可以显著减少加载时间。


八、最佳实践

8.1 通用建议

1. 关键渲染路径脚本使用 <script defer> 而非 <script>(无属性)
2. 独立脚本(分析、监控)使用 <script async>
3. 现代 ES 模块使用 <script type="module">
4. 首屏关键脚本配合 <link rel="preload">
5. 避免内联脚本阻塞解析——尽量使用外部脚本

8.2 典型场景

<!-- 场景一:DOM 依赖库 + 业务代码 -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>

<!-- 场景二:第三方独立脚本 -->
<script async src="analytics.js"></script>

<!-- 场景三:现代模块应用 -->
<script type="module" src="main.js"></script>

<!-- 场景四:关键路径优化 -->
<link rel="preload" href="critical.js" as="script" />
<script defer src="critical.js"></script>

九、面试高频问题

Q: defer 和 async 的区别是什么?分别在什么场景使用?

回答要点:defer 脚本在 DOM 解析完成后按文档顺序执行,适合依赖 DOM 或相互依赖的脚本;async 脚本下载完成后立即无序执行,适合完全独立的脚本如分析代码。defer 保证执行顺序,async 不保证。

Q: 为什么 type="module" 脚本不需要 defer?

回答要点:模块脚本默认具有 defer 的行为特征——并行获取、在 DOM 解析后执行、按依赖顺序执行。ES 模块的 import/export 机制要求按依赖图顺序加载,这使得"立即执行"的 async 语义对模块不适用。

Q: 动态创建的 <script> 标签默认是阻塞还是非阻塞?

回答要点:通过 document.createElement('script') 创建的脚本默认是异步的,不会阻塞 HTML 解析。但如果设置 script.async = false,它会变成 defer 行为(按创建顺序执行)。


参考资料

延展阅读