defer 和 async 的区别
Category(分类): JavaScript Status: 已更新
原文围绕《JavaScript 高级程序设计》中 defer 和 async 的区别展开。本文保留原来的加载顺序、HTML 解析和“脚本是否有依赖”这条学习路线,并按当前 HTML Standard 和浏览器实现补充经典脚本、模块脚本、DOMContentLoaded、动态插入脚本等边界。
一句话先说清楚
当浏览器解析到下面的经典外部脚本时:
<script src="script.js"></script>
如果没有 async、defer 或 type="module",HTML 解析器通常会暂停,等待脚本下载并执行,然后再继续解析后面的文档。脚本的执行本身也会占用 JavaScript 执行线程,因此不要把“下载不阻塞”与“执行不阻塞”混为一谈。
1. 普通经典脚本
<script src="script.js"></script>
- 解析器读到标签后暂停解析;
- 外部脚本下载完成后立即执行;
- 后面的 HTML 要等脚本执行完成才能继续解析;
- 多个普通脚本按照它们在文档中的顺序执行。
2. async 脚本
<script async src="analytics.js"></script>
- 下载可以与 HTML 解析并行进行;
- 下载完成后立即执行,执行时可能暂停 HTML 解析;
- 多个
async脚本不保证执行顺序; - 不应依赖其他脚本或 DOM 已经解析完成;
DOMContentLoaded不会等待async脚本。
3. defer 脚本
<script defer src="app.js"></script>
- 下载可以与 HTML 解析并行进行;
- HTML 文档解析完成后才执行;
- 多个
defer脚本按照文档顺序执行; - 执行发生在
DOMContentLoaded之前; DOMContentLoaded会等待这些脚本下载并执行完成。
原文示意图
配图依据 HTML Standard 的 async/defer 示意图 本地保存;MDN 页面说明其按 CC BY 4.0 条款提供。下面保留原文历史配图,当前规范结论不以它为唯一依据。

蓝色线可以理解为脚本的网络获取,红色线表示脚本执行,绿色线表示 HTML 解析。图是历史配图,细节以当前 HTML Standard 为准;尤其是现代页面还要考虑模块脚本、预加载、网络优先级和渲染机会。
async 和 defer 的共同点
- 对于带
src的经典脚本,它们都允许脚本下载与 HTML 解析并行进行; - 它们都能避免“先下载脚本、再继续解析文档”的下载阶段阻塞;
- 它们都不能让 JavaScript 执行本身变成并行执行。脚本开始执行时,当前 JavaScript 执行仍然会占用该执行环境;
- 两者都只改变脚本的加载/执行时机,不会自动解决脚本之间的业务依赖。
它们最重要的区别
| 对比项 | 普通经典脚本 | async | defer |
|---|---|---|---|
| 下载是否可与 HTML 解析并行 | 否(通常阻塞解析) | 是 | 是 |
| 何时执行 | 下载完成后立即执行 | 下载完成后尽快执行 | 文档解析完成后执行 |
| 多个脚本的执行顺序 | 按文档顺序 | 不保证,谁先下载完谁先执行 | 按文档顺序 |
| 是否适合有依赖的脚本 | 可按顺序依赖 | 不适合 | 适合按顺序依赖 |
| 是否等待 DOM 解析完成 | 否 | 不等待 | 是 |
DOMContentLoaded 是否等待 | 普通脚本会自然地阻塞解析 | 不等待 | 等待执行完成 |
因此,原文总结中的前两点仍然成立:async 和 defer 的下载都可以异步进行,区别主要在下载完成后的执行时机;如果应用脚本需要 DOM 或其他脚本,defer 通常比 async 更合适。
为什么 defer 按顺序执行,而 async 不按顺序执行?
假设页面中有两个脚本:
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>
即使 app.js 先下载完成,浏览器也会等到合适的时机,并按照 vendor.js、app.js 的文档顺序执行。这适合 app.js 依赖 vendor.js 的情况。
而下面的写法不能依赖顺序:
<script async src="vendor.js"></script>
<script async src="app.js"></script>
如果 app.js 先下载完成,它可能先执行,此时 vendor.js 暴露的全局变量还不存在。只有当两个脚本彼此独立时,async 才是合适的选择,例如独立的统计、广告或监控脚本(仍应结合隐私、性能和安全要求配置)。
DOMContentLoaded 的关系
<script defer src="a.js"></script>
<script defer src="b.js"></script>
<script async src="metrics.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
console.log('defer 脚本已经按顺序执行')
})
</script>
这里可以确定:
a.js先于b.js执行;DOMContentLoaded发生在a.js、b.js执行完成之后;metrics.js可能在DOMContentLoaded之前执行,也可能在之后执行,事件不等待它;DOMContentLoaded只表示文档已经解析完成,并不表示图片、样式表、字体和所有异步请求都已完成;window.load通常比DOMContentLoaded更晚,并且会等待更多资源,但也不能把它当作“所有业务异步任务都完成”的信号。
defer 脚本如果下载失败或执行抛错,也不能简单认为页面会等待到业务成功;事件顺序和错误处理应通过 error 事件、监控和应用逻辑明确处理。
模块脚本的特殊规则
现代项目经常使用 ES modules:
<script type="module" src="main.js"></script>
模块脚本与经典脚本不同:
- 模块脚本默认具有类似
defer的非阻塞解析行为; defer对模块脚本没有额外效果;- 模块及其静态依赖会先被获取,再按模块依赖关系链接和评估;
- 模块拥有自己的作用域,不会因为顶层
const或function自动成为window属性; - 跨源模块请求需要满足 CORS;
async可以用于模块脚本,此时模块及其依赖准备好后即可评估,不保证多个模块脚本标签之间的顺序;- 动态
import()会在运行时加载模块,不属于页面初始模块脚本的顺序保证; - 模块中的顶层
await会让模块评估异步化。依赖模块会等待相应评估,但DOMContentLoaded不应被当作所有顶层await业务初始化都完成的通用信号;需要明确的初始化完成状态时,应显式暴露并等待一个 Promise。
例如:
<script type="module" src="main.js"></script>
<script nomodule src="legacy.js"></script>
支持模块的浏览器会忽略 nomodule 脚本,从而可以为旧浏览器提供降级版本。实际项目是否还需要 nomodule,要结合目标浏览器和构建产物决定。
async 和 defer 不能用于内联经典脚本
下面的 defer 不会把内联代码推迟到文档解析完成后:
<script defer>
console.log('这段内联经典脚本不会因为 defer 自动延后')
</script>
async、defer 的这类加载语义主要针对带 src 的经典脚本。若要延后内联代码,应将代码放到 DOMContentLoaded 监听器中,或把它放进模块/外部脚本中:
<script>
document.addEventListener('DOMContentLoaded', () => {
initPage()
})
</script>
不过,如果脚本只是为了访问已经在脚本后面的 DOM,通常应优先使用 defer 的外部脚本,而不是把大量代码塞进事件监听器。
脚本放在 </body> 前还值得做吗?
原文提到,把脚本放到 </body> 前是旧浏览器时代常见的优化方式。这个历史经验仍然值得理解,但不能再当成唯一最佳实践:
<body>
<main id="app"></main>
<script src="app.js"></script>
</body>
脚本位于文档末尾时,前面的 HTML 已经解析,脚本对首屏内容的阻塞影响可能较小;但现代页面通常更适合:
<head>
<script defer src="app.js"></script>
</head>
这样可以尽早发现并下载脚本,同时不阻塞 HTML 解析,并保持有序执行。构建工具还可能使用 <link rel="modulepreload">、资源优先级提示和代码分割来优化关键路径,不能只靠移动标签位置判断性能。
动态插入的脚本
通过 JavaScript 创建的经典脚本默认具有异步加载倾向:
const script = document.createElement('script')
script.async = false // 多个动态经典脚本可按插入顺序执行
script.src = '/feature.js'
script.onload = () => console.log('feature loaded')
script.onerror = () => console.error('feature failed')
document.head.append(script)
动态脚本不能直接套用页面中两个 defer 标签的顺序保证。经典动态脚本默认倾向于异步执行;设置 script.async = false 可以请求按插入顺序执行,但如果业务依赖明确,仍建议使用 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.append(script)
})
}
async function loadFeatures() {
await loadScript('/vendor.js')
await loadScript('/app.js')
}
loadFeatures()
选择建议
适合 async 的场景
- 脚本与页面主逻辑无依赖;
- 脚本与其他脚本无顺序依赖;
- 脚本下载完成后尽快执行即可;
- 例如独立统计、某些第三方 SDK 或延迟可选功能。
适合 defer 的场景
- 脚本需要访问页面 DOM;
- 多个脚本有先后依赖;
- 希望脚本不阻塞 HTML 解析;
- 希望在
DOMContentLoaded前完成初始化。
需要额外考虑的事项
- 对关键脚本设置正确的缓存策略和压缩格式;
- 第三方脚本使用
async并不代表没有隐私或供应链风险; - 可以使用
integrity(子资源完整性)和 CSP 限制脚本来源; - 不要因为脚本“异步下载”就把巨大的同步执行逻辑放在首屏关键路径上;
- 对非关键功能优先考虑动态
import()和代码分割。