script 标签的加载机制:前端查漏补缺
Category(分类): JavaScript Status: 已整理(2026)
前言
现代项目通常由 Vite、Webpack、Rollup 等工具生成脚本,但浏览器最终仍然要按照 HTML 和 <script> 的规则获取、解析、执行 JavaScript。理解这些规则有助于解释首屏空白、脚本依赖、defer/async、模块脚本和页面生命周期事件。
原文提出了几个很实用的问题:
script放在head和body有什么区别?defer、async有什么作用?- 多个脚本的下载时间不同,会不会影响执行顺序?
DOMContentLoaded和load分别等待什么?- 动态加载脚本时怎样监听完成?
本文保留原文通过延迟 HTTP 响应观察加载过程的思路,但将抓取后拼接在一起的代码恢复为可运行示例,并补充模块脚本、动态脚本、渲染阻塞和现代浏览器语义。
一、原文的延迟响应演示
原文使用 Koa 服务端模拟 /js/test1.js 等资源延迟返回。以下是整理后的示例,运行需要额外安装 koa 和 koa-static,并且只是观察加载顺序,不是生产服务器配置:
// server.cjs
const path = require('node:path')
const Koa = require('koa')
const serve = require('koa-static')
const app = new Koa()
function lazyLoadScript(requestPath, time = 3000) {
if (!/^\/js\/test\d+\.js$/.test(requestPath)) {
return Promise.resolve()
}
return new Promise(resolve => {
console.log(`拦截 ${requestPath},${time} 毫秒后返回`)
setTimeout(() => {
console.log(`响应 ${requestPath}`)
resolve()
}, time)
})
}
app.use(async (ctx, next) => {
await lazyLoadScript(ctx.path)
await next()
})
app.use(serve(path.join(__dirname, 'public')))
app.listen(3000, () => console.log('http://localhost:3000'))
基础 HTML:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>script 加载机制</title>
</head>
<body>
<h2>script 加载机制</h2>
</body>
</html>
二、脚本放在页面的什么位置
2.1 放在 head 中的普通 classic script
<head>
<meta charset="utf-8">
<title>script 加载机制</title>
<script src="/js/test1.js"></script>
<script src="/js/test2.js"></script>
<script src="/js/test3.js"></script>
</head>
当 HTML 解析器遇到没有 async、defer 或 type="module" 的外部 classic script 时,解析通常会暂停,浏览器获取并执行脚本后才继续解析。脚本执行本身也可能阻塞渲染,因此把大型业务脚本放在 head 往往容易造成可见内容迟迟出现。

图片来源:原文配图。
“脚本会阻塞页面渲染”是入门级概括。更精确地说,普通 parser-inserted classic script 会阻塞 HTML 解析;解析被阻塞通常也会推迟后续内容可用和渲染。浏览器还可能通过 preload scanner 提前请求后面的资源,因此不能把网络请求时间简单相加。
2.2 放在 body 底部
<body>
<h2>script 加载机制</h2>
<script src="/js/test1.js"></script>
<script src="/js/test2.js"></script>
<script src="/js/test3.js"></script>
</body>

图片来源:原文配图。
此时大部分 body 已经被解析,用户通常可以先看到页面骨架,再等待脚本。不过脚本在它所在的位置仍会暂停后续解析;如果脚本很大,仍然可能影响页面交互和绘制。
把脚本放在 body 底部是历史上常见的优化方式,但现代项目更常使用 defer、模块脚本或构建工具生成的合适加载策略。无论放在哪里,如果脚本需要操作 DOM,都应确保目标元素已经存在,或者使用合适的生命周期时机。
三、多个脚本的加载和执行顺序
普通 classic script 通常按照文档顺序执行:
<script src="/js/test1.js"></script>
<script src="/js/test2.js"></script>
<script src="/js/test3.js"></script>
// test1.js
console.log('test1')
// test2.js
console.log('test2')
// test3.js
console.log('test3')
输出通常是:
test1
test2
test3
即使后面的响应更早完成,浏览器也不能让普通 parser-inserted classic script 打破文档顺序执行。浏览器可能提前发现并并行请求资源,但执行仍要遵守依赖和顺序要求。
原文通过不同响应延迟模拟这个过程:
function responseDelay(requestPath) {
if (requestPath === '/js/test1.js') return 3 * 1000
if (requestPath === '/js/test2.js') return 2 * 1000
return 1 * 1000
}
app.use(async (ctx, next) => {
await lazyLoadScript(ctx.path, responseDelay(ctx.path))
await next()
})
如果三个脚本都是普通 classic script,常见结果仍是 test1、test2、test3。但“最终只等待 3 秒而不是 3+2+1 秒”不是所有浏览器、缓存和服务器实现都能保证的定律;它取决于请求是否并行、浏览器预加载、连接复用和响应时机。应该把“执行有序”和“网络请求是否并行”分开讨论。

图片来源:原文配图。
四、defer 和 async
defer 和 async 都能让外部脚本在 HTML 解析期间获取,但执行时机和顺序完全不同。它们只对有 src 的 classic script 有主要意义;内联 classic script 上的 defer 没有效果。
4.1 defer
<head>
<script defer src="/js/test1.js"></script>
<script defer src="/js/test2.js"></script>
<script defer src="/js/test3.js"></script>
</head>
对 classic external script 而言,defer 的要点是:
- 脚本可以与 HTML 解析并行获取;
- HTML 文档解析完成后执行;
- 多个
defer脚本按文档顺序执行; - 执行完成后,浏览器才会触发
DOMContentLoaded; - 在执行时 DOM 已经完成解析,但外部图片不一定已经加载完成。

图片来源:原文配图。
4.2 async
<script async src="/js/analytics.js"></script>
<script async src="/js/metrics.js"></script>
<script async src="/js/ads.js"></script>
对 classic external script 而言,async 的要点是:
- 下载可以与 HTML 解析并行;
- 哪个脚本先下载完成,哪个脚本先执行;
- 执行期间可能短暂暂停 HTML 解析和绘制;
- 多个 async 脚本没有可靠的相互执行顺序;
- async 脚本不应依赖另一个 async 脚本已经执行;
- async 脚本通常不会阻塞
DOMContentLoaded,但可能影响页面的load时机。
原文曾写成“document 全部加载完后才执行 async”,这是错误的。async 脚本会在自己准备好后尽快执行,可能早于 DOM 解析完成,也可能晚于 DOMContentLoaded(如果它下载得很慢)。

图片来源:原文配图。
如果 test1.js 定义全局变量,而 test3.js 依赖它,使用 async 可能出现未定义:
// test1.js
var globalNumber = 1
// test3.js
console.log(globalNumber)
这类有依赖关系的脚本应使用有序的 defer,或合并为模块依赖图;只有相互独立的统计、广告、监控等脚本才适合考虑 async。
4.3 defer、async、模块脚本对照
| 方式 | 获取期间是否继续解析 | 执行顺序 | 是否通常等待 DOM 解析 | 对 DOMContentLoaded 的影响 |
|---|---|---|---|---|
| 普通 classic external | 否,解析会在脚本处暂停 | 文档顺序 | 在执行前不一定 | 会推迟 |
defer classic external | 是 | 文档顺序 | 是 | 等脚本执行完 |
async classic external | 是 | 下载完成顺序 | 不保证 | 通常不等待 |
type="module" | 是 | 按模块依赖图 | 默认类似 defer | 模块评估完成后 |
type="module" async | 是 | 准备好后尽快 | 不保证 | 通常不等待 |
模块脚本默认延迟执行,defer 对模块脚本没有额外作用;但模块的依赖仍会被获取和评估。模块脚本还需要正确的 JavaScript MIME 类型,跨源加载需满足 CORS。
五、DOMContentLoaded 和 window.load
原文用 jQuery 的 document.ready 表示 DOM 就绪。document.ready 不是浏览器原生事件;原生代码应使用 DOMContentLoaded:
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM fully loaded and parsed')
})
window.addEventListener('load', () => {
console.log('page resources loaded')
})
5.1 DOMContentLoaded
DOMContentLoaded 在 HTML 文档完成解析,并且需要等待的 deferred/module 脚本执行完成后触发。它不等待普通图片、视频、iframe 等外部资源全部加载完成。
需要注意两个容易混淆的细节:
- CSS 通常不会直接让
DOMContentLoaded等待,但 parser-blocking script 可能等待此前尚未加载完的样式表,而该脚本又会间接推迟 DOM 解析和DOMContentLoaded; defer和普通模块脚本会推迟DOMContentLoaded,async 脚本一般不会;模块中的顶层await也可能延迟模块评估和事件时机。
一个同步脚本中的长循环也会延迟 DOM 解析和 DOMContentLoaded:
<script>
document.addEventListener('DOMContentLoaded', () => {
console.log('DOMContentLoaded')
})
for (let index = 0; index < 100000000; index += 1) {
// 模拟长时间同步任务
}
</script>

图片来源:原文配图。
如果脚本可能以 async、动态注入或动态 import 的方式运行,注册监听时要处理事件已经发生的情况:
function init() {
console.log('safe to initialize DOM-dependent code')
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true })
} else {
init()
}
5.2 window.load
页面主文档的 load 事件通常在页面及其依赖资源加载完成后触发,包括样式表、脚本、图片和 iframe;懒加载资源等例外不应简单理解为一定等待。它也不冒泡,使用 window.addEventListener('load', handler) 更适合添加多个监听者。
window.addEventListener('load', () => {
const image = document.querySelector('img')
console.log('image natural width:', image.naturalWidth)
})

图片来源:原文配图。
实践中:
- 只需要操作已解析的 DOM 时,优先使用脚本放在 body 底部、
defer、模块脚本或DOMContentLoaded; - 需要图片尺寸、字体或其他外部资源就绪时,才使用
load; - 不要为了等待 DOM 而无条件等待整页所有资源,可能让交互初始化变慢。
5.3 async 对生命周期事件的影响
<script>
document.addEventListener('DOMContentLoaded', () => {
console.log('DOMContentLoaded')
})
window.addEventListener('load', () => {
console.log('load')
})
</script>
<script async src="/js/test1.js"></script>
<script async src="/js/test2.js"></script>
<script async src="/js/test3.js"></script>
DOMContentLoaded 不会为了等待 async 脚本而停留;脚本下载完成后会自行执行。load 的资源等待关系更广,因此具体输出顺序要结合下载速度和脚本是否在初始文档中。不要根据某一次网络环境的截图推导绝对事件顺序。
六、动态加载脚本
动态插入的 classic script 不会像解析器遇到的普通脚本那样阻塞 HTML 解析。动态外部脚本默认具有 async 行为;如果多个动态脚本之间有顺序依赖,可以在插入前设置 async = false:
function loadScript(url, { ordered = false } = {}) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = url
script.async = !ordered
script.onload = () => resolve(script)
script.onerror = () => reject(new Error(`加载失败:${url}`))
document.head.appendChild(script)
})
}
loadScript('/js/test1.js')
.then(() => console.log('test1 loaded'))
.catch(console.error)
多个脚本按顺序加载执行:
async function loadInOrder(urls) {
for (const url of urls) {
await loadScript(url, { ordered: true })
}
}
loadInOrder(['/js/test1.js', '/js/test2.js']).catch(console.error)
如果脚本只需要独立加载,默认 async 可以更早执行;如果脚本依赖 DOM 或其他脚本,模块静态依赖、defer 或显式顺序加载通常更清晰。动态脚本还要考虑 CSP、SRI、跨源响应和错误处理,不能只监听已废弃的 IE readyState:
const script = document.createElement('script')
script.src = '/js/analytics.js'
script.onload = () => console.log('loaded')
script.onerror = () => console.error('failed')
document.head.appendChild(script)
七、结论
- 普通 classic script 在解析到的位置可能暂停 HTML 解析和脚本执行;
- 放在 body 底部能让前面的内容先被解析,但不是所有性能问题的解决方案;
defer适合有依赖关系、需要 DOM 的外部脚本,按文档顺序执行并等待DOMContentLoaded;async适合互不依赖的脚本,按下载完成顺序执行;- 模块脚本默认延迟,
async模块则会失去常规的依赖等待时机; DOMContentLoaded关注 DOM 解析和 deferred/module 脚本,load还要等待更广泛的页面资源;- 动态脚本默认 async,可以监听
load/error,按需设置async = false或串行等待; - “资源请求并行”和“脚本执行有序”是两个不同问题,必须分开验证。