普通 script 标签会阻塞 DOM 解析和渲染;defer 脚本并行下载、按序执行且不阻塞解析;async 脚本并行下载、谁先完成谁先执行、不保证顺序;type="module" 默认 defer 且支持现代特性。

普通 script 标签会阻塞 DOM 解析和渲染
遇到没有 async 或 defer 的 <script>,浏览器立刻暂停 HTML 解析,下载并同步执行脚本——期间 DOM 构建中断、样式计算停摆、页面完全不绘制。
常见错误现象:
- 首屏白屏时间长,尤其脚本体积大或网络慢时
-
document.getElementById("main")在内联脚本中返回null,因为对应元素还没被解析到 - 即使
<span>Hello</span>写在<script>alert("1")</script>前,用户也先看到弹窗,后见文字——不是 DOM 没插入,是渲染被冻结
实操建议:
- 避免把
<script>放在<head>里(除非是现代模块化入口,如type="module") - 内联脚本若需操作 DOM,必须确保它出现在目标元素之后,或包裹在
DOMContentLoaded回调中 - 用
devtools → Network查看脚本是否成为关键路径瓶颈;若 TTFB + 下载耗时 > 200ms,优先外链 + 缓存
defer 脚本按顺序执行,且不阻塞解析
defer 只对带 src 的外链脚本有效,内联脚本加了也无效。它让浏览器并行下载脚本,但推迟执行到整个 HTML 解析完成之后、DOMContentLoaded 触发之前。
立即学习“前端免费学习笔记(深入)”;
使用场景:
- 需要操作完整 DOM,又不想手动监听
DOMContentLoaded的逻辑(如初始化导航菜单、表单校验) - 多个有依赖关系的脚本(如
utils.js必须在app.js之前执行)
注意点:
- 多个
defer脚本严格按 HTML 中出现顺序执行,哪怕app.js先下载完,也要等utils.js执行完才轮到它 - 如果某个
defer脚本加载极慢,DOMContentLoaded会被拖住——它等所有defer脚本下载+执行完毕才触发 -
<script defer src="a.js"></script>和<script defer src="b.js"></script>不能跨<body>拆开,否则顺序可能因解析位置错乱
async 脚本谁先下完谁先执行,不保证顺序
async 同样只对外链脚本生效。它让脚本下载与 HTML 解析完全并行,但一旦下载完成,就立即中断当前解析、执行脚本——不等其他脚本,也不等 DOM 构建结束。
容易踩的坑:
- 在
async脚本里调用document.querySelector("header"),大概率报错,因为此时<header>可能还没解析到 - 两个
async脚本存在依赖(如 A 初始化全局对象,B 使用该对象),执行顺序不可控,极易崩溃 - 放在
</body>前的async脚本,反而可能比 DOM 构建还早执行——位置不解决执行时机问题
适用场景有限:
- 完全独立、无 DOM 依赖、无其他脚本依赖的逻辑(如访客统计、错误上报)
- 第三方 SDK(如 Google Analytics)明确要求
async加载时
现代方案:type="module" 自带 defer 语义
<script type="module" src="app.mjs"></script> 默认行为等价于 defer:下载并行、执行延迟、顺序保证,且额外支持顶层 await 和静态导入分析。
为什么值得切换:
- 无需手动加
defer,减少配置遗漏风险 - 天然支持
import语法,便于代码拆分和 tree-shaking - 模块脚本自动启用 CORS,避免非模块脚本跨域限制的隐式陷阱
但要注意:
- 旧版浏览器(如 IE、Android Browser 4.4)不支持,需搭配
nomodule回退方案 - 动态
import()是真正按需加载的手段,不是靠改script标签位置能实现的 - 模块脚本中的
this在顶层为undefined,与传统脚本不同,调试时留意作用域差异
最常被忽略的一点:无论用哪种加载策略,只要脚本执行本身耗时过长(比如循环 10 万次、大量 DOM 操作),就会直接卡住主线程,导致渲染帧丢失、交互卡顿。优化重点不在“怎么加载”,而在“加载后做了什么”。



















