script 的 defer 与 async 区别:为什么头部脚本拿不到 DOM 元素

script 默认会阻塞 HTML 解析,这正是头部脚本 getElementById 拿到 null 的原因。本文用一张对照表讲清默认、defer、async 三种加载方式的执行时机差异,给出 DOMContentLoaded 标准写法、动态脚本顺序控制与现在的脚本放置建议。

约 6 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

一句话结论:要有序用 defer,要独立用 async,默认同步最危险

浏览器解析 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执行时阻塞,但主体已解析解析到达时按文档顺序

三种加载方式写法对照

HTML 写法
<!-- 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>

为什么头部脚本 getElementById 返回 null

document 按顺序解析,<head> 里的普通脚本执行时,<body> 里的元素根本还不存在,所以 getElementById 得到 null,接着给 null.addEventListener 就报「Cannot read properties of null」。这不是加载问题,是执行时机问题:代码跑得比 DOM 快。

DOMContentLoaded:等 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 步。

  1. 看报错行号对应的脚本在哪里加载:head 里还是在元素之后
  2. 给脚本加上 defer 或移到 </body> 前,再刷新验证
  3. 确认元素 id 拼写无误、没有被模板条件渲染掉(v-if、条件渲染)
  4. 若元素是 JS 动态创建的,改为监听其父容器的事件委托,而不是直接绑定

现在的脚本放置建议

defer 早已全量支持,老习惯可以退休了。

  • 首选:脚本全部放 <head> 并加 defer,HTML 写起来干净,下载也最早开始
  • 独立第三方脚本(统计、广告)用 async,别让它排队
  • 有严格依赖链的(先库后业务)用 defer,天然按顺序执行
  • 不再推荐把脚本堆在 </body> 前:这是 defer 普及前的老习惯
  • 需要极早执行的关键逻辑才考虑内联,普通逻辑别内联

动态插入的 script 默认是 async

用 JS 创建的 script 标签行为和静态写法不同:默认 async = true,顺序不保证。

动态脚本指定顺序
const s = document.createElement('script');
s.src = '/chunk.js';
// 动态脚本默认 async = true,执行顺序不保证
// 要按顺序执行必须显式关掉
s.async = false;
document.head.appendChild(s);

一行修复:给头部脚本加上 defer

把 <script src="app.js"> 改成 <script defer src="app.js">,「Cannot read properties of null」大概率立刻消失。改完用 HTML 格式化工具检查标签与属性写法,再用兼容性查询确认 defer/async 的支持情况——早已是 100%。

常见问题

defer 和 async 可以同时写吗?

可以同时出现在标签里,但此时 defer 会被忽略,脚本按 async 行为执行。实际项目里写一个就够,避免误读。

type="module" 脚本需要加 defer 吗?

不需要。模块脚本默认就是 defer 语义:不阻塞解析、按顺序、在 DOMContentLoaded 前执行,显式加 defer 也不冲突。

DOMContentLoaded 和 load 事件有什么区别?

DOMContentLoaded 在 HTML 解析完(含 defer 脚本执行)时触发;load 要等图片、样式等所有资源加载完,通常更晚。绑定元素事件用前者即可。

把 script 放 body 末尾还有意义吗?

在全面支持 defer 的今天意义不大。head + defer 能更早开始下载,又保持非阻塞,是更优解;只有极老的浏览器才需要末尾放置的兼容写法。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件