HTML中async和defer属性的区别与使用场景

在脚本标签上随手加上 async 或 defer,很多时候被当作一种性能优化的“仪式”。但真相是,用错了它们,不仅不会提速,反而会埋下隐患。轻则控制台报出 document.getElementById is not a function,重则导致核心业务逻辑失效、关键埋点数据漏报,甚至让页面白屏时间翻倍。所以,核心原则不是“加了就快”,而是“加对才稳”。
什么时候脚本执行会报 document.querySelector 返回 null?
这个问题堪称前端开发中的“经典报错信号”。其根源非常明确:脚本试图访问某个DOM元素,但浏览器解析HTML的进度条,还没走到那个元素的位置。这通常发生在错误地使用了 async,或者干脆什么属性都没加的情况下。
async脚本:它的行为是“下载完成,立即执行”。一旦脚本下载完毕,它会立刻中断浏览器的HTML解析过程,抢跑执行。此时,可能只解析了一半,你代码里写的#app根节点,在DOM树里根本还不存在。defer脚本:它的策略是“耐心等待”。它会等到整个HTML文档被完全解析、DOM树构建完成之后,才按顺序执行。因此,在执行defer脚本时,document.body以及所有在HTML中定义的元素都已是“可访问状态”。- 无属性脚本:这其实是最危险的情况。默认的
标签不仅会阻塞HTML解析,其执行时机还完全取决于它在文档中的书写位置。如果把它放在里,那么脚本执行时,DOM的构建工作甚至可能还没开始。
defer 为什么能保证多个脚本按顺序执行?
关键在于,浏览器为 defer 脚本划定了一个非常明确且唯一的执行窗口:在HTML解析完成之后,在 DOMContentLoaded 事件触发之前。所有标记了 defer 的脚本,都会进入这个共享队列,并按它们在HTML中间出现的顺序依次执行。
- 顺序是铁律:执行顺序只取决于HTML中的书写顺序,与脚本文件的大小、网络下载的快慢完全无关。先写的
a.js,就一定在b.js之前执行。 - 经典应用场景:这完美适用于“库+业务代码”的组合。例如,先加载
lodash.js工具库,再加载依赖它的utils.js业务工具函数,顺序能得到绝对保证。 - 重要注意事项:
defer属性对内联脚本是无效的。像这样的代码,并不会延迟执行,而是会立刻执行。另外,现代构建工具(如Vite)生成的type="module"脚本,默认就具有defer的行为。此时再手动添加defer属性,虽然不会报错,但也没有任何额外效果。
async 真的“谁下完谁先执行”吗?
是的,这就是 async 最核心的行为:下载完毕,立刻执行,先到先得。这个“立刻”有多快呢?它可能发生在DOM构建的中途,甚至极端情况下,在 标签刚被解析时,如果脚本已经下载好了,它就会立刻执行。
立即学习“前端免费学习笔记(深入)”;
- 适用场景非常特定:通常只有三类脚本适合用
async:网站分析脚本(如analytics.js)、错误追踪脚本(如error-tracking.js)以及广告加载脚本(如ads.js)。它们的共同点是:不操作DOM、不依赖其他JS、也不被其他JS依赖。 - 依赖是禁忌:多个
async脚本之间绝对不能存在调用关系。否则,你可能会遇到utils.init()这个函数调用,在utils.js文件本身加载完成之前就执行了的诡异错误。 - 属性互斥:当
async和defer同时出现在一个标签上时,浏览器会忽略defer,只遵循async的行为模式。 - SSR页面慎用:在服务端渲染的页面中,如果将首屏渲染所依赖的初始化逻辑放在
async脚本里,就等于主动放弃了服务端预渲染带来的性能收益,可能导致客户端出现不必要的重新渲染。
最后,有一个非常关键且容易被忽略的判断维度:必须同时考虑脚本是否“依赖DOM”以及是否“被其他脚本依赖”。如果一个脚本既要操作 document.body,又要导出函数供后面的脚本调用,那么它唯一的选择就是 defer。反过来,哪怕一个脚本只是发送一个统计请求或记录日志,只要它不触碰DOM、不对外暴露API,那么使用 async 就是一个更清晰、更高效的选择。理解这一点,才算真正掌握了这两个属性的使用精髓。
