技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

script 标签的加载机制:前端查漏补缺

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/56-script 标签的加载机制 前端查漏补缺
本文目录9 个章节
  1. 前言
  2. 一、原文的延迟响应演示
  3. 二、脚本放在页面的什么位置
  4. 三、多个脚本的加载和执行顺序
  5. 四、defer 和 async
  6. 五、DOMContentLoaded 和 window.load
  7. 六、动态加载脚本
  8. 七、结论
  9. 参考资料

script 标签的加载机制:前端查漏补缺

Category(分类): JavaScript Status: 已整理(2026)

前言

现代项目通常由 Vite、Webpack、Rollup 等工具生成脚本,但浏览器最终仍然要按照 HTML 和 <script> 的规则获取、解析、执行 JavaScript。理解这些规则有助于解释首屏空白、脚本依赖、defer/async、模块脚本和页面生命周期事件。

原文提出了几个很实用的问题:

  • script 放在 headbody 有什么区别?
  • deferasync 有什么作用?
  • 多个脚本的下载时间不同,会不会影响执行顺序?
  • DOMContentLoadedload 分别等待什么?
  • 动态加载脚本时怎样监听完成?

本文保留原文通过延迟 HTTP 响应观察加载过程的思路,但将抓取后拼接在一起的代码恢复为可运行示例,并补充模块脚本、动态脚本、渲染阻塞和现代浏览器语义。

一、原文的延迟响应演示

原文使用 Koa 服务端模拟 /js/test1.js 等资源延迟返回。以下是整理后的示例,运行需要额外安装 koakoa-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 解析器遇到没有 asyncdefertype="module" 的外部 classic script 时,解析通常会暂停,浏览器获取并执行脚本后才继续解析。脚本执行本身也可能阻塞渲染,因此把大型业务脚本放在 head 往往容易造成可见内容迟迟出现。

普通 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>

普通 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,常见结果仍是 test1test2test3。但“最终只等待 3 秒而不是 3+2+1 秒”不是所有浏览器、缓存和服务器实现都能保证的定律;它取决于请求是否并行、浏览器预加载、连接复用和响应时机。应该把“执行有序”和“网络请求是否并行”分开讨论。

多个普通 script 的执行顺序示意

图片来源:原文配图

四、deferasync

deferasync 都能让外部脚本在 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 已经完成解析,但外部图片不一定已经加载完成。

defer 脚本的加载示意

图片来源:原文配图

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(如果它下载得很慢)。

async 脚本的加载示意

图片来源:原文配图

如果 test1.js 定义全局变量,而 test3.js 依赖它,使用 async 可能出现未定义:

// test1.js
var globalNumber = 1
// test3.js
console.log(globalNumber)

这类有依赖关系的脚本应使用有序的 defer,或合并为模块依赖图;只有相互独立的统计、广告、监控等脚本才适合考虑 async。

4.3 deferasync、模块脚本对照

方式获取期间是否继续解析执行顺序是否通常等待 DOM 解析DOMContentLoaded 的影响
普通 classic external否,解析会在脚本处暂停文档顺序在执行前不一定会推迟
defer classic external文档顺序等脚本执行完
async classic external下载完成顺序不保证通常不等待
type="module"按模块依赖图默认类似 defer模块评估完成后
type="module" async准备好后尽快不保证通常不等待

模块脚本默认延迟执行,defer 对模块脚本没有额外作用;但模块的依赖仍会被获取和评估。模块脚本还需要正确的 JavaScript MIME 类型,跨源加载需满足 CORS。

五、DOMContentLoadedwindow.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>

DOMContentLoaded 与脚本加载顺序示意

图片来源:原文配图

如果脚本可能以 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)
})

图片加载完成后触发 load 的示意

图片来源:原文配图

实践中:

  • 只需要操作已解析的 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)

七、结论

  1. 普通 classic script 在解析到的位置可能暂停 HTML 解析和脚本执行;
  2. 放在 body 底部能让前面的内容先被解析,但不是所有性能问题的解决方案;
  3. defer 适合有依赖关系、需要 DOM 的外部脚本,按文档顺序执行并等待 DOMContentLoaded
  4. async 适合互不依赖的脚本,按下载完成顺序执行;
  5. 模块脚本默认延迟,async 模块则会失去常规的依赖等待时机;
  6. DOMContentLoaded 关注 DOM 解析和 deferred/module 脚本,load 还要等待更广泛的页面资源;
  7. 动态脚本默认 async,可以监听 load/error,按需设置 async = false 或串行等待;
  8. “资源请求并行”和“脚本执行有序”是两个不同问题,必须分开验证。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS