浅谈script标签的defer和async
Category(分类): JavaScript Status: 已整理
本文保留了原文(2018 年)的完整叙事:从「看到前辈同时写 async 和 defer」的疑问出发,复习红宝书定义、分析 WebKit 流程、做 Timeline 实验、讨论兼容性动机,最后给出结论。整理时按当前 WHATWG 规范更新了几处结论(defer 的顺序保证、「单独开进程」的说法、Chrome 引擎名称),并补充了模块脚本、动态插入脚本等现代内容。文末附勘误清单。
1. 什么鬼
今天在做一个小需的时候,忽然看到前辈一句吊炸天的代码
<script src="xxxx/xx/home/home.js" type="text/javascript" async defer></script>
卧槽,竟然同时有 async 和 defer 属性,心想着肯定是前辈老司机的什么黑科技,两个一块儿肯定会发生什么神奇化学反应,于是赶紧怀着一颗崇敬的心去翻书翻文档,先复习一下各自的定义。
2. 调查一番
先看看 async 和 defer 各自的定义吧,翻开红宝书望远镜,是这么介绍的
2.1 defer
这个属性的用途是表明脚本在执行时不会影响页面的构造。也就是说,脚本会被延迟到整个页面都解析完毕后再运行。因此,在
<script>元素中设置 defer 属性,相当于告诉浏览器立即下载,但延迟执行。HTML5 规范要求脚本按照它们出现的先后顺序执行,因此第一个延迟脚本会先于第二个延迟脚本执行,而这两个脚本会先于
DOMContentLoaded事件执行。在现实当中,延迟脚本并不一定会按照顺序执行,也不一定会在DOMContentLoaded事件触发前执行,因此最好只包含一个延迟脚本。
现代注解:红宝书第三版这一段「在现实当中不保证顺序」的警告,描述的是 IE4–IE9 时代的真实情况(IE 的 defer 实现有严重 bug,详见第 4 节)。当前 WHATWG 规范把话说死了:带
defer的经典脚本必须在文档解析完成后、按文档中出现顺序依次执行,且全部先于DOMContentLoaded事件。 现代浏览器(Chrome/Firefox/Safari/Edge/IE10+)都遵循这个保证,所以今天可以放心地写多个 defer 脚本并依赖它们的执行顺序。
2.2 async
这个属性与 defer 类似,都用于改变处理脚本的行为。同样与 defer 类似,async 只适用于外部脚本文件,并告诉浏览器立即下载文件。但与 defer 不同的是,标记为 async 的脚本并不保证按照它们的先后顺序执行。
第二个脚本文件可能会在第一个脚本文件之前执行。因此确保两者之间互不依赖非常重要。指定
async属性的目的是不让页面等待两个脚本下载和执行,从而异步加载页面其他内容。
概括来讲,就是这两个属性都会使 script 标签异步加载,然而执行的时机是不一样的。引用 segmentfault 上的一个回答中的一张图

蓝色线代表网络读取,红色线代表执行时间,这俩都是针对脚本的;绿色线代表 HTML 解析。
也就是说 async 是乱序的,而 defer 是顺序执行,这也就决定了 async 比较适用于百度分析或者谷歌分析这类不依赖其他脚本的库。从图中可以看到一个普通的 <script> 标签的加载和解析都是同步的,会阻塞 DOM 的渲染,这也就是我们经常会把 <script> 写在 <body> 底部的原因之一,为了防止加载资源而导致的长时间的白屏,另一个原因是 js 可能会进行 DOM 操作,所以要在 DOM 全部渲染完后再执行。
2.3 really?
然而,这张图(几乎是百度搜到的唯一答案)是不严谨的,这只是规范的情况,大多数浏览器在实现的时候会作出优化。
来看看 chrome 是怎么做的
《WebKit 技术内幕》:
- 当用户输入网页 URL 的时候,WebKit 调用其资源加载器加载该 URL 对应的网页。
- 加载器依赖网络模块建立连接,发送请求并接受答复。
- WebKit 接收到各种网页或者资源的数据,其中某些资源可能是同步或异步获取的。
- 网页被交给 HTML 解释器转变成一系列的词语。
- 解释器根据词语构建节点,形成 DOM 树。
- 如果节点是 JavaScript 代码的话,调用 JavaScript 引擎解释并执行。
- JavaScript 代码可能会修改 DOM 树的结构。
- 如果节点需要依赖其他资源,例如图片、CSS、视频等,调用资源加载器来加载他们,但是他们是异步的,不会阻碍当前 DOM 树的继续创建;如果是 JavaScript 资源 URL(没有标记异步方式),则需要停止当前 DOM 树的创建,直到 JavaScript 的资源加载并被 JavaScript 引擎执行后才继续 DOM 树的创建。
所以,通俗来讲,chrome 浏览器首先会请求 HTML 文档,然后对其中的各种资源调用相应的资源加载器进行异步网络请求,同时进行 DOM 渲染,直到遇到 <script> 标签的时候,主进程才会停止渲染等待此资源加载完毕然后调用 V8 引擎对 js 解析,继而继续进行 DOM 解析。我的理解如果加了 async 属性就相当于单独开了一个进程去独立加载和执行,而 defer 是和将 <script> 放到 <body> 底部一样的效果。
现代注解:这段结论有三处需要修正/补充:
- 「单独开一个进程」不准确。
async并不会为脚本开独立进程——下载确实由网络线程并行完成,但执行仍在主线程:脚本执行时 HTML 解析照样要停下来。所以 async 脚本只是「下载不阻塞、执行随到随跑」,不是并行执行。- 现代浏览器有 preload scanner(预加载扫描器)。主解析器被同步脚本阻塞时,浏览器会另开一个轻量扫描器继续往后扫描标记,提前发现 img/script/css 等资源并排队请求。所以「遇到 script 就完全停止一切网络活动」的描述要打个折扣:阻塞的是解析和执行,不是资源发现。
- Chrome 的引擎如今叫 Blink(2013 年从 WebKit 分叉),文中《WebKit 技术内幕》描述的流程在 Blink 时代大体仍然成立,但细节(如 preload scanner、speculative parsing)早已增强。另外
defer与「放 body 底部」也不完全等价:defer 脚本要等整个文档解析完才按序执行,body 底部的同步脚本只是「解析到哪执行到哪」。详细机制可阅读 web.dev:Deep dive into the murky waters of script loading。
3. 实验一发
3.1 demo
为了验证上面的结论我们来测试一下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<link href="http://libs.baidu.com/bootstrap/3.0.3/css/bootstrap.css" rel="stylesheet">
<link href="http://cdn.staticfile.org/foundation/6.0.1/css/foundation.css" rel="stylesheet">
<script src="http://lib.sinaapp.com/js/angular.js/angular-1.2.19/angular.js"></script>
<script src="http://libs.baidu.com/backbone/0.9.2/backbone.js"></script>
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.js"></script>
</head>
<body>
<!-- 原文为 Emmet 缩写:ul>li{这是第$个节点}*1000,展开后是 1000 个 li -->
<ul>
<li>这是第1个节点</li>
<li>这是第2个节点</li>
<!-- ……共 1000 个 -->
</ul>
</body>
</html>
一个简单的 demo,从各个 CDN 上引用了 2 个 CSS、3 个 JS,在 body 里面创建了 1000 个 li。通过调整外部引用资源的位置和加入相关的属性利用 chrome 的 Timeline(现已更名为 Performance 面板)进行验证。
现代注解:这里的 CDN 地址是 2018 年的历史快照,
lib.sinaapp.com等域名如今可能已失效;想复现实验可换成任意本地或可用的第三方脚本(URL 的响应速度差异恰好能放大 async 的乱序效果)。
3.2 放置在 <head> 内

异步加载资源,但会阻塞 <body> 的渲染会出现白屏,按照顺序立即执行脚本
3.3 放置在 <body> 底部

异步加载资源,等 <body> 中的内容渲染完毕后且加载完按顺序执行 JS
3.4 放置在 <head> 头部并使用 async

异步加载资源,且加载完 JS 资源立即执行,并不会按顺序,谁快谁先上
3.5 放置在 <head> 头部并使用 defer

异步加载资源,在 DOM 渲染后之后再按顺序执行 JS
3.6 放置在 <head> 头部并同时使用 async 和 defer

表现和 async 一致,开了个脑洞,把这两个属性交换一下位置,看会不会有覆盖效果,结果发现是一致的 = =、
综上,在 webkit 引擎下,建议的方式仍然是把 <script> 写在 <body> 底部,如果需要使用百度谷歌分析或者不蒜子等独立库时可以使用 async 属性,若你的 <script> 标签必须写在 <head> 头部内可以使用 defer 属性
现代注解:这个结论在「不控制构建工具」的手写页面场景依然成立。今天更常见的选择是把入口脚本写成
<script type="module" src="app.js">——模块脚本默认就是 defer 语义(并行下载、解析完成后按序执行、先于 DOMContentLoaded),无需再写 defer;现代框架的构建产物(Vite/Next 等)则统一交给打包器管理模块加载。
4. 兼容性
那么,揣摩一下前辈的心理,同时写上的原因是什么呢,兼容性?
上 caniuse,async 在 IE<=9 时不支持,其他浏览器 OK;defer 在 IE<=9 时支持但会有 bug,其他浏览器 OK;现象在这个 issue 里有描述,这也就是「望远镜」里建议只有一个 defer 的原因。所以两个属性都指定是为了在 async 不支持的时候启用 defer,但 defer 在某些情况下还是有 bug。
The defer attribute may be specified even if the async attribute is specified, to cause legacy Web browsers that only support defer (and not async) to fall back to the defer behavior instead of the synchronous blocking behavior that is the default.
——上面这段规范引文(出自 HTML 标准对 async/defer 属性的说明,现行规范已重写但保留同样的回退语义)正解释了这个写法的动机:同时支持两者的现代浏览器里 async 生效(覆盖 defer);只支持 defer 的古老浏览器则退回 defer 行为,而不是回到默认的同步阻塞。
现代注解:如今
async/defer属于 Baseline 特性,所有在维护的浏览器(Chrome、Edge、Firefox、Safari,以及 IE10+)都完整支持规范语义,IE9 及以下的市场份额可以忽略,这个「两个都写」的写法只剩历史考古价值。当前规范对两者组合的规定是:带src的经典脚本,async存在时按 async 处理(无论 defer);仅defer存在时按 defer 处理;内联经典脚本两者都无效(没有下载过程,无从延迟)。
5. 结论
其实这么讲来,最稳妥的办法还是把 <script> 写在 <body> 底部,没有兼容性问题,没有白屏问题,没有执行顺序问题,高枕无忧,不要搞什么 defer 和 async 的花啦~
(现代视角补充:手写页面时 body 底部依然是零心智负担的稳妥方案;需要放 head 时优先 type="module"(自带 defer 语义)或 defer;完全独立的分析类脚本用 async。)
目前只研究了 chrome 的 webkit 的渲染机制,Firefox 和 IE 的有待继续研究,图片和 CSS 以及其他外部资源的渲染有待研究。
(现代补充)2024 视角下还有这些细节要知道
- 模块脚本的默认行为:
<script type="module">默认就是 defer 语义(并行下载、按序执行、先于 DOMContentLoaded);给它加defer无效果,加async则改成「整个依赖图下载完 ASAP 执行、不保证顺序」。跨源模块还需要 CORS(crossorigin)。 - 动态插入的脚本默认是 async:用
document.createElement('script')插入的脚本默认async = true(谁先下完谁先执行),想保序必须显式script.async = false。这是 SPA 时代最常见的乱序坑。 document.write与 defer/async 互斥:defer/async 的前提是「承诺不用document.write注入解析器」,外部脚本一旦用了它就会出问题。- async 脚本可能在 DOMContentLoaded 之后执行:慢的 async 脚本可能在
DOMContentLoaded甚至接近load时才跑完,依赖它的DOMContentLoaded回调会拿不到结果(所以埋点库要监听更早的事件或容忍缺数据)。 - 更现代的加载控制属性:
fetchpriority="low"(如 Chrome)可以给不重要的脚本降优先级;blocking="render"/expect属性是较新的渲染阻塞控制手段;这些已经超出了 defer/async 的讨论范围,按需查阅 MDN:script。
原文勘误与现代化说明
- 修复了被复制损坏的代码块:清除了掘金页面复制按钮残留文字,恢复了 demo HTML 的缩进结构,并把
#link(...)模板语法还原为普通src路径。 - 红宝书「现实中 defer 不保证顺序/不保证先于 DOMContentLoaded」的警告标注为 IE 时代现象;当前 WHATWG 规范明确保证 defer 脚本按序执行且先于 DOMContentLoaded,现代浏览器均遵循。
- 「async 相当于单独开了一个进程去独立加载和执行」更正为:下载并行、执行仍在主线程且会暂停解析;并补充 preload scanner 的存在(阻塞的是解析不是资源发现)。
- 「webkit 引擎下」补充说明:Chrome 自 2013 年起使用 Blink;Timeline 面板已更名为 Performance。
defer与「放 body 底部」不完全等价的差异说明(defer 等整个文档解析完成)。- 章节编号修正:原文两个小节都编号为 3.3(body 底部 / async),已顺序调整为 3.1–3.6。
DOMContentLoad拼写更正为DOMContentLoaded。- 兼容性一节补充现行规范语义(async 覆盖 defer、内联经典脚本两者无效)与 Baseline 现状;规范引文链接由旧版 W3C HTML5 页面更新为 WHATWG 现行标准对应章节。
- 新增「2024 视角」小节:模块脚本默认 defer、动态脚本默认 async、document.write 互斥、async 可能在 DOMContentLoaded 后执行、fetchpriority/blocking 等新属性。
- 链接整理:juejin 跳转链替换为直链;已失效的 ifibercc.com 外链移除;demo 中 CDN 标注为历史快照。
图片来源与授权说明
正文图片已本地化到 images/ 目录,均来自原文(掘金 Balled《浅谈script标签的defer和async》)的图床资源。图片的转载授权无法仅凭下载确认,公开发布前应向原作者确认许可:
images/109-image-01.webp:script 同步/defer/async 三种加载方式对比图。来源:segmentfault 回答配图(原文引用)。images/109-image-02.webp:脚本置于 head 内的 Timeline 截图。来源:原文截图。images/109-image-03.webp:脚本置于 body 底部的 Timeline 截图。来源:原文截图。images/109-image-04.webp:head + async 的 Timeline 截图。来源:原文截图。images/109-image-05.webp:head + defer 的 Timeline 截图。来源:原文截图。images/109-image-06.webp:head + async + defer 的 Timeline 截图。来源:原文截图。
截图反映的是 2018 年 Chrome 的表现,具体时间线随浏览器版本演进会有差异,正文结论以规范为准。
原文归属
作者:Balled。历史来源:掘金《浅谈script标签的defer和async》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。
参考
- JavaScript 高级程序设计(第三版)
- WebKit 技术内幕
- WHATWG HTML Standard:4.12 Scripting(script 与 async/defer 规范)
- MDN:
<script>元素 - web.dev:Deep dive into the murky waters of script loading
- The Modern JavaScript Tutorial:Scripts: async, defer
- defer 和 async 的区别(segmentfault 问答,配图出处)
- h5bp/lazyweb-requests#42(IE defer bug 记录)
- caniuse:script-defer