async、defer、type=”module” 到底有什么区别?
写过前端的人大概都遇到过这样的场景:页面加载很慢,或者脚本报错说某个变量没定义,排查半天发现是 <script> 标签的加载方式出了问题。async、defer、type="module" 这三个东西经常被放在一起讨论,但它们解决的其实是不同维度的问题,混着用很容易踩坑。这篇文章把它们的原理、区别和适用场景捋一遍。
先搞清楚问题的根源:脚本为什么会阻塞
在讨论这几个属性之前,得先理解浏览器解析 HTML 的默认行为。
浏览器从上到下解析 HTML,构建 DOM 树。当解析到一个普通的 <script src="..."> 标签时,会发生三件事:
- 暂停 HTML 解析
- 下载脚本文件
- 执行脚本
- 执行完毕后,才继续解析后面的 HTML
这就是所谓的”阻塞”。如果脚本文件很大,或者服务器响应慢,页面会出现明显的白屏或内容中断,因为浏览器在傻等这个脚本下载和执行完。
async 和 defer 就是为了解决这个”阻塞”问题而生的,而 type="module" 则是为了引入现代模块化机制。三者关注点不同,但都会影响脚本的加载和执行时机,所以经常被放在一起比较。
默认情况(无 async/defer)
<script src="a.js"></script>
<script src="b.js"></script>
- 按照文档顺序,依次下载、依次执行
- 每个脚本都会阻塞后续 HTML 的解析
- 脚本执行时机与它在文档中的位置严格对应
这是最古老也最”安全”的方式,因为执行顺序完全可预测,但性能最差。
defer:延迟执行,保持顺序
<script src="a.js" defer></script>
<script src="b.js" defer></script>
defer 的行为可以概括为:
- 下载不阻塞 HTML 解析:浏览器发现
defer脚本后,会异步下载,同时继续解析后面的 HTML - 执行时机延后:脚本不会立即执行,而是等到整个文档解析完成、也就是
DOMContentLoaded事件触发之前才执行 - 保持文档顺序:多个
defer脚本会按照它们在 HTML 中出现的顺序依次执行,不管谁先下载完
这意味着用 defer 加载的脚本,可以放心地假设 DOM 已经完全解析好了,不用再写 DOMContentLoaded 监听器去等 DOM。这也是为什么很多人推荐把普通业务脚本都加上 defer——它几乎总是比默认方式更优,而且不会打乱执行顺序。
async:下载不阻塞,谁先好谁先执行
<script src="a.js" async></script>
<script src="b.js" async></script>
async 看起来和 defer类似,都是”下载不阻塞解析”,但执行逻辑完全不同:
- 下载不阻塞 HTML 解析,这点和 defer 一样
- 下载完成后立即执行,执行时会暂停 HTML 解析(如果此时解析还没完成的话)
- 不保证执行顺序:哪个脚本先下载完,哪个先执行,和它们在文档中的书写顺序无关
举个例子,如果 b.js 体积很小、先下载完,而 a.js 体积大、后下载完,那么执行顺序就是 b.js 先于 a.js,即便 HTML 里 a.js 写在前面。
正因为执行顺序不可控,async 比较适合互相之间没有依赖关系的脚本,比如统计分析代码、广告脚本这类”即插即用”、不需要操作 DOM、也不依赖其他脚本的独立模块。如果多个脚本之间有依赖(比如 b.js 依赖 a.js 里定义的变量),用 async 就是给自己埋雷。
一张表看清 defer 与 async 的区别
| 特性 | 默认(无属性) | defer | async |
|---|---|---|---|
| 下载是否阻塞解析 | 是 | 否 | 否 |
| 执行时机 | 下载完立即执行,阻塞解析 | DOM 解析完成后,DOMContentLoaded 之前 | 下载完立即执行 |
| 多脚本执行顺序 | 按文档顺序 | 按文档顺序 | 谁先下载完谁先执行 |
| 适用场景 | 需要严格顺序且脚本很小 | 有依赖关系的业务脚本 | 相互独立的脚本(统计、广告等) |
需要补充一点:如果脚本是内联的(没有 src 属性,直接把代码写在 <script> 标签里),async 和 defer 都不会生效,浏览器会忽略这两个属性,按老规矩同步执行。这两个属性只对外部脚本文件有意义。
type=”module”:另一个维度的变化
type="module" 解决的不是”阻塞”问题,而是引入了 ES Module(ESM)机制,这是 JavaScript 模块化标准在浏览器端的原生实现。用法:
<script type="module" src="main.js"></script>
它带来的变化主要有:
1. 支持 import/export 语法
在 type="module" 的脚本里,可以直接使用 import 和 export 关键字来组织代码依赖关系,不再需要打包工具或者手动管理加载顺序:
// main.js
import { formatDate } from './utils.js';
2. 默认具有 defer 的效果
这是很多人容易忽略的一点:模块脚本默认就是延迟执行的,行为类似 defer——下载不阻塞解析,执行时机延后到文档解析完成之后。所以给 type="module" 的脚本额外加 defer 属性,通常是多余的(不会报错,但没有额外效果)。
3. 天然处于严格模式(strict mode)
模块脚本内部自动启用 'use strict',不需要手动声明。
4. 拥有独立的作用域
每个模块文件都有自己的顶层作用域,模块内声明的变量不会污染全局 window 对象,这和传统脚本的”全局大杂烩”是本质区别。
5. 支持 async 属性,且此时对模块生效
如果给 type="module" 的脚本同时加上 async,那么该模块会脱离”默认 defer”的顺序保证,变成下载完立即执行,这一点和普通脚本的 async 语义是一致的:
<script type="module" async src="main.js"></script>
6. 遵循 CORS 规则
跨域加载模块脚本时会受到 CORS 限制,这一点和普通脚本不同,普通脚本默认没有这个限制。
7. 自动去重
同一个模块文件如果被多处 import,浏览器只会加载和执行一次,不用担心重复引入的问题。
三者放在一起怎么理解
如果用一句话总结三者的关系:
- defer / async 解决的是”脚本加载会不会阻塞页面解析、多个脚本谁先执行”的问题,针对的是传统脚本的加载策略
- type=”module” 解决的是”代码怎么组织依赖关系”的问题,是模块化方案,顺带默认继承了 defer 的加载行为
所以在实际选择时,可以按这个思路走:
- 如果项目使用 ES Module 组织代码,直接用
type="module",不用再纠结加不加 defer,浏览器已经按 defer 的方式处理了 - 如果是传统的非模块脚本,且脚本之间有依赖关系、需要保证顺序,用
defer - 如果是完全独立、不依赖 DOM 和其他脚本的第三方脚本(统计、监控等),用
async - 现代项目如果用了打包工具(Webpack、Vite 等),打包后的产物如何加载,通常工具已经处理好了,但理解这套机制依然有助于排查加载顺序相关的问题
小结
async、defer、type="module" 三者看似经常一起出现,但其实是从不同角度在解决问题:前两者关注脚本的加载时机和执行顺序,后者关注代码的模块化组织方式。理解了浏览器解析 HTML 时的默认阻塞行为,再看这三个属性的设计动机,就会发现它们其实都是在给开发者提供”绕开阻塞、控制执行时机”的工具,只是切入点不同。搞清楚这一层,写 <script> 标签的时候就不会再靠”抄一下别人怎么写的”了。