跳到主要内容

script 标签的 defer 和 async

问题

<script> 标签的 deferasync 属性有什么区别?分别在什么场景下使用?

答案

默认行为(无 defer/async)

默认情况下,浏览器遇到 <script> 标签会:

  1. 暂停 HTML 解析
  2. 下载脚本
  3. 执行脚本
  4. 继续 HTML 解析
<script src="script.js"></script>
问题

这会阻塞页面渲染,如果脚本很大或网络很慢,用户会看到白屏。


async 属性

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

特点

  • 异步下载:不阻塞 HTML 解析
  • 下载完立即执行:会暂停 HTML 解析
  • 执行顺序不确定:谁先下载完谁先执行

适用场景

  • 独立的第三方脚本(广告、统计)
  • 不依赖其他脚本
  • 不操作 DOM
<!-- 适合 async 的脚本 -->
<script async src="https://www.google-analytics.com/analytics.js"></script>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

defer 属性

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

特点

  • 异步下载:不阻塞 HTML 解析
  • 延迟执行:等 HTML 解析完成后执行
  • 保证顺序:按照在 HTML 中的顺序执行
  • 在 DOMContentLoaded 之前执行

适用场景

  • 需要操作 DOM 的脚本
  • 有依赖关系的多个脚本
  • 主要的应用程序代码
<!-- 适合 defer 的脚本 -->
<script defer src="vendor.js"></script> <!-- 先执行 -->
<script defer src="app.js"></script> <!-- 后执行 -->

对比总结

特性默认asyncdefer
下载时阻塞解析✅ 是❌ 否❌ 否
执行时阻塞解析✅ 是✅ 是❌ 否
执行时机下载后立即下载后立即HTML 解析完成后
执行顺序按顺序不确定按顺序
DOM 可用取决于位置不一定✅ 是

时间线对比

默认(阻塞):
HTML: =====[暂停]===========[暂停]=====
JS1: [下载][执行]
JS2: [下载][执行]

async(异步,下载完就执行):
HTML: ================================
JS1: [ 下载 ][执行]
JS2: [下载][执行] ← JS2 可能先执行!

defer(异步,最后按顺序执行):
HTML: ================================[完成]
JS1: [ 下载 ] [执行]
JS2: [下载] [执行]
↑ 按顺序

代码示例

验证执行顺序

index.html
<!DOCTYPE html>
<html>
<head>
<script>
console.log('1. 内联脚本(head)');
</script>
<script defer src="defer1.js"></script>
<script defer src="defer2.js"></script>
<script async src="async1.js"></script>
<script async src="async2.js"></script>
</head>
<body>
<script>
console.log('5. 内联脚本(body)');
</script>
<script>
document.addEventListener('DOMContentLoaded', () => {
console.log('6. DOMContentLoaded');
});
</script>
</body>
</html>
defer1.js
console.log('2. defer1.js');
defer2.js
console.log('3. defer2.js');
async1.js
console.log('async1.js');  // 顺序不确定
async2.js
console.log('async2.js');  // 顺序不确定

输出顺序

1. 内联脚本(head)
5. 内联脚本(body)
async1.js 或 async2.js(顺序不确定)
async2.js 或 async1.js(顺序不确定)
2. defer1.js
3. defer2.js
6. DOMContentLoaded
关键点
  • 内联脚本不受 defer/async 影响
  • defer 脚本在 DOMContentLoaded 之前执行
  • async 脚本的执行顺序取决于下载速度

常见面试问题

Q1: 把 script 放在 body 底部和使用 defer 有什么区别?

<!-- 方式一:放在 body 底部 -->
<body>
<!-- 页面内容 -->
<script src="app.js"></script>
</body>

<!-- 方式二:使用 defer -->
<head>
<script defer src="app.js"></script>
</head>
对比项body 底部defer
下载时机HTML 解析到 script 标签时HTML 解析开始时(并行)
执行时机下载完立即执行HTML 解析完成后
性能较慢(串行)较快(并行下载)

结论defer 更优,因为可以并行下载,不会等到 body 底部才开始下载。

Q2: async 和 defer 同时使用会怎样?

<script async defer src="script.js"></script>
  • 如果浏览器支持 async,使用 async 行为
  • 如果不支持 async(老浏览器),降级使用 defer
  • 这是一种兼容性写法

Q3: 动态创建的 script 默认是什么行为?

const script = document.createElement('script');
script.src = 'app.js';
document.body.appendChild(script);

动态创建的脚本默认是 async 行为,如果需要按顺序执行:

const script = document.createElement('script');
script.src = 'app.js';
script.async = false; // 改为同步,按顺序执行
document.body.appendChild(script);

Q4: module 类型的 script 默认是什么行为?

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

ES Module 脚本默认是 defer 行为

  • 异步下载
  • HTML 解析完成后按顺序执行
  • 可以加 async 变成 async 行为
<!-- defer 行为(默认) -->
<script type="module" src="app.js"></script>

<!-- async 行为 -->
<script type="module" async src="app.js"></script>

Q5: type="module" 的脚本有什么特殊行为?和 defer 有什么异同?

答案

<script type="module"> 是 ES Module 在浏览器中的原生加载方式,它的行为和普通的 defer 脚本有诸多相似之处,但也有关键差异:

type="module" 的特殊行为

特性type="module"普通 defer
默认加载方式默认 defer(异步下载,HTML 解析后执行)需显式添加 defer 属性
执行模式自动严格模式'use strict'非严格模式(除非手动声明)
作用域独立模块作用域(顶层变量不污染全局)共享全局作用域
import/export支持不支持
重复加载同一模块只执行一次每个 script 标签都执行
CORS 要求必须遵守 CORS(跨域需服务端配置)无 CORS 限制
this顶层 thisundefined顶层 thiswindow
// module 脚本的独立作用域
// fileA.ts (type="module")
const name = 'Alice';
export { name };
// name 不会泄漏到全局

// fileB.ts (type="module")
const name = 'Bob'; // 不冲突,各自独立作用域
import { name as nameA } from './fileA.js';
console.log(nameA); // 'Alice'

// 普通 defer 脚本
// scriptA.js (defer)
var name = 'Alice'; // 挂到 window.name

// scriptB.js (defer)
console.log(name); // 'Alice'(共享全局作用域)

行为差异详解

<!-- 1. module 默认 defer,无需显式声明 -->
<script type="module" src="app.js"></script>
<!-- 等价于 -->
<script defer src="app.js"></script>

<!-- 2. module 可以加 async 变成异步执行 -->
<script type="module" async src="app.js"></script>

<!-- 3. module 的 CORS 要求 -->
<!-- ❌ 跨域 module 脚本没有 CORS 头会报错 -->
<script type="module" src="https://other-domain.com/lib.js"></script>
<!-- ✅ 需要服务端返回 Access-Control-Allow-Origin -->

<!-- 4. module 同一 URL 只执行一次 -->
<script type="module" src="init.js"></script>
<script type="module" src="init.js"></script>
<!-- init.js 只会执行一次 -->

<!-- 普通脚本每次都执行 -->
<script defer src="init.js"></script>
<script defer src="init.js"></script>
<!-- init.js 会执行两次 -->
内联 module 脚本

内联的 <script type="module"> 也是 defer 行为(等 HTML 解析完才执行),这和内联普通脚本不同:

<!-- 内联普通脚本:立即执行,阻塞解析 -->
<script>console.log('立即执行');</script>

<!-- 内联 module 脚本:延迟执行,不阻塞解析 -->
<script type="module">console.log('HTML 解析完后执行');</script>

Q6: 动态创建 script 标签加载脚本,默认是 async 还是 sync?如何控制加载顺序?

答案

通过 document.createElement('script') 动态创建的脚本标签,默认 async = true,即异步下载并在下载完成后立即执行,不保证顺序。

// 动态脚本默认是 async 行为
const script = document.createElement('script');
script.src = 'app.js';
console.log(script.async); // true ← 默认值

document.head.appendChild(script);
// 脚本异步下载,下载完立即执行

设置 async = false 保证顺序执行

// 多个脚本需要按顺序执行时
function loadScriptsInOrder(urls: string[]): void {
urls.forEach((url) => {
const script = document.createElement('script');
script.src = url;
script.async = false; // 关键:关闭 async,按 DOM 插入顺序执行
document.head.appendChild(script);
});
}

// vendor.js 会在 app.js 之前执行
loadScriptsInOrder(['/vendor.js', '/app.js']);

Promise 封装脚本加载器

function loadScript(url: string): Promise<void> {
return 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);
});
}

// 串行加载(保证顺序)
async function loadScriptsSerial(urls: string[]): Promise<void> {
for (const url of urls) {
await loadScript(url);
}
}

// 并行加载(不保证顺序,但更快)
async function loadScriptsParallel(urls: string[]): Promise<void> {
await Promise.all(urls.map(loadScript));
}

// 使用示例
async function initApp(): Promise<void> {
// 依赖库必须按顺序加载
await loadScriptsSerial([
'https://cdn.example.com/react.production.min.js',
'https://cdn.example.com/react-dom.production.min.js',
]);

// 独立模块可以并行加载
await loadScriptsParallel([
'/modules/analytics.js',
'/modules/chat-widget.js',
]);

console.log('所有脚本加载完成');
}

三种动态加载方式对比

方式默认行为执行顺序适用场景
createElement + async = true(默认)异步下载,下载完即执行不确定独立脚本(统计、广告)
createElement + async = false异步下载,按插入顺序执行确定有依赖关系的多个脚本
createElement + onload + Promise异步下载,手动控制执行时机完全可控需要精确控制加载流程
注意事项
  • async = false 只有在脚本被插入 DOM 之前设置才有效
  • 动态脚本不会触发 DOMContentLoaded 等待,它们独立于文档解析流程
  • 如果需要在加载完成后执行回调,务必使用 onload 事件而非假设脚本已执行

最佳实践

1. 使用 defer 加载主要脚本

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

2. 使用 async 加载独立的第三方脚本

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

3. 内联关键脚本

首屏渲染需要的关键代码可以内联:

<head>
<script>
// 关键的初始化代码
window.__CONFIG__ = { /* ... */ };
</script>
</head>

4. 预加载重要资源

<head>
<link rel="preload" href="critical.js" as="script">
<script defer src="critical.js"></script>
</head>

浏览器兼容性

属性ChromeFirefoxSafariEdgeIE
async10+
defer10+
兼容性说明
  • IE9 的 defer 实现有 bug,执行顺序可能不正确
  • 现代浏览器都完全支持

相关链接