约 6 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
浏览器解析 HTML 遇到 <script src> 时默认会停下来:下载并执行完才继续。defer 让脚本在 HTML 解析完后、DOMContentLoaded 之前按顺序执行;async 则是下载完立刻执行、顺序不保证。头部脚本拿不到元素,就是因为默认脚本在 DOM 构建完成之前就执行了。
选型前先看清每种的阻塞行为、执行时机与顺序保证。
| 写法 | 阻塞 HTML 解析 | 执行时机 | 顺序保证 |
|---|---|---|---|
| <script src> | 阻塞(下载 + 执行) | 立即 | 按文档顺序 |
| <script async src> | 下载不阻塞,执行时占用主线程 | 下载完立即执行 | 不保证 |
| <script defer src> | 不阻塞 | HTML 解析完后、DOMContentLoaded 前 | 按文档顺序 |
| type="module" | 默认等同 defer | 同 defer | 按文档顺序 |
| </body> 前普通 script | 执行时阻塞,但主体已解析 | 解析到达时 | 按文档顺序 |
<!-- 1. 默认:阻塞解析,头部慎用 -->
<script src="/app.js"></script>
<!-- 2. async:下载不阻塞,下载完立即执行,顺序不保证
适合独立脚本:统计、广告 -->
<script async src="/analytics.js"></script>
<!-- 3. defer:不阻塞,解析完后按顺序执行
适合有依赖关系的主脚本 -->
<script defer src="/vendor.js"></script>
<script defer src="/app.js"></script>document 按顺序解析,<head> 里的普通脚本执行时,<body> 里的元素根本还不存在,所以 getElementById 得到 null,接着给 null.addEventListener 就报「Cannot read properties of null」。这不是加载问题,是执行时机问题:代码跑得比 DOM 快。
// defer 脚本执行完之后才触发 DOMContentLoaded,
// 所以 defer 脚本里通常不需要再监听它
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('submit');
btn.addEventListener('click', handleSubmit);
});
// window.load 更晚:还要等图片、样式表全部加载完
window.addEventListener('load', () => {
console.log('所有资源加载完毕');
});绝大多数情况卡在第 1 步和第 4 步。
defer 早已全量支持,老习惯可以退休了。
用 JS 创建的 script 标签行为和静态写法不同:默认 async = true,顺序不保证。
const s = document.createElement('script');
s.src = '/chunk.js';
// 动态脚本默认 async = true,执行顺序不保证
// 要按顺序执行必须显式关掉
s.async = false;
document.head.appendChild(s);把 <script src="app.js"> 改成 <script defer src="app.js">,「Cannot read properties of null」大概率立刻消失。改完用 HTML 格式化工具检查标签与属性写法,再用兼容性查询确认 defer/async 的支持情况——早已是 100%。
可以同时出现在标签里,但此时 defer 会被忽略,脚本按 async 行为执行。实际项目里写一个就够,避免误读。
不需要。模块脚本默认就是 defer 语义:不阻塞解析、按顺序、在 DOMContentLoaded 前执行,显式加 defer 也不冲突。
DOMContentLoaded 在 HTML 解析完(含 defer 脚本执行)时触发;load 要等图片、样式等所有资源加载完,通常更晚。绑定元素事件用前者即可。
在全面支持 defer 的今天意义不大。head + defer 能更早开始下载,又保持非阻塞,是更优解;只有极老的浏览器才需要末尾放置的兼容写法。