跳到主要内容
预计阅读 40 分钟

浏览器篇 | async 与 defer

前言

如果你做过任何前端性能优化,一定见过这两个属性:asyncdefer。它们被加在 <script> 标签上,用来控制外部脚本的加载和执行时机。

很多人知道”加了 async 或 defer 就不会阻塞页面”,但如果面试官继续追问:

  • async 和 defer 到底在什么时候执行脚本?它们的执行顺序有什么区别?
  • <script type="module"> 默认是 async 还是 defer?
  • 动态创建的 <script> 标签,加载行为和静态写在 HTML 里的有什么不同?
  • preloadprefetch 又是干什么的?它们和 async/defer 有什么关系?

能把这些讲清楚的人,真不多。

本章我们就从 <script> 标签的默认加载行为出发,一步步搞清楚 async、defer、type=“module”、动态脚本、以及预加载策略,让你在面试中能从底层原理到实际选型,讲出一套完整的逻辑。


诊断自测

在正文开始前,先用几道题检验一下你对脚本加载的了解程度。答不上来没关系,读完全文再回来对答案。

Q1:下面三个脚本标签的加载和执行顺序是怎样的?

<script defer src="a.js"></script>
<script async src="b.js"></script>
<script src="c.js"></script>
点击查看答案

首先,c.js 是普通脚本,会阻塞 HTML 解析——浏览器遇到它会暂停解析,下载并执行完后才继续。a.js(defer)和 b.js(async)都是异步下载,不阻塞 HTML 解析。

执行顺序:c.js 最先执行(因为解析到就阻塞执行了)。b.js 在下载完成后立即执行,时机不确定。a.js 在 DOM 解析完毕后、DOMContentLoaded 事件之前执行。如果 b.jsa.js 下载快,b.js 可能先于 a.js 执行;反之则相反。

Q2:<script type="module" src="app.js"></script> 的默认加载行为和加了 defer 一样吗?

点击查看答案

是的。type="module" 的脚本默认就是 defer 行为:异步下载,不阻塞 HTML 解析,在 DOM 解析完毕后按顺序执行。如果你给它加上 async 属性,则变成”下载完立即执行,不保证顺序”。

Q3:动态创建一个 script 标签并插入 DOM,它的加载行为默认是 async 还是 defer?

点击查看答案

动态创建的 <script> 标签,async 属性默认为 true——也就是说,默认行为类似 async:下载完就执行,不保证顺序。如果你想让多个动态脚本按插入顺序执行,需要手动设置 script.async = false


一、script 标签的默认加载行为

先搞清楚最基本的情况:不加任何属性的 <script> 标签。

<script src="app.js"></script>

当浏览器的 HTML 解析器遇到这行代码时,会经历以下过程:

  1. 暂停 HTML 解析
  2. 下载 app.js(如果是外部脚本)
  3. 执行 app.js
  4. 执行完毕后,恢复 HTML 解析

注意:这里”暂停解析”意味着后面的 DOM 节点还没有被创建。如果脚本里试图访问它后面的 DOM 元素,会拿到 null

<script>
  // ❌ 此时 #app 还没被解析出来
  console.log(document.getElementById('app')); // null
</script>
<div id="app"></div>

这就是为什么传统做法是把 script 标签放在 </body> 前面——等 DOM 都解析完了再执行脚本,就不会有”找不到元素”的问题。

<body>
  <div id="app"></div>
  <!-- 所有 DOM 都解析完了,脚本可以安全地操作它们 -->
  <script src="app.js"></script>
</body>

但这种方式有一个明显的缺点:脚本的下载被延迟了。浏览器要一直解析到 </body> 附近才开始下载脚本,白白浪费了前面解析 HTML 时的网络空闲时间。

这就是 asyncdefer 要解决的问题。


二、defer:异步加载,延迟执行,保证顺序

<script defer src="app.js"></script>

加了 defer 之后,行为变成:

  1. 浏览器在解析 HTML 时,并行下载脚本,不暂停解析
  2. 等整个 HTML 文档解析完毕(DOM 树构建完成)后,按照在文档中出现的顺序依次执行
  3. 执行完所有 defer 脚本后,触发 DOMContentLoaded 事件

关键特性:

  • 不阻塞 HTML 解析:下载过程和 HTML 解析并行
  • 执行时机确定:DOM 解析完毕后、DOMContentLoaded 之前
  • 保证执行顺序:多个 defer 脚本,按它们在 HTML 中出现的顺序执行
<script defer src="jquery.js"></script>
<script defer src="plugin.js"></script>
<script defer src="app.js"></script>

上面三个脚本,无论哪个先下载完,执行顺序一定是 jquery.js → plugin.js → app.js。这对有依赖关系的脚本至关重要。

defer 的适用场景

  • 脚本需要操作 DOM,但你又想把 <script> 写在 <head> 里(提前开始下载)
  • 多个脚本之间有依赖关系,必须按顺序执行
  • 大多数情况下,defer 是最安全、最推荐的选择

一个小细节

defer 只对外部脚本有效。对内联脚本(没有 src 属性的)加 defer 没有任何效果,浏览器会忽略它。

<!-- defer 无效,仍然同步执行 -->
<script defer>
  console.log('我还是会阻塞解析');
</script>

三、async:异步加载,加载完立即执行,不保证顺序

<script async src="analytics.js"></script>

加了 async 之后,行为变成:

  1. 浏览器在解析 HTML 时,并行下载脚本,不暂停解析
  2. 下载完成后立即执行,执行期间暂停 HTML 解析
  3. 多个 async 脚本之间不保证执行顺序——谁先下载完谁先执行

关键特性:

  • 不阻塞下载:和 defer 一样,下载过程不阻塞解析
  • 执行时机不确定:取决于下载速度,可能在 DOM 解析完之前,也可能在之后
  • 不保证顺序:多个 async 脚本的执行顺序是”先到先得”
<script async src="a.js"></script> <!-- 100KB -->
<script async src="b.js"></script> <!-- 10KB -->

如果 b.js 体积更小、下载更快,那么 b.js 会先执行。即使 a.js 在 HTML 中写在前面。

async 的适用场景

  • 完全独立的脚本,不依赖其他脚本,也不操作 DOM
  • 典型例子:统计分析脚本(Google Analytics)、广告脚本、第三方追踪器
  • 这类脚本越早执行越好,但具体执行时机不重要

async 和 defer 同时写会怎样?

<script async defer src="app.js"></script>

如果浏览器同时支持 asyncdeferasync 优先defer 在这里只是作为不支持 async 的老浏览器的降级方案(现代浏览器都支持 async,所以这种写法已经没什么意义了)。


四、一张图搞清楚区别

用时间线来看,三种加载方式的区别一目了然:

普通 <script>:
  HTML 解析 ──────┤ 下载脚本 │ 执行脚本 ├── HTML 解析继续 ──
                  ↑ 暂停解析

<script defer>:
  HTML 解析 ──────────────────────────── 解析完毕 ┤ 执行脚本 ├→ DOMContentLoaded
                  │ 下载脚本(并行)│

<script async>:
  HTML 解析 ──────────┤ 执行脚本 ├── HTML 解析继续 ──
                  │ 下载 │↑
                  (并行)  下载完就执行

对比表

特性普通 scriptdeferasync
下载时阻塞解析
执行时阻塞解析不会(DOM 已解析完)
执行时机立即DOM 解析完毕后下载完成后立即
执行顺序按文档顺序按文档顺序不保证
适用场景必须立即执行的脚本大多数脚本独立的第三方脚本

五、type=“module” 的加载行为

ES Modules 在浏览器中已经得到了广泛支持。<script type="module"> 的加载行为有几个值得注意的特点:

默认 defer

<script type="module" src="app.mjs"></script>

type="module" 的脚本默认就是 defer 行为:异步下载,DOM 解析完毕后按文档顺序执行。你不需要额外加 defer 属性。

可以加 async

<script async type="module" src="app.mjs"></script>

加了 async 后,模块脚本变成”下载完立即执行”,行为和普通 async 脚本类似。

内联模块也是 defer

和普通脚本不同的是,内联的 module 脚本也支持 defer 行为(虽然内联脚本没有下载阶段,但它的执行会等到 DOM 解析完毕后):

<script type="module">
  // 这段代码会在 DOM 解析完毕后执行
  console.log(document.getElementById('app')); // ✅ 能拿到
</script>
<div id="app"></div>

自动严格模式

模块脚本自动运行在严格模式下,不需要 'use strict'

同一模块只执行一次

如果多个 <script type="module"> 引用了同一个模块 URL,浏览器只会下载和执行一次

<!-- utils.mjs 只会被下载和执行一次 -->
<script type="module" src="utils.mjs"></script>
<script type="module" src="utils.mjs"></script>

对比表

特性普通 scripttype=“module”
默认加载行为同步阻塞defer
支持 async
内联脚本的 defer不支持支持
严格模式需要手动声明自动
重复引用每次都执行只执行一次
跨域请求不需要 CORS需要 CORS

六、动态创建 script 标签

在单页应用和按需加载的场景中,我们经常需要用 JavaScript 动态创建 <script> 标签:

const script = document.createElement('script');
script.src = 'lazy-module.js';
document.head.appendChild(script);

动态创建的脚本有一个非常重要的特性:默认行为是 async 的

也就是说,上面的代码等价于:

<script async src="lazy-module.js"></script>

脚本下载完成后会立即执行,不保证和其他动态脚本的执行顺序。

让动态脚本按顺序执行

如果你需要动态加载多个有依赖关系的脚本,需要手动关闭 async:

function loadScriptsInOrder(urls) {
  urls.forEach(url => {
    const script = document.createElement('script');
    script.src = url;
    script.async = false; // 关键:关闭 async,变成类似 defer 的行为
    document.head.appendChild(script);
  });
}

loadScriptsInOrder([
  'jquery.js',
  'plugin.js',
  'app.js'
]);
// 保证执行顺序:jquery.js → plugin.js → app.js

设置 script.async = false 后,脚本会按照被添加到 DOM 的顺序执行,类似 defer 的行为。

监听加载完成

动态脚本可以通过 loaderror 事件来监听加载状态:

const script = document.createElement('script');
script.src = 'analytics.js';

script.onload = () => {
  console.log('脚本加载并执行完成');
};

script.onerror = () => {
  console.error('脚本加载失败');
};

document.head.appendChild(script);

封装成 Promise

在实际项目中,通常会把动态加载封装成 Promise:

function loadScript(src) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

// 使用
async function init() {
  await loadScript('vendor.js');
  await loadScript('app.js');
  console.log('所有脚本加载完成');
}

七、preload 与 prefetch 预加载策略

除了控制脚本的执行时机,我们还可以通过 preloadprefetch 来优化资源的下载时机

preload:提前加载当前页面的关键资源

<link rel="preload" href="critical.js" as="script">

preload 告诉浏览器:“这个资源在当前页面马上就要用到,请尽早开始下载。”

关键点:

  • preload 只下载,不执行。要执行脚本,还是需要 <script> 标签
  • 适用于首屏关键资源:字体、CSS、关键 JS
  • 需要指定 as 属性(scriptstylefont 等),帮助浏览器确定优先级
<head>
  <!-- 提前下载,但还没执行 -->
  <link rel="preload" href="app.js" as="script">
  <link rel="preload" href="styles.css" as="style">
</head>
<body>
  <!-- 真正使用的时候,文件可能已经下载好了 -->
  <link rel="stylesheet" href="styles.css">
  <script defer src="app.js"></script>
</body>

prefetch:预加载将来可能用到的资源

<link rel="prefetch" href="next-page.js" as="script">

prefetch 告诉浏览器:“这个资源在将来的导航中可能会用到,如果你有空闲带宽,可以提前下载。”

关键点:

  • 优先级很低,不会抢占当前页面资源的带宽
  • 适用于下一页可能需要的资源
  • 预取的资源会被存入浏览器缓存,下次请求时直接命中

preload vs prefetch

特性preloadprefetch
用途当前页面的关键资源将来导航可能需要的资源
优先级
下载时机尽早浏览器空闲时
是否执行否,只下载否,只下载

modulepreload:专门为 ES Modules 设计

<link rel="modulepreload" href="utils.mjs">

modulepreloadpreload 类似,但它专门针对 ES Module。它不仅会下载模块文件,还会解析模块的依赖图,预先下载所有依赖的子模块。

<link rel="modulepreload" href="app.mjs">
<link rel="modulepreload" href="utils.mjs">
<script type="module" src="app.mjs"></script>

实际项目中的搭配策略

一个典型的首屏优化策略:

<head>
  <!-- 1. 预加载关键资源 -->
  <link rel="preload" href="main.css" as="style">
  <link rel="preload" href="app.js" as="script">
  <link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>

  <!-- 2. 预取下一页可能用到的资源 -->
  <link rel="prefetch" href="about-page.js" as="script">

  <!-- 3. 加载 CSS(此时可能已经下载完了) -->
  <link rel="stylesheet" href="main.css">
</head>
<body>
  <div id="app"></div>
  <!-- 4. defer 加载脚本(此时可能已经下载完了) -->
  <script defer src="app.js"></script>
</body>

八、总结对比:选择哪种方式?

根据不同的场景,选择最合适的脚本加载策略:

场景推荐方式原因
大多数应用脚本defer不阻塞解析,保证顺序,DOM 就绪后执行
独立的第三方脚本async无依赖,越早执行越好
ES Moduletype="module"默认 defer,支持 import/export
按需加载动态 <script>路由切换或用户交互后再加载
首屏关键资源preload + defer提前下载,延迟执行
下一页资源prefetch空闲时预取

常见误区

误区一:“async 就是异步加载,所以比 defer 好”

async 的”异步”指的是下载不阻塞,但执行仍然会阻塞 HTML 解析。而且 async 不保证执行顺序。如果你的脚本有依赖关系(比如先加载 jQuery 再加载插件),用 async 可能导致插件在 jQuery 之前执行,直接报错。defer 在大多数场景下是更安全的选择。

误区二:“把 script 放在 body 底部就不需要 defer 了”

放在 body 底部确实能避免阻塞 DOM 解析,但脚本的下载也被推迟了。使用 defer 并把脚本放在 <head> 中,可以让浏览器在解析 HTML 的同时就开始下载脚本,实现”下载提前、执行延后”的最优效果。

误区三:“preload 能让资源立即执行”

preload 只管下载,不管执行。你告诉浏览器”提前下载这个文件”,但要让 JS 执行、CSS 生效,还是需要对应的 <script><link rel="stylesheet"> 标签。如果你只写了 preload 但忘了写使用标签,浏览器会在控制台警告你”资源被 preload 但 3 秒内未被使用”。

误区四:“动态创建的 script 和普通 script 行为一样”

不一样。动态创建的 <script> 默认 async = true,而静态写在 HTML 中的 <script> 默认是同步阻塞的。这个差异经常被忽略,导致动态加载多个有依赖关系的脚本时顺序错乱。


小结

本章从 <script> 标签的默认加载行为出发,系统梳理了 async、defer、type=“module”、动态脚本和预加载策略之间的关系。

核心要点

  1. 普通 script:下载和执行都阻塞 HTML 解析,最朴素也最”危险”的加载方式
  2. defer:异步下载,DOM 解析完后按顺序执行,适合大多数脚本
  3. async:异步下载,下载完立即执行,不保证顺序,适合独立的第三方脚本
  4. type=“module”:默认 defer 行为,支持 ES Module 语法
  5. 动态 script:默认 async 行为,设置 async = false 可保证顺序
  6. preload:提前下载当前页面的关键资源,只下载不执行
  7. prefetch:低优先级预取将来可能用到的资源

本章思维导图

浏览器:script 加载策略
  • 默认行为
    • 下载和执行都阻塞 HTML 解析
    • 传统做法:放在 </body> 前
  • defer
    • 异步下载,不阻塞解析
    • DOM 解析完毕后执行
    • 保证文档顺序
    • 只对外部脚本有效
  • async
    • 异步下载,不阻塞解析
    • 下载完立即执行
    • 不保证顺序
    • 适合独立脚本
  • type="module"
    • 默认 defer 行为
    • 可加 async 覆盖
    • 内联模块也是 defer
    • 自动严格模式
    • 同一模块只执行一次
  • 动态 script
    • 默认 async = true
    • async = false 可保证顺序
    • onload / onerror 监听状态
    • 可封装成 Promise
  • 预加载策略
    • preload:当前页面关键资源,高优先级
    • prefetch:将来可能用到,低优先级
    • modulepreload:ES Module 专用
  • 选型建议
    • 大多数脚本 → defer
    • 第三方独立脚本 → async
    • 按需加载 → 动态 script
    • 首屏优化 → preload + defer

练习挑战

第一题(⭐ 基础):判断执行顺序

<!DOCTYPE html>
<html>
<head>
  <script defer src="a.js"></script>
  <script async src="b.js"></script>
</head>
<body>
  <script>console.log('inline');</script>
  <div id="app"></div>
  <script defer src="c.js"></script>
</body>
</html>

假设 b.js 体积很大,下载耗时较长。请问 inlinea.jsb.jsc.js 的执行顺序是什么?

点击查看答案与解析

执行顺序:inlinea.jsc.jsb.js(假设 b.js 下载很慢)。

  • inline:内联脚本没有 defer/async,遇到就执行,最先
  • a.jsc.js:都是 defer,DOM 解析完后按文档顺序执行,所以 a 在 c 前面
  • b.js:async,下载完才执行。因为假设它体积大下载慢,所以最后执行

如果 b.js 下载很快,它可能在 inline 之后、a.js 之前就执行了——async 的时机是不确定的。

第二题(⭐⭐ 进阶):实现按序动态加载

请实现一个 loadScriptsSequentially 函数,接收一组脚本 URL,依次加载并执行。要求:

  1. 前一个脚本执行完后再加载下一个
  2. 返回一个 Promise,全部完成后 resolve
  3. 任一脚本加载失败时 reject
// 用法
loadScriptsSequentially([
  'https://cdn.example.com/react.js',
  'https://cdn.example.com/react-dom.js',
  'https://cdn.example.com/app.js'
]).then(() => {
  console.log('All scripts loaded');
});
点击查看答案与解析
function loadScriptsSequentially(urls) {
  return urls.reduce((chain, url) => {
    return chain.then(() => new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = url;
      script.onload = resolve;
      script.onerror = () => reject(new Error(`Failed to load: ${url}`));
      document.head.appendChild(script);
    }));
  }, Promise.resolve());
}

通过 reduce 构建 Promise 链,每个脚本的加载都等待前一个完成后才开始。这里没有设置 async = false,因为我们用 Promise 链来保证顺序——每次只添加一个脚本,等它的 onload 触发后再添加下一个。

第三题(⭐⭐⭐ 综合):首屏加载优化方案

假设你有以下资源需要加载:

  • main.css:页面核心样式
  • vendor.js:第三方依赖(React、lodash 等)
  • app.js:业务逻辑,依赖 vendor.js
  • analytics.js:统计脚本,完全独立
  • font.woff2:自定义字体
  • next-page.js:用户很可能会访问的下一页的脚本

请写出完整的 HTML <head><body> 中的资源加载标签,并说明每个标签为什么这样选择。

点击查看答案与解析
<head>
  <!-- 1. preload 关键资源:提前下载,不阻塞渲染 -->
  <link rel="preload" href="main.css" as="style">
  <link rel="preload" href="vendor.js" as="script">
  <link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>

  <!-- 2. prefetch 下一页资源:低优先级预取 -->
  <link rel="prefetch" href="next-page.js" as="script">

  <!-- 3. 加载 CSS:preload 已提前下载,这里立即可用 -->
  <link rel="stylesheet" href="main.css">

  <!-- 4. defer 加载业务脚本:保证 vendor → app 顺序 -->
  <script defer src="vendor.js"></script>
  <script defer src="app.js"></script>

  <!-- 5. async 加载独立统计脚本 -->
  <script async src="analytics.js"></script>
</head>

说明:

  • main.css 用 preload 提前下载,配合 stylesheet 标签使用
  • vendor.js 用 preload 提前下载 + defer 延迟执行
  • font.woff2 用 preload 避免 FOUT(字体闪烁)
  • vendor.jsapp.js 都用 defer,保证顺序且不阻塞解析
  • analytics.js 用 async,完全独立,越早执行越好
  • next-page.js 用 prefetch,空闲时预取

自我检测

读完本章后,对照下面的清单检验一下自己的掌握程度:

  • 能说清楚不加任何属性的 <script> 标签的默认加载行为及其性能问题
  • 能准确描述 defer 的三个关键特性:异步下载、DOM 解析后执行、保证顺序
  • 能准确描述 async 的三个关键特性:异步下载、下载完立即执行、不保证顺序
  • 能解释 type="module" 的默认加载行为以及它和 defer 的关系
  • 能说出动态创建 <script> 标签默认是 async 行为,以及如何让它按序执行
  • 能区分 preloadprefetch 的用途和优先级
  • 能根据具体场景选择合适的脚本加载策略
  • 能解释为什么 defer 通常比”把 script 放在 body 底部”更好

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90