在现代前端开发中,<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)实现懒加载的标准方式。

八、浏览器解析机制与关键事件

理解浏览器的解析顺序有助于精确控制脚本:

  1. 预扫描(Preload Scanner):浏览器并行扫描 HTML,找到需要下载的资源(图片、CSS、脚本)并提前发起请求。

  2. 构建 DOM 树:解析 HTML 为节点,普通脚本会阻塞此过程。

  3. 构建 CSSOM 树:CSS 不阻塞 DOM 解析,但会阻塞 脚本执行 (因为脚本可能读取样式)。

  4. 执行脚本:根据 async/defer/普通规则执行。

  5. 触发事件

  • 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> 元素的设计虽然悠久,但在现代浏览器中已演化出一套精细的控制体系。合理利用这些机制,可以显著改善页面的首屏性能与交互流畅度。

核心建议

  1. 优先使用 <script defer> 并放在 <head> 中 —— 既利用浏览器的早期下载,又不阻塞渲染,同时保证 DOM 就绪和正确的执行顺序。这是目前最通用、最安全的实践。

  2. 第三方独立脚本(分析、广告)使用 async,防止它们影响页面主要内容。

  3. 新项目全面拥抱 ES 模块 (type="module"),享受默认延迟执行、作用域隔离和现代化语法。

  4. 对于关键的上述脚本,可配合 preload 或 modulepreload 进一步缩短加载时间。

  5. 动态加载非首屏逻辑(通过动态 import() 或创建 <script> 标签),减少初始传输体积。

  6. 始终为外部 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 中出现的顺序执行

关键机制

  1. 不阻塞解析:defer 脚本下载时,HTML 解析继续进行。

  2. 等待 DOM:确保脚本执行时 DOM 已完整构建。

  3. 顺序保证:多个 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. 为什么这样设计?

设计意图

  1. 保证脚本获取完整 DOM:defer 脚本执行时,DOM 已完整构建,可以安全地操作 DOM。

  2. 保证脚本执行顺序:多个 defer 脚本按顺序执行,避免依赖问题。

  3. 优化加载性能:脚本下载与 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 树。