在现代前端开发中,<script> 标签是连接 HTML 结构与 JavaScript 行为的核心通道。然而,它的放置位置、加载方式以及执行时机,对页面渲染性能、用户交互体验乃至代码可维护性都有着深远影响。许多开发者习惯于“把脚本放在页面底部”,但如今 async、defer、ES 模块以及更精细的资源加载策略已提供了更优解。
本文将从基础到进阶,系统梳理 <script> 元素的最佳实践,帮助你构建更快、更可靠的网页。
一、基础回顾:内联脚本与外部脚本
<script> 元素有两种基本使用形式:
示例:
<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>
<script defer src="jquery.js"></script><script defer src="plugin.js"></script><script defer src="app.js"></script>
现代实践建议:
四、ES 模块:type="module" 与 nomodule
现代浏览器原生支持 ES 模块(ESM),它们默认具备 defer 的行为,并带来模块化作用域、严格模式、import/export 等特性。
<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"><script defer src="critical.js"></script>
<link rel="modulepreload" href="main.js"><script type="module" src="main.js"></script>
使用原则:
七、动态脚本加载与懒加载
通过 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/普通规则执行。
触发事件:
<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 不需要等待:
需要等待的资源
触发 DOMContentLoaded 必须等待:
4. 实际示例
<!DOCTYPE html><html><head> <link rel="stylesheet" href="style.css"> <script defer src="a.js"></script> <script defer src="b.js"></script> <script async src="analytics.js"></script></head><body> <img src="large-image.jpg"> <script> document.addEventListener('DOMContentLoaded', function() { 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 解析并行,提升首屏加载速度。
典型应用场景
<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 树。
阅读原文:https://mp.weixin.qq.com/s/sSRcfacmIbivUR-n5JxzgQ
该文章在 2026/5/16 8:48:43 编辑过