DOMContentLoaded 晚触发的根本原因是默认脚本阻塞 DOM 解析,而非 script 位置;defer 脚本执行完才触发该事件,async 脚本执行时机不可控,监听需显式判断 readyState 并兜底。

DOMContentLoaded 为什么总比预期晚触发
根本原因不是 <script> 放在哪儿,而是它有没有阻塞 DOM 解析。默认脚本(没加 async 或 defer)会强制暂停 HTML 解析,等下载、解析、执行全做完才继续——哪怕只有一行 console.log("hi"),也会把 DOMContentLoaded 推迟到它执行完。
常见现象包括:document.getElementById("app") 返回 null、Vue 初始化失败、按钮点击无响应;DevTools Network 面板里 DOMContentLoaded 时间戳明显滞后于 HTML 完成线。
- 第三方 SDK 脚本(如 analytics.js)放在
<head>且没defer,首屏渲染直接卡住几百毫秒 -
<script src="polyfill.js"></script>在<head>里,<main>内容迟迟不渲染 - 移动端弱网下,用户看到空白页,控制台报
Cannot read property 'addEventListener' of null
defer 脚本执行时机与 DOMContentLoaded 的先后关系
defer 脚本不会阻塞解析,但它的执行严格发生在 DOM 构建完成之后、DOMContentLoaded 触发之前。也就是说:DOMContentLoaded 是 defer 脚本执行完才发的,不是并发或并行的。
这意味着你在 defer 脚本里能安全调用 document.getElementById、document.body.appendChild、element.addEventListener,但不能假设 CSS 已生效或图片已加载——getComputedStyle(element) 可能返回初始值,document.images[0].complete 可能还是 false。
立即学习“前端免费学习笔记(深入)”;
- 多个
defer脚本按 HTML 中出现顺序执行,和下载完成时间无关 -
<script src="a.js" defer></script>和<script src="b.js" defer></script>,即使 b.js 下载更快,也一定等 a.js 执行完再执行 b.js -
async和defer不能共存;若同时写,async优先级更高,defer被静默忽略 - Webpack 默认输出不带
defer,需在html-webpack-plugin配置scriptLoading: 'defer'
async 脚本为何不能依赖 DOMContentLoaded
async 脚本下载时不阻塞解析,但一旦下载完成就立刻执行——不管 DOM 是否构建完毕。它可能在 DOMContentLoaded 前、中、后任意时刻运行,完全不可预测。
所以你不能在 async 脚本里注册 DOMContentLoaded 监听器,也不能在里面操作 DOM 元素(除非加兜底判断),更不能指望它和其他脚本有执行顺序。
- 适合场景:PV 统计、错误上报、CDN 加速的独立工具(如 Sentry init)
- 不适合场景:
new Vue({ el: '#app' })、$("#header").show()、任何依赖document.body存在的操作 - 如果误用:
document.querySelector(".nav")在 async 脚本里返回null,且无法靠重试解决——因为执行时机早于 DOM 就位 - Vite 默认生成内联脚本,
defer无效;要改用外链输出或关闭inlineDynamicImports
监听 DOMContentLoaded 的正确姿势
别靠脚本位置“碰运气”,比如把初始化逻辑写在 </body> 前面就以为万事大吉——只要上面混入一个没 defer 的同步脚本,整个逻辑就可能提前执行失败。
唯一可靠方式是显式监听事件,并做好状态兜底:
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
注意点:
- 在
<iframe>内监听,必须用iframe.contentDocument,不是顶层document - 微前端或 SSR 场景下,页面可能已被激活过一次,
addEventListener可能错过已触发的事件 - 不要在
defer脚本里再监听DOMContentLoaded——它已经保证执行时 DOM 就绪,再监听纯属冗余
最常被忽略的干扰项:页面里混了其他非 defer 脚本。哪怕只有一个没配属性的 <script src="legacy.js"></script>,就能让所有后续逻辑失效。



















