在现代前端开发中,<script> 标签是连接 HTML 结构与 JavaScript 行为的核心通道。然而,它的放置位置、加载方式以及执行时机,对页面渲染性能、用户交互体验乃至代码可维护性都有着深远影响。许多开发者习惯于“把脚本放在页面底部”,但如今 async、defer、ES 模块以及更精细的资源加载策略已提供了更优解。本文将从基础到进阶,系统梳理 <script> 元素的最佳实践,帮助你构建更快、更可靠的网页。
一、基础回顾:内联脚本与外部脚本
<script> 元素有两种基本使用形式:
内联脚本:直接嵌入 JavaScript 代码,适合页面级别的小逻辑或初始数据。
外部脚本:通过 src 属性引用独立的 .js 文件,推荐用于可缓存、可复用的逻辑。
示例:
<!-- 内联脚本 -->
<script>
window.APP_CONFIG = { apiUrl: '/api' };
</script><!-- 外联脚本 -->
<script src="/js/main.js"></script>注意:当 src 被指定时,标签内的任何脚本内容都会被忽略。
二、脚本位置如何影响渲染与 DOM 访问
1. 放置在 <head> 中(传统但需谨慎)
<!DOCTYPE html>
<html>
<head>
<script src="critical.js"></script>
</head>
<body>...</body>
</html>阻塞渲染:浏览器必须下载并执行完脚本,才能继续解析 <body> 内容。
无法直接访问 DOM:此时 <body> 尚未解析,操作 DOM 元素会返回 null。
适用场景:必须在页面任何内容展示前运行的脚本,例如 polyfill、document.write、性能打点(需尽早)、配置对象。
2. 放置在 <body> 末尾(传统最佳实践)
<body>
<!-- 所有页面内容 -->
<script src="app.js"></script>
</body>不阻塞渲染:用户先看到页面内容,再获取并执行脚本。
可安全访问 DOM:脚本执行时,所有元素均已解析完成。
局限:脚本下载和执行的开始时间被推迟,如果脚本较大且网络慢,可能影响后续交互的响应速度。
为了解决“头部阻塞”与“底部延迟”之间的矛盾,HTML5 引入了 async 和 defer。
三、异步加载的核心武器:async 与 defer
两者都让浏览器在 不阻塞 HTML 解析 的前提下下载脚本。主要区别在于 执行时机 和 顺序保证 。
特性 | 普通脚本(头部) | async | defer |
|---|---|---|---|
下载时机 | 遇到立即下载,阻塞解析 | 立即异步下载 | 立即异步下载 |
执行时机 | 下载后立即执行,阻塞解析 | 下载完成后立即执行(可能阻塞解析) | HTML 解析完成后、DOMContentLoaded 前执行 |
执行顺序 | 按照文档顺序 | 不保证顺序(谁先下载完谁执行) | 保证顺序 |
DOM 可访问 | 否(位于头部时) | 不确定(取决于执行时 DOM 是否解析完) | 能 |
执行时序示意
普通脚本:
┌─────────────────────────────────────────┐
│ HTML解析 → 遇到<script> → 停止解析 → 下载 → 执行 → 继续解析 │
└─────────────────────────────────────────┘
async:
┌─────────────────────────────────────────┐
│ HTML解析 → 遇到<script> → 并行下载 → 下载完成 → 暂停解析 │
│ → 执行脚本 → 继续解析 │
└─────────────────────────────────────────┘
defer:
┌─────────────────────────────────────────┐
│ HTML解析 → 遇到<script> → 并行下载 → 解析完成 → 执行脚本 │
└─────────────────────────────────────────┘代码示例
<!-- 顺序不确定,适合完全独立的第三方脚本(统计、广告) -->
<script async src="https://www.google-analytics.com/analytics.js"></script>
<!-- 保证顺序,适合依赖 DOM 且相互依赖的脚本 -->
<script defer src="jquery.js"></script>
<script defer src="plugin.js"></script>
<script defer src="app.js"></script>现代实践建议:
默认使用 defer:既能尽早下载,又不阻塞渲染,还能保证执行顺序和 DOM 就绪,是多数业务脚本的“安全牌”。
仅用 async:当脚本完全自包含,不依赖其他脚本、也不被其他脚本依赖时(如埋点、A/B 测试)。
四、ES 模块:type="module" 与 nomodule
现代浏览器原生支持 ES 模块(ESM),它们默认具备 defer 的行为,并带来模块化作用域、严格模式、import/export 等特性。
<!-- 自动延迟执行,等价于 defer -->
<script type="module" src="main.js"></script>
<!-- 内联模块也支持导入 -->
<script type="module">
import { formatDate } from './utils.js';
console.log(formatDate(new Date()));
</script>
<!-- 为旧浏览器提供回退 -->
<script nomodule src="legacy.js"></script>关键特性:
默认启用严格模式,变量不会泄漏到全局。
作用域隔离,每个模块拥有独立的作用域。
默认支持 async 属性(加上后将改变执行顺序,不保证顺序)。
模块脚本的跨域请求需遵循 CORS 策略(非同源时需配置 CORS)。
nomodule 属性可被支持 ESM 的浏览器忽略,用于优雅降级。
五、进阶属性与安全增强
属性 | 作用 | 示例 |
|---|---|---|
crossorigin | 控制跨域请求是否携带凭证,常用于 CDN 脚本的错误捕获 | <script src="https://cdn.com/lib.js" crossorigin="anonymous"></script> |
integrity | 子资源完整性(SRI),防止 CDN 资源被篡改。需配合 crossorigin | <script src="https://cdn.com/lib.js" integrity="sha384-..." crossorigin="anonymous"></script> |
referrerpolicy | 控制请求中 Referer 头的发送行为 | <script referrerpolicy="origin" src="..."></script> |
onload/onerror | 监听脚本加载成功或失败 | <script src="app.js" onload="init()" onerror="fallback()"></script> |
完整性校验示例
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous"></script>SRI 要求资源支持 CORS,因此 crossorigin 不能省略。
SRI(Subresource Integrity 子资源完整性)是一种安全验证机制,用于确保浏览器加载的外部资源(如 JavaScript、CSS 文件)在传输过程中没有被篡改。
六、现代资源提示:preload 与 modulepreload
除了 async/defer,你可以在 <head> 中使用 <link rel="preload"> 来 提前加载 脚本,但不执行它,从而进一步缩短加载时间。
<!-- 预加载一个普通脚本,但未指定执行策略 -->
<link rel="preload" href="critical.js" as="script">
<!-- 之后再用 defer 或 async 引用并执行 -->
<script defer src="critical.js"></script>
<!-- 针对 ES 模块的专用预加载 -->
<link rel="modulepreload" href="main.js">
<script type="module" src="main.js"></script>使用原则:
仅对 关键脚本(如首屏必需的交互逻辑)使用 preload,过度使用会抢占带宽。
modulepreload 会同时预加载模块的依赖关系图,适用于深层依赖的模块树。
七、动态脚本加载与懒加载
通过 JavaScript 动态创建 <script> 可以实现按需加载,减少初始负载。
方式一:传统 DOM 操作
<button id="loadChart">加载图表库</button>
<script>
document.getElementById('loadChart').addEventListener('click', () => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0';
script.onload = () => new Chart(...);
document.head.appendChild(script);
});
</script>方式二:动态 import()(推荐)
<script type="module">
document.getElementById('loadModule').onclick = async () => {
const module = await import('./heavy-module.js');
module.doSomething();
};
</script>动态 import() 返回 Promise,支持代码分割和按需加载,是现代构建工具(如 webpack、Vite)实现懒加载的标准方式。
八、浏览器解析机制与关键事件
理解浏览器的解析顺序有助于精确控制脚本:
预扫描(Preload Scanner):浏览器并行扫描 HTML,找到需要下载的资源(图片、CSS、脚本)并提前发起请求。
构建 DOM 树:解析 HTML 为节点,普通脚本会阻塞此过程。
构建 CSSOM 树:CSS 不阻塞 DOM 解析,但会阻塞 脚本执行 (因为脚本可能读取样式)。
执行脚本:根据 async/defer/普通规则执行。
触发事件:
DOMContentLoaded:DOM 解析完成,defer 脚本执行完毕(不等待 async、图片、样式表等)。
load:所有资源(包括图片、样式、async 脚本)加载完成。
<script>
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM 就绪,defer 已执行');
});
window.addEventListener('load', () => {
console.log('所有资源加载完毕');
});
</script>九、综合对比一览表
放置方式 | 阻塞渲染 | DOM 可访问性 | 执行顺序 | 推荐场景 |
|---|---|---|---|---|
<head> 普通 | 是 | 否 | 按顺序 | 须立即执行的 polyfill、配置 |
<body> 末尾 | 否 | 能 | 按顺序 | 传统兼容方案,无异步需求 |
<head> + async | 否(执行时短暂阻塞) | 不确定 | 不保证 | 独立第三方脚本(统计、广告) |
<head> + defer | 否 | 能 | 按顺序 | 现代默认选择,几乎所有业务脚本 |
<head> + type="module" | 否 | 能 | 按顺序(默认 defer) | 模块化项目(推荐) |
preload + defer | 否 | 能 | 按顺序 | 关键脚本的加载提速 |
总结
<script> 元素的设计虽然悠久,但在现代浏览器中已演化出一套精细的控制体系。合理利用这些机制,可以显著改善页面的首屏性能与交互流畅度。
核心建议:
优先使用 <script defer> 并放在 <head> 中 —— 既利用浏览器的早期下载,又不阻塞渲染,同时保证 DOM 就绪和正确的执行顺序。这是目前最通用、最安全的实践。
第三方独立脚本(分析、广告)使用 async,防止它们影响页面主要内容。
新项目全面拥抱 ES 模块 (type="module"),享受默认延迟执行、作用域隔离和现代化语法。
对于关键的上述脚本,可配合 preload 或 modulepreload 进一步缩短加载时间。
动态加载非首屏逻辑(通过动态 import() 或创建 <script> 标签),减少初始传输体积。
始终为外部 CDN 脚本配置 integrity 与 crossorigin,保障资源安全。
通过以上策略,你的网页将获得更快的可交互时间(TTI)、更流畅的渲染过程,以及更健壮的代码结构。
一句话总结:日常用 defer,独立第三方用 async,模块化用 type="module",关键资源可 preload;动态懒加载是锦上添花,安全加固不可少。
知识扩展:
"defer脚本执行完毕" 的含义
"defer脚本执行完毕" 指的是:所有带有 defer 属性的脚本必须在 DOMContentLoaded 事件触发之前执行完成。
1. 执行顺序图
HTML 解析开始
│
▼
遇到 <script defer src="a.js"> ──→ 开始并行下载 a.js
│
▼
遇到 <script defer src="b.js"> ──→ 开始并行下载 b.js
│
▼
HTML 解析完成
│
▼
执行 defer 脚本(按顺序): a.js → b.js
│
▼
触发 DOMContentLoaded 事件2. 为什么 defer 脚本会在此时执行?
defer 的特性回顾
特性 | 说明 |
|---|---|
下载时机 | 立即开始下载(与 HTML 解析并行) |
执行时机 | 等待 HTML 解析完成后 |
执行顺序 | 按在 HTML 中出现的顺序执行 |
关键机制
不阻塞解析:defer 脚本下载时,HTML 解析继续进行。
等待 DOM:确保脚本执行时 DOM 已完整构建。
顺序保证:多个 defer 脚本按顺序执行,保证依赖关系。
3. DOMContentLoaded 的触发条件
DOMContentLoaded 触发 = HTML解析完成 + defer脚本执行完毕不等待的资源
触发 DOMContentLoaded 不需要等待:
async 脚本(可能还在下载或执行)。
图片(<img>)。
样式表(<link rel="stylesheet">)。
子框架(<iframe>)。
需要等待的资源
触发 DOMContentLoaded 必须等待:
所有 defer 脚本执行完毕。
4. 实际示例
<!DOCTYPE html>
<html>
<head>
<!-- 样式表:不阻塞 DOMContentLoaded -->
<link rel="stylesheet" href="style.css">
<!-- defer 脚本:会在 DOMContentLoaded 前执行 -->
<script defer src="a.js"></script>
<script defer src="b.js"></script>
<!-- async 脚本:不等待 -->
<script async src="analytics.js"></script>
</head>
<body>
<img src="large-image.jpg"> <!-- 图片:不等待 -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// 此时:
// ✓ DOM 已完整解析
// ✓ a.js 和 b.js 已执行完毕
// ✗ analytics.js 可能还未执行
// ✗ large-image.jpg 可能还未加载完成
console.log('DOM ready!');
});
</script>
</body>
</html>执行时序
时间轴 →
─────────────────────────────────────────────────────────────
解析HTML ████████████████████████████████████████
下载a.js ███████████████
下载b.js ███████████████
下载样式表 █████████████████
下载图片 ████████████████████████
下载async ███████████████
─────────────────────────────────────────────────────────────
执行a.js ██
执行b.js ██
DOMContentLoaded触发 ▼
执行async ██
图片加载完成 ██5. 为什么这样设计?
设计意图
保证脚本获取完整 DOM:defer 脚本执行时,DOM 已完整构建,可以安全地操作 DOM。
保证脚本执行顺序:多个 defer 脚本按顺序执行,避免依赖问题。
优化加载性能:脚本下载与 HTML 解析并行,提升首屏加载速度。
典型应用场景
<!-- 依赖 DOM 的初始化脚本 -->
<script defer src="app.js"></script>
<!-- 需要按顺序执行的脚本 -->
<script defer src="utils.js"></script>
<script defer src="main.js"></script>6. 小结
资源类型 | 是否等待 | 说明 |
|---|---|---|
defer 脚本 | 必须等待 | 执行完毕后才触发 DOMContentLoaded |
async 脚本 | 不等待 | 可能在事件触发前或后执行 |
样式表 | 不等待 | 仅阻塞后续脚本执行 |
图片 | 不等待 | 不影响 DOMContentLoaded |
核心要点:defer 脚本的设计目的就是在 DOM 构建完成后、DOMContentLoaded 触发前执行,确保脚本能安全地访问和操作完整的 DOM 树。