async、defer、type=”module” 到底有什么区别?

JavaScript1周前更新 admin
166 0

写过前端的人大概都遇到过这样的场景:页面加载很慢,或者脚本报错说某个变量没定义,排查半天发现是 <script> 标签的加载方式出了问题。asyncdefertype="module" 这三个东西经常被放在一起讨论,但它们解决的其实是不同维度的问题,混着用很容易踩坑。这篇文章把它们的原理、区别和适用场景捋一遍。

先搞清楚问题的根源:脚本为什么会阻塞

在讨论这几个属性之前,得先理解浏览器解析 HTML 的默认行为。

浏览器从上到下解析 HTML,构建 DOM 树。当解析到一个普通的 <script src="..."> 标签时,会发生三件事:

  1. 暂停 HTML 解析
  2. 下载脚本文件
  3. 执行脚本
  4. 执行完毕后,才继续解析后面的 HTML

这就是所谓的”阻塞”。如果脚本文件很大,或者服务器响应慢,页面会出现明显的白屏或内容中断,因为浏览器在傻等这个脚本下载和执行完。

asyncdefer 就是为了解决这个”阻塞”问题而生的,而 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> 标签里),asyncdefer 都不会生效,浏览器会忽略这两个属性,按老规矩同步执行。这两个属性只对外部脚本文件有意义。

type=”module”:另一个维度的变化

type="module" 解决的不是”阻塞”问题,而是引入了 ES Module(ESM)机制,这是 JavaScript 模块化标准在浏览器端的原生实现。用法:

<script type="module" src="main.js"></script>

它带来的变化主要有:

1. 支持 import/export 语法

type="module" 的脚本里,可以直接使用 importexport 关键字来组织代码依赖关系,不再需要打包工具或者手动管理加载顺序:

// 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 等),打包后的产物如何加载,通常工具已经处理好了,但理解这套机制依然有助于排查加载顺序相关的问题

小结

asyncdefertype="module" 三者看似经常一起出现,但其实是从不同角度在解决问题:前两者关注脚本的加载时机和执行顺序,后者关注代码的模块化组织方式。理解了浏览器解析 HTML 时的默认阻塞行为,再看这三个属性的设计动机,就会发现它们其实都是在给开发者提供”绕开阻塞、控制执行时机”的工具,只是切入点不同。搞清楚这一层,写 <script> 标签的时候就不会再靠”抄一下别人怎么写的”了。

© 版权声明

相关文章

暂无评论

none
暂无评论...