defer 与 async 加载脚本时 DOMContentLoaded 触发顺序

这道题几乎每次问「浏览器渲染流程」都会被带出来:给 <script> 加上 defer 或 async,DOMContentLoaded 到底在什么时候触发?很多人的回答停留在「defer 在 DOMContentLoaded 之前、async 不确定」,这个说法不算错,但被追问一句「多个 defer 脚本之间谁先执行」「async 脚本会不会插

这道题几乎每次问「浏览器渲染流程」都会被带出来:给 <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 或者把 `