JavaScript 加载机制:同步/异步加载与页面渲染阻塞
问题解析
理解 JavaScript 的加载机制是前端性能优化的基础。浏览器在解析 HTML 时,遇到 <script> 标签会有不同的处理策略,这直接影响页面的加载速度和用户体验。
核心概念
浏览器渲染流程
浏览器加载页面的基本流程:
下载 HTML → 解析 HTML(构建 DOM) → 下载 CSS → 构建 CSSOM → 合并成渲染树 → 布局 → 绘制
关键点:HTML 解析和 JS 执行共用同一个主线程,因此 JS 的加载和执行会影响 HTML 的解析进度。
同步加载(默认行为)
工作方式
<script src="script.js"></script>
浏览器遇到普通 <script> 标签时,执行以下步骤:
- 暂停 HTML 解析
- 下载 JS 文件(网络请求)
- 执行 JS 代码
- 恢复 HTML 解析
时序图
HTML 解析 ████████░░░░░░░░░░░░████████████
↑ ↑
暂停解析 恢复解析
JS 下载 ████████
JS 执行 ████
为什么会阻塞?
JS 可以操作 DOM(如 document.write()),如果 JS 执行时 DOM 还在继续构建,会产生竞争问题,所以浏览器必须等待 JS 执行完毕再继续解析 HTML。
异步加载
async —— 异步加载,加载完立即执行
<script async src="script.js"></script>
| 阶段 | 行为 |
|---|---|
| 下载 | 与 HTML 解析并行进行,不阻塞 |
| 执行 | 下载完成后立即执行,此时暂停 HTML 解析 |
HTML 解析 ████████████░░░████████████████
JS 下载 ████████
JS 执行 ████
特点:
- 执行顺序不确定(谁先下载完谁先执行)
- 适合无依赖的独立脚本,如广告、统计埋点
defer —— 异步加载,延迟执行
<script defer src="script.js"></script>
| 阶段 | 行为 |
|---|---|
| 下载 | 与 HTML 解析并行进行,不阻塞 |
| 执行 | 等 HTML 完全解析完成后,按顺序执行 |
HTML 解析 ████████████████████████████
JS 下载 ████████
JS 执行 ████ ← DOMContentLoaded 之前
特点:
- 执行顺序按声明顺序保证
- 适合有依赖关系的脚本(如 A 依赖 B)
三种方式对比
| 特性 | 普通 <script> |
async |
defer |
|---|---|---|---|
| HTML 解析是否阻塞(下载时) | 是 | 否 | 否 |
| HTML 解析是否阻塞(执行时) | 是 | 是 | 否 |
| 执行时机 | 立即(下载后) | 立即(下载后) | HTML 解析完成后 |
| 执行顺序 | 按声明顺序 | 不确定 | 按声明顺序 |
| 适用场景 | 依赖 DOM 的内联脚本 | 独立无依赖脚本 | 依赖 DOM 的外部脚本 |
JS 加载是否阻塞页面渲染?
结论
| 加载方式 | 阻塞 HTML 解析 | 阻塞渲染 |
|---|---|---|
<script> |
是 | 是 |
<script async> |
执行时短暂阻塞 | 执行时短暂阻塞 |
<script defer> |
否 | 否 |
<script> 放在 </body> 前 |
实际不阻塞(DOM 已解析) | 近似否 |
深入理解
渲染阻塞 分两层含义:
- 阻塞 HTML 解析:浏览器停止构建 DOM 树
- 阻塞首次渲染(FCP):用户看到空白页面
同步 <script> 放在 <head> 中时,两者都会阻塞,直到脚本执行完毕页面才渲染。
<!-- 糟糕的实践:阻塞渲染 -->
<head>
<script src="heavy-script.js"></script> <!-- 用户看到白屏直到这里执行完 -->
</head>
<!-- 推荐实践 -->
<head>
<script defer src="app.js"></script> <!-- 不阻塞,HTML 解析完后执行 -->
</head>
动态创建脚本
JS 也可以动态插入 <script> 标签:
const script = document.createElement('script')
script.src = 'script.js'
script.async = true // 默认就是 async = true
document.head.appendChild(script)
动态创建的脚本默认是异步的(相当于 async),不会阻塞页面解析。
最佳实践总结
<!DOCTYPE html>
<html>
<head>
<!-- 1. 无依赖的第三方脚本用 async -->
<script async src="analytics.js"></script>
<!-- 2. 有依赖的业务脚本用 defer,保证顺序 -->
<script defer src="lib.js"></script>
<script defer src="app.js"></script> <!-- 在 lib.js 之后执行 -->
</head>
<body>
<!-- 3. 内联关键脚本放 </body> 前(兜底方案) -->
<script>
// 此时 DOM 已解析完毕
</script>
</body>
</html>
优先级建议:defer > 放 </body> 前 > async > 默认同步
常见面试题
Q:DOMContentLoaded 和 load 有什么区别?
DOMContentLoaded:HTML 解析完成,defer脚本执行完毕后触发,不等图片/CSSload:页面所有资源(图片、CSS、JS)全部加载完成后触发
Q:async 和 defer 的 script 都不阻塞渲染,有什么区别?
核心区别在执行时机:async 下载完就执行(可能打断 HTML 解析),defer 等 HTML 完全解析后才执行(绝不打断)。多个 defer 按顺序执行,多个 async 顺序不定。