脚本加载机制
一、脚本加载的重要性
1.1 JavaScript 与页面渲染的关系
当浏览器遇到 <script> 标签时,默认行为是暂停 HTML 解析、获取脚本、执行脚本,然后继续解析 HTML。这个过程被称为"解析器阻塞"(parser blocking)。在慢网络环境下,一个体积较大的脚本可能导致页面长时间空白,严重影响用户体验和 Core Web Vitals 指标。
理解脚本加载机制是性能优化的基础。同一个脚本,使用不同的加载属性可能产生截然不同的性能表现。
1.2 脚本加载属性全景图
| 属性 | 并行获取 | 执行时机 | 执行顺序 | DOM 就绪 | 渲染阻塞 |
|---|---|---|---|---|---|
| 无 | 否 | 立即 | 出现顺序 | 否 | 是 |
async |
是 | 下载完成后立即 | 无序 | 否 | 是(执行时) |
defer |
是 | DOM 解析完成后 | 出现顺序 | 是 | 否 |
type="module" |
是 | DOM 解析完成后 | 出现顺序 | 是 | 否 |
二、defer 属性
2.1 行为解析
defer 是最常用的脚本加载优化属性。它告诉浏览器:
- 并行获取:脚本的获取与 HTML 解析并行进行,不阻塞解析器
- 延迟执行:脚本在 DOM 树构建完成后、
DOMContentLoaded事件之前执行 - 保持顺序:多个 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 行为解析
async 与 defer 的关键区别在于执行时机和执行顺序:
- 并行获取:与 defer 相同,脚本获取不阻塞解析
- 立即执行:一旦脚本下载完成,立即暂停解析并执行
- 无序执行:多个 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 模块处理,具有以下特性:
- 默认 defer:模块脚本自动 defer,不需要也不应该加 defer 属性
- 按序执行:模块及其所有依赖按引用顺序执行
- 静默 CORS:跨域模块脚本必须通过 CORS 验证
- 静默 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 行为(按创建顺序执行)。