浏览器篇 | async 与 defer
前言
如果你做过任何前端性能优化,一定见过这两个属性:async 和 defer。它们被加在 <script> 标签上,用来控制外部脚本的加载和执行时机。
很多人知道”加了 async 或 defer 就不会阻塞页面”,但如果面试官继续追问:
- async 和 defer 到底在什么时候执行脚本?它们的执行顺序有什么区别?
<script type="module">默认是 async 还是 defer?- 动态创建的
<script>标签,加载行为和静态写在 HTML 里的有什么不同? preload和prefetch又是干什么的?它们和 async/defer 有什么关系?
能把这些讲清楚的人,真不多。
本章我们就从 <script> 标签的默认加载行为出发,一步步搞清楚 async、defer、type=“module”、动态脚本、以及预加载策略,让你在面试中能从底层原理到实际选型,讲出一套完整的逻辑。
诊断自测
在正文开始前,先用几道题检验一下你对脚本加载的了解程度。答不上来没关系,读完全文再回来对答案。
Q1:下面三个脚本标签的加载和执行顺序是怎样的?
<script defer src="a.js"></script>
<script async src="b.js"></script>
<script src="c.js"></script>
点击查看答案
首先,c.js 是普通脚本,会阻塞 HTML 解析——浏览器遇到它会暂停解析,下载并执行完后才继续。a.js(defer)和 b.js(async)都是异步下载,不阻塞 HTML 解析。
执行顺序:c.js 最先执行(因为解析到就阻塞执行了)。b.js 在下载完成后立即执行,时机不确定。a.js 在 DOM 解析完毕后、DOMContentLoaded 事件之前执行。如果 b.js 比 a.js 下载快,b.js 可能先于 a.js 执行;反之则相反。
Q2:<script type="module" src="app.js"></script> 的默认加载行为和加了 defer 一样吗?
点击查看答案
是的。type="module" 的脚本默认就是 defer 行为:异步下载,不阻塞 HTML 解析,在 DOM 解析完毕后按顺序执行。如果你给它加上 async 属性,则变成”下载完立即执行,不保证顺序”。
Q3:动态创建一个 script 标签并插入 DOM,它的加载行为默认是 async 还是 defer?
点击查看答案
动态创建的 <script> 标签,async 属性默认为 true——也就是说,默认行为类似 async:下载完就执行,不保证顺序。如果你想让多个动态脚本按插入顺序执行,需要手动设置 script.async = false。
一、script 标签的默认加载行为
先搞清楚最基本的情况:不加任何属性的 <script> 标签。
<script src="app.js"></script>
当浏览器的 HTML 解析器遇到这行代码时,会经历以下过程:
- 暂停 HTML 解析
- 下载
app.js(如果是外部脚本) - 执行
app.js - 执行完毕后,恢复 HTML 解析
注意:这里”暂停解析”意味着后面的 DOM 节点还没有被创建。如果脚本里试图访问它后面的 DOM 元素,会拿到 null。
<script>
// ❌ 此时 #app 还没被解析出来
console.log(document.getElementById('app')); // null
</script>
<div id="app"></div>
这就是为什么传统做法是把 script 标签放在 </body> 前面——等 DOM 都解析完了再执行脚本,就不会有”找不到元素”的问题。
<body>
<div id="app"></div>
<!-- 所有 DOM 都解析完了,脚本可以安全地操作它们 -->
<script src="app.js"></script>
</body>
但这种方式有一个明显的缺点:脚本的下载被延迟了。浏览器要一直解析到 </body> 附近才开始下载脚本,白白浪费了前面解析 HTML 时的网络空闲时间。
这就是 async 和 defer 要解决的问题。
二、defer:异步加载,延迟执行,保证顺序
<script defer src="app.js"></script>
加了 defer 之后,行为变成:
- 浏览器在解析 HTML 时,并行下载脚本,不暂停解析
- 等整个 HTML 文档解析完毕(DOM 树构建完成)后,按照在文档中出现的顺序依次执行
- 执行完所有 defer 脚本后,触发
DOMContentLoaded事件
关键特性:
- 不阻塞 HTML 解析:下载过程和 HTML 解析并行
- 执行时机确定:DOM 解析完毕后、DOMContentLoaded 之前
- 保证执行顺序:多个 defer 脚本,按它们在 HTML 中出现的顺序执行
<script defer src="jquery.js"></script>
<script defer src="plugin.js"></script>
<script defer src="app.js"></script>
上面三个脚本,无论哪个先下载完,执行顺序一定是 jquery.js → plugin.js → app.js。这对有依赖关系的脚本至关重要。
defer 的适用场景
- 脚本需要操作 DOM,但你又想把
<script>写在<head>里(提前开始下载) - 多个脚本之间有依赖关系,必须按顺序执行
- 大多数情况下,
defer是最安全、最推荐的选择
一个小细节
defer 只对外部脚本有效。对内联脚本(没有 src 属性的)加 defer 没有任何效果,浏览器会忽略它。
<!-- defer 无效,仍然同步执行 -->
<script defer>
console.log('我还是会阻塞解析');
</script>
三、async:异步加载,加载完立即执行,不保证顺序
<script async src="analytics.js"></script>
加了 async 之后,行为变成:
- 浏览器在解析 HTML 时,并行下载脚本,不暂停解析
- 下载完成后立即执行,执行期间暂停 HTML 解析
- 多个 async 脚本之间不保证执行顺序——谁先下载完谁先执行
关键特性:
- 不阻塞下载:和 defer 一样,下载过程不阻塞解析
- 执行时机不确定:取决于下载速度,可能在 DOM 解析完之前,也可能在之后
- 不保证顺序:多个 async 脚本的执行顺序是”先到先得”
<script async src="a.js"></script> <!-- 100KB -->
<script async src="b.js"></script> <!-- 10KB -->
如果 b.js 体积更小、下载更快,那么 b.js 会先执行。即使 a.js 在 HTML 中写在前面。
async 的适用场景
- 完全独立的脚本,不依赖其他脚本,也不操作 DOM
- 典型例子:统计分析脚本(Google Analytics)、广告脚本、第三方追踪器
- 这类脚本越早执行越好,但具体执行时机不重要
async 和 defer 同时写会怎样?
<script async defer src="app.js"></script>
如果浏览器同时支持 async 和 defer,async 优先。defer 在这里只是作为不支持 async 的老浏览器的降级方案(现代浏览器都支持 async,所以这种写法已经没什么意义了)。
四、一张图搞清楚区别
用时间线来看,三种加载方式的区别一目了然:
普通 <script>:
HTML 解析 ──────┤ 下载脚本 │ 执行脚本 ├── HTML 解析继续 ──
↑ 暂停解析
<script defer>:
HTML 解析 ──────────────────────────── 解析完毕 ┤ 执行脚本 ├→ DOMContentLoaded
│ 下载脚本(并行)│
<script async>:
HTML 解析 ──────────┤ 执行脚本 ├── HTML 解析继续 ──
│ 下载 │↑
(并行) 下载完就执行
对比表
| 特性 | 普通 script | defer | async |
|---|---|---|---|
| 下载时阻塞解析 | 是 | 否 | 否 |
| 执行时阻塞解析 | 是 | 不会(DOM 已解析完) | 是 |
| 执行时机 | 立即 | DOM 解析完毕后 | 下载完成后立即 |
| 执行顺序 | 按文档顺序 | 按文档顺序 | 不保证 |
| 适用场景 | 必须立即执行的脚本 | 大多数脚本 | 独立的第三方脚本 |
五、type=“module” 的加载行为
ES Modules 在浏览器中已经得到了广泛支持。<script type="module"> 的加载行为有几个值得注意的特点:
默认 defer
<script type="module" src="app.mjs"></script>
type="module" 的脚本默认就是 defer 行为:异步下载,DOM 解析完毕后按文档顺序执行。你不需要额外加 defer 属性。
可以加 async
<script async type="module" src="app.mjs"></script>
加了 async 后,模块脚本变成”下载完立即执行”,行为和普通 async 脚本类似。
内联模块也是 defer
和普通脚本不同的是,内联的 module 脚本也支持 defer 行为(虽然内联脚本没有下载阶段,但它的执行会等到 DOM 解析完毕后):
<script type="module">
// 这段代码会在 DOM 解析完毕后执行
console.log(document.getElementById('app')); // ✅ 能拿到
</script>
<div id="app"></div>
自动严格模式
模块脚本自动运行在严格模式下,不需要 'use strict'。
同一模块只执行一次
如果多个 <script type="module"> 引用了同一个模块 URL,浏览器只会下载和执行一次。
<!-- utils.mjs 只会被下载和执行一次 -->
<script type="module" src="utils.mjs"></script>
<script type="module" src="utils.mjs"></script>
对比表
| 特性 | 普通 script | type=“module” |
|---|---|---|
| 默认加载行为 | 同步阻塞 | defer |
| 支持 async | 是 | 是 |
| 内联脚本的 defer | 不支持 | 支持 |
| 严格模式 | 需要手动声明 | 自动 |
| 重复引用 | 每次都执行 | 只执行一次 |
| 跨域请求 | 不需要 CORS | 需要 CORS |
六、动态创建 script 标签
在单页应用和按需加载的场景中,我们经常需要用 JavaScript 动态创建 <script> 标签:
const script = document.createElement('script');
script.src = 'lazy-module.js';
document.head.appendChild(script);
动态创建的脚本有一个非常重要的特性:默认行为是 async 的。
也就是说,上面的代码等价于:
<script async src="lazy-module.js"></script>
脚本下载完成后会立即执行,不保证和其他动态脚本的执行顺序。
让动态脚本按顺序执行
如果你需要动态加载多个有依赖关系的脚本,需要手动关闭 async:
function loadScriptsInOrder(urls) {
urls.forEach(url => {
const script = document.createElement('script');
script.src = url;
script.async = false; // 关键:关闭 async,变成类似 defer 的行为
document.head.appendChild(script);
});
}
loadScriptsInOrder([
'jquery.js',
'plugin.js',
'app.js'
]);
// 保证执行顺序:jquery.js → plugin.js → app.js
设置 script.async = false 后,脚本会按照被添加到 DOM 的顺序执行,类似 defer 的行为。
监听加载完成
动态脚本可以通过 load 和 error 事件来监听加载状态:
const script = document.createElement('script');
script.src = 'analytics.js';
script.onload = () => {
console.log('脚本加载并执行完成');
};
script.onerror = () => {
console.error('脚本加载失败');
};
document.head.appendChild(script);
封装成 Promise
在实际项目中,通常会把动态加载封装成 Promise:
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 使用
async function init() {
await loadScript('vendor.js');
await loadScript('app.js');
console.log('所有脚本加载完成');
}
七、preload 与 prefetch 预加载策略
除了控制脚本的执行时机,我们还可以通过 preload 和 prefetch 来优化资源的下载时机。
preload:提前加载当前页面的关键资源
<link rel="preload" href="critical.js" as="script">
preload 告诉浏览器:“这个资源在当前页面马上就要用到,请尽早开始下载。”
关键点:
- preload 只下载,不执行。要执行脚本,还是需要
<script>标签 - 适用于首屏关键资源:字体、CSS、关键 JS
- 需要指定
as属性(script、style、font等),帮助浏览器确定优先级
<head>
<!-- 提前下载,但还没执行 -->
<link rel="preload" href="app.js" as="script">
<link rel="preload" href="styles.css" as="style">
</head>
<body>
<!-- 真正使用的时候,文件可能已经下载好了 -->
<link rel="stylesheet" href="styles.css">
<script defer src="app.js"></script>
</body>
prefetch:预加载将来可能用到的资源
<link rel="prefetch" href="next-page.js" as="script">
prefetch 告诉浏览器:“这个资源在将来的导航中可能会用到,如果你有空闲带宽,可以提前下载。”
关键点:
- 优先级很低,不会抢占当前页面资源的带宽
- 适用于下一页可能需要的资源
- 预取的资源会被存入浏览器缓存,下次请求时直接命中
preload vs prefetch
| 特性 | preload | prefetch |
|---|---|---|
| 用途 | 当前页面的关键资源 | 将来导航可能需要的资源 |
| 优先级 | 高 | 低 |
| 下载时机 | 尽早 | 浏览器空闲时 |
| 是否执行 | 否,只下载 | 否,只下载 |
modulepreload:专门为 ES Modules 设计
<link rel="modulepreload" href="utils.mjs">
modulepreload 和 preload 类似,但它专门针对 ES Module。它不仅会下载模块文件,还会解析模块的依赖图,预先下载所有依赖的子模块。
<link rel="modulepreload" href="app.mjs">
<link rel="modulepreload" href="utils.mjs">
<script type="module" src="app.mjs"></script>
实际项目中的搭配策略
一个典型的首屏优化策略:
<head>
<!-- 1. 预加载关键资源 -->
<link rel="preload" href="main.css" as="style">
<link rel="preload" href="app.js" as="script">
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
<!-- 2. 预取下一页可能用到的资源 -->
<link rel="prefetch" href="about-page.js" as="script">
<!-- 3. 加载 CSS(此时可能已经下载完了) -->
<link rel="stylesheet" href="main.css">
</head>
<body>
<div id="app"></div>
<!-- 4. defer 加载脚本(此时可能已经下载完了) -->
<script defer src="app.js"></script>
</body>
八、总结对比:选择哪种方式?
根据不同的场景,选择最合适的脚本加载策略:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 大多数应用脚本 | defer | 不阻塞解析,保证顺序,DOM 就绪后执行 |
| 独立的第三方脚本 | async | 无依赖,越早执行越好 |
| ES Module | type="module" | 默认 defer,支持 import/export |
| 按需加载 | 动态 <script> | 路由切换或用户交互后再加载 |
| 首屏关键资源 | preload + defer | 提前下载,延迟执行 |
| 下一页资源 | prefetch | 空闲时预取 |
常见误区
误区一:“async 就是异步加载,所以比 defer 好”
async 的”异步”指的是下载不阻塞,但执行仍然会阻塞 HTML 解析。而且 async 不保证执行顺序。如果你的脚本有依赖关系(比如先加载 jQuery 再加载插件),用 async 可能导致插件在 jQuery 之前执行,直接报错。defer 在大多数场景下是更安全的选择。
误区二:“把 script 放在 body 底部就不需要 defer 了”
放在 body 底部确实能避免阻塞 DOM 解析,但脚本的下载也被推迟了。使用 defer 并把脚本放在 <head> 中,可以让浏览器在解析 HTML 的同时就开始下载脚本,实现”下载提前、执行延后”的最优效果。
误区三:“preload 能让资源立即执行”
preload 只管下载,不管执行。你告诉浏览器”提前下载这个文件”,但要让 JS 执行、CSS 生效,还是需要对应的 <script> 或 <link rel="stylesheet"> 标签。如果你只写了 preload 但忘了写使用标签,浏览器会在控制台警告你”资源被 preload 但 3 秒内未被使用”。
误区四:“动态创建的 script 和普通 script 行为一样”
不一样。动态创建的 <script> 默认 async = true,而静态写在 HTML 中的 <script> 默认是同步阻塞的。这个差异经常被忽略,导致动态加载多个有依赖关系的脚本时顺序错乱。
小结
本章从 <script> 标签的默认加载行为出发,系统梳理了 async、defer、type=“module”、动态脚本和预加载策略之间的关系。
核心要点
- 普通 script:下载和执行都阻塞 HTML 解析,最朴素也最”危险”的加载方式
- defer:异步下载,DOM 解析完后按顺序执行,适合大多数脚本
- async:异步下载,下载完立即执行,不保证顺序,适合独立的第三方脚本
- type=“module”:默认 defer 行为,支持 ES Module 语法
- 动态 script:默认 async 行为,设置
async = false可保证顺序 - preload:提前下载当前页面的关键资源,只下载不执行
- prefetch:低优先级预取将来可能用到的资源
本章思维导图
- 默认行为
- 下载和执行都阻塞 HTML 解析
- 传统做法:放在 </body> 前
- defer
- 异步下载,不阻塞解析
- DOM 解析完毕后执行
- 保证文档顺序
- 只对外部脚本有效
- async
- 异步下载,不阻塞解析
- 下载完立即执行
- 不保证顺序
- 适合独立脚本
- type="module"
- 默认 defer 行为
- 可加 async 覆盖
- 内联模块也是 defer
- 自动严格模式
- 同一模块只执行一次
- 动态 script
- 默认 async = true
- async = false 可保证顺序
- onload / onerror 监听状态
- 可封装成 Promise
- 预加载策略
- preload:当前页面关键资源,高优先级
- prefetch:将来可能用到,低优先级
- modulepreload:ES Module 专用
- 选型建议
- 大多数脚本 → defer
- 第三方独立脚本 → async
- 按需加载 → 动态 script
- 首屏优化 → preload + defer
练习挑战
第一题(⭐ 基础):判断执行顺序
<!DOCTYPE html>
<html>
<head>
<script defer src="a.js"></script>
<script async src="b.js"></script>
</head>
<body>
<script>console.log('inline');</script>
<div id="app"></div>
<script defer src="c.js"></script>
</body>
</html>
假设 b.js 体积很大,下载耗时较长。请问 inline、a.js、b.js、c.js 的执行顺序是什么?
点击查看答案与解析
执行顺序:inline → a.js → c.js → b.js(假设 b.js 下载很慢)。
inline:内联脚本没有 defer/async,遇到就执行,最先a.js和c.js:都是 defer,DOM 解析完后按文档顺序执行,所以 a 在 c 前面b.js:async,下载完才执行。因为假设它体积大下载慢,所以最后执行
如果 b.js 下载很快,它可能在 inline 之后、a.js 之前就执行了——async 的时机是不确定的。
第二题(⭐⭐ 进阶):实现按序动态加载
请实现一个 loadScriptsSequentially 函数,接收一组脚本 URL,依次加载并执行。要求:
- 前一个脚本执行完后再加载下一个
- 返回一个 Promise,全部完成后 resolve
- 任一脚本加载失败时 reject
// 用法
loadScriptsSequentially([
'https://cdn.example.com/react.js',
'https://cdn.example.com/react-dom.js',
'https://cdn.example.com/app.js'
]).then(() => {
console.log('All scripts loaded');
});
点击查看答案与解析
function loadScriptsSequentially(urls) {
return urls.reduce((chain, url) => {
return chain.then(() => new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load: ${url}`));
document.head.appendChild(script);
}));
}, Promise.resolve());
}
通过 reduce 构建 Promise 链,每个脚本的加载都等待前一个完成后才开始。这里没有设置 async = false,因为我们用 Promise 链来保证顺序——每次只添加一个脚本,等它的 onload 触发后再添加下一个。
第三题(⭐⭐⭐ 综合):首屏加载优化方案
假设你有以下资源需要加载:
main.css:页面核心样式vendor.js:第三方依赖(React、lodash 等)app.js:业务逻辑,依赖 vendor.jsanalytics.js:统计脚本,完全独立font.woff2:自定义字体next-page.js:用户很可能会访问的下一页的脚本
请写出完整的 HTML <head> 和 <body> 中的资源加载标签,并说明每个标签为什么这样选择。
点击查看答案与解析
<head>
<!-- 1. preload 关键资源:提前下载,不阻塞渲染 -->
<link rel="preload" href="main.css" as="style">
<link rel="preload" href="vendor.js" as="script">
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
<!-- 2. prefetch 下一页资源:低优先级预取 -->
<link rel="prefetch" href="next-page.js" as="script">
<!-- 3. 加载 CSS:preload 已提前下载,这里立即可用 -->
<link rel="stylesheet" href="main.css">
<!-- 4. defer 加载业务脚本:保证 vendor → app 顺序 -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>
<!-- 5. async 加载独立统计脚本 -->
<script async src="analytics.js"></script>
</head>
说明:
main.css用 preload 提前下载,配合 stylesheet 标签使用vendor.js用 preload 提前下载 + defer 延迟执行font.woff2用 preload 避免 FOUT(字体闪烁)vendor.js和app.js都用 defer,保证顺序且不阻塞解析analytics.js用 async,完全独立,越早执行越好next-page.js用 prefetch,空闲时预取
自我检测
读完本章后,对照下面的清单检验一下自己的掌握程度:
- 能说清楚不加任何属性的
<script>标签的默认加载行为及其性能问题 - 能准确描述
defer的三个关键特性:异步下载、DOM 解析后执行、保证顺序 - 能准确描述
async的三个关键特性:异步下载、下载完立即执行、不保证顺序 - 能解释
type="module"的默认加载行为以及它和 defer 的关系 - 能说出动态创建
<script>标签默认是 async 行为,以及如何让它按序执行 - 能区分
preload和prefetch的用途和优先级 - 能根据具体场景选择合适的脚本加载策略
- 能解释为什么 defer 通常比”把 script 放在 body 底部”更好
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90