这道题几乎每次问「浏览器渲染流程」都会被带出来:给 <script> 加上 defer 或 async,DOMContentLoaded 到底在什么时候触发?很多人的回答停留在「defer 在 DOMContentLoaded 之前、async 不确定」,这个说法不算错,但被追问一句「多个 defer 脚本之间谁先执行」「async 脚本会不会插到 DOMContentLoaded 前面」「动态插入的 script 默认是什么行为」就答不上来了。下面把规范里的结论拆开讲清楚,并给出可以直接跑的验证代码。
先把三个事件和两种加载时机对齐
要答这道题,先把参与方列清楚,否则讨论顺序时容易把 load 和 DOMContentLoaded 混在一起。
| 事件 | 触发时机 | 依赖什么 |
|---|---|---|
DOMContentLoaded |
HTML 被完整解析、所有 defer 脚本执行完毕后 | 不等待图片、iframe、async 脚本 |
load |
页面所有资源(图片、样式表、iframe、所有脚本)加载完成 | 等待一切 |
readystatechange |
document.readyState 变化:loading → interactive → complete | 与上面两个事件有固定对应关系 |
关键的对应关系是:当 readyState 变成 interactive 时,浏览器开始执行 defer 脚本队列,队列清空后才派发 DOMContentLoaded。等到 load 触发前,readyState 才变成 complete。理解这条链,后面所有结论都是它的推论。
defer:解析不阻塞,执行按文档顺序,且在 DOMContentLoaded 之前
defer 的三个行为要一起记,缺一条就会被追问倒:
- 下载不阻塞解析。浏览器解析到
<script defer src>时立即发起请求,但继续往下解析 HTML。 - 执行在解析完成后、按文档出现顺序串行执行。多个 defer 脚本保证按标签在 HTML 中出现的先后执行,前一个执行完才执行下一个。
- 执行完才派发 DOMContentLoaded。所以在 defer 脚本里能拿到完整 DOM,也能安全地给 DOMContentLoaded 绑监听(此时还没触发)。
规范里对 defer 有一句容易被忽略的限制:它只对外部脚本有效,写成 <script defer>内联代码</script> 时 defer 被忽略,内联脚本照常同步执行。另外 defer 脚本执行时 document.readyState 已经是 interactive。
<!DOCTYPE html>
<html>
<head>
<script defer src="/a.js"></script>
<script defer src="/b.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
console.log('DOMContentLoaded fired');
});
console.log('inline script, readyState =', document.readyState); // loading
</script>
</head>
<body><p>hello</p></body>
</html>
假设 a.js 和 b.js 里各打一行日志,稳定输出顺序是:
inline script, readyState = loading
a.js executed
b.js executed
DOMContentLoaded fired
注意即使 a.js 体积远大于 b.js、b.js 先下载完,执行顺序仍然是 a 在前。defer 保序是规范级别的保证,不是实现巧合。
面试高频追问:如果 defer 脚本里又用
document.write会怎样?答案是不允许,Chrome 会输出警告:Failed to execute 'write' on 'Document': It isn't possible to write into a document from an asynchronously-loaded external script unless it is explicitly opened.所以别在 defer 脚本里用 document.write。
async:下载不阻塞,下载完立刻执行,时机完全不可控
async 的语义只有一句话:脚本可以异步下载,下载完成后立刻执行,执行时阻塞 HTML 解析。由此推出几个必考结论:
- 不保证顺序。哪个先下载完哪个先执行,多个 async 脚本之间谁先谁后完全看网络和体积。
- 可能在 DOMContentLoaded 之前,也可能在之后。如果 async 脚本在页面解析完成前就下载好并执行完,DOMContentLoaded 不会为它等待;如果它下载很慢,解析完成、defer 队列清空、DOMContentLoaded 已经触发,它才姗姗来迟地执行。
- 不阻塞 DOMContentLoaded 的触发时机。这点是跟 defer 最大的区别:DOMContentLoaded 不等 async 脚本。
<script>
document.addEventListener('DOMContentLoaded', () => console.log('DCL'));
window.addEventListener('load', () => console.log('load'));
</script>
<!-- 故意让这个脚本走慢速网络(DevTools 里把网络限速成 Slow 3G) -->
<script async src="/slow.js"></script>
<script async src="/fast.js"></script>
在慢速网络下常见输出是 DCL → load 之前插入 slow.js executed,甚至 fast.js executed 与 slow.js executed 的先后都可能互换。这不是 bug,是 async 的设计目的:适合统计脚本、广告脚本这类彼此独立、不依赖 DOM 状态的资源。
但要注意一个容易被忽略的细节:async 脚本的执行会阻塞解析,因此会间接推迟 DOMContentLoaded。如果 async 脚本在解析尚未结束时就下载完,它的执行会占住主线程,HTML 解析暂停,DOMContentLoaded 自然也往后推。准确表述应该是「DOMContentLoaded 不等待 async 脚本下载完成」,而不是「async 不影响 DOMContentLoaded」。
四种 script 形态的完整对比
面试时最好给出这张表,比口头描述可靠:
| 写法 | 是否阻塞解析 | 执行顺序 | 与 DOMContentLoaded |
|---|---|---|---|
<script src> |
是,下载和执行都阻塞 | 按文档顺序 | 一定在 DCL 之前 |
<script defer src> |
下载不阻塞,执行在解析完成后 | 严格按文档顺序串行 | 一定在 DCL 之前 |
<script async src> |
下载不阻塞,执行时阻塞 | 下载完即执行,顺序不定 | 可能在 DCL 前后任意一侧 |
<script type="module"> |
默认等同 defer | 按文档顺序,但有依赖图 | 在 DCL 之前 |
关于 module 那一行补一句:type="module" 默认就是 defer 行为,加 async 后变成 async 行为。模块脚本还受 CORS 约束,本地用 file:// 打开会直接报:
Access to script at 'file:///path/main.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, isolated-app, chrome-extension, chrome, https, chrome-untrusted.
解决办法是起一个本地静态服务器,而不是把 type="module" 删掉。这个报错在面试里被问到「为什么我的模块脚本本地跑不起来」时是标准答案。
动态插入的 script:默认是 async
这是本主题最容易被忽略的考点。document.createElement('script') 创建的脚本元素,默认 async 为 true,也就是说不显式设置时它表现得像 async。想让它按顺序、在 DCL 之前执行,要手动改两个属性:
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
// 关键:动态脚本默认 async=true,需要显式关掉
s.async = false;
// 让它走 defer 队列,而不是立即执行
s.defer = true;
s.onload = () => resolve(src);
s.onerror = () => reject(new Error('load failed: ' + src));
document.head.appendChild(s);
});
}
// 串行按顺序加载,a.js 内定义的全局变量在 b.js 里可用
Promise.all([loadScript('/a.js'), loadScript('/b.js')]).then(() => {
console.log('all scripts ready');
});
有两处实测行为要记牢:其一,async = false 只对动态插入且设置了 src 的脚本生效,能保证它们按照插入顺序执行;其二,如果页面解析已经结束(readyState 为 complete),那么动态脚本无论怎么设置都不会再等 DOMContentLoaded,因为它早就触发过了,此时给 document 加 DOMContentLoaded 监听器永远不会被调用。这是很多「为什么我的回调不执行」问题的根因,正确写法是先判断状态:
function onReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
// interactive 或 complete,DOM 已可用,直接执行
fn();
}
}
onReady(() => {
console.log('DOM ready, readyState =', document.readyState);
});
一次可复现的时序验证实验
口说无凭,给出一个不依赖任何库的验证页面,本地起服务后打开控制台即可看到顺序。为了让 async 的执行时机可控,用 setTimeout 的同步脚本无法模拟,所以这里靠网络限速制造差异,代码本身保持普通写法。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>defer vs async</title>
<script>
window.logs = [];
window.log = (msg) => {
const t = performance.now().toFixed(1);
window.logs.push(t + ' ' + msg);
console.log(t, msg);
};
document.addEventListener('DOMContentLoaded', () => log('DOMContentLoaded, readyState=' + document.readyState));
window.addEventListener('load', () => log('load, readyState=' + document.readyState));
</script>
<!-- 这两个 defer 脚本体积做成一大一小,验证保序 -->
<script defer src="/heavy.js"></script>
<script defer src="/light.js"></script>
<!-- 这两个 async 脚本体积与网络状况不同,验证乱序 -->
<script async src="/fast.js"></script>
<script async src="/slow.js"></script>
</head>
<body>
<p>打开控制台观察输出顺序</p>
</body>
</html>
heavy.js / light.js / fast.js / slow.js 里各写一行 log('heavy.js executed'); log('heavy.js finished'); 之类的日志即可。在 Chrome DevTools 的 Network 面板把限速调到 Slow 3G,多刷新几次,你会稳定看到 heavy.js 先于 light.js,而 fast.js 与 slow.js 的先后会变,且它们可能落在 DOMContentLoaded 之前也可能之后。把这个实验做过一遍,回答这类题就有底了。
再补一个经常被追问的点:DOMContentLoaded 的等待对象里到底包不包括样式表?规范上的行为是:如果文档中存在尚未加载完成的样式表(stylesheet),defer 脚本的执行会被推迟到样式表加载完之后,因为脚本可能读取样式。这意味着一个慢 CSS 会间接拖后 defer 脚本和 DOMContentLoaded。这个依赖链是「首屏 CSS 要内联、非关键 CSS 要延迟」这类优化建议的由来。
小结
- defer:下载异步、执行在解析完成后、严格保序、一定在 DOMContentLoaded 之前。只对外部脚本有效。
- async:下载异步、下载完立即执行(执行时阻塞解析)、不保序、可能在 DOMContentLoaded 任意一侧;它不保证也不阻止 DCL 等它下载,但它的执行本身会推迟解析。
- module 默认 defer,加 async 后等同 async。
- 动态脚本默认 async=true,需要
async = false才能保序;页面已 interactive/complete 时 DCL 监听器不会再触发,要判断document.readyState。 - 慢样式表会推迟 defer 执行,进而推迟 DOMContentLoaded。
FAQ
1. defer 和 async 同时写在一个 script 上会怎样?
规范规定:如果两个属性都存在,async 优先,defer 被忽略,脚本表现为 async。所以不要这么写,行为依赖实现不如显式只写一个。
2. 内联脚本加 defer 有用吗?
没用。defer 和 async 都只对外部脚本(有 src)生效,内联的 <script defer> 会被忽略,依旧同步执行。想让一段代码等到 DOM 就绪,用 DOMContentLoaded 或者把 `