你有没有遇到过这种情况:页面打开像老牛拉破车,白屏好几秒,F12 一开 Network 面板,一堆 script 文件在排队加载。很多刚入门的前端同学都会在这个环节吃亏——代码逻辑明明没问题,可页面就是慢,甚至报错。这种现象十有八九跟 script 标签的加载方式有关。今天就把前端里最基础也最关键的 async 和 defer 两个属性讲透,顺便把页面加载性能这块的深层逻辑一起理顺。
这两个属性是前端面试里的常客,也是做页面性能优化绕不开的基础知识点。明白了它们,你不仅能看懂很多项目里 script 标签为什么要那样写,还能在排查页面加载慢、白屏、脚本执行报错这类问题时,少走一大截弯路。
1. 打基础:浏览器到底是怎么加载 script 的
1.1 一个 script 标签引起的“交通堵塞”
要理解 async 和 defer,得先搞清楚浏览器解析 HTML 时,遇到一个普通 script 标签会发生什么。
浏览器拿到 HTML 文档后,是从上到下一行一行解析的。解析到 <script src="xxx.js"></script> 时,它不知道这个脚本内部做了什么操作,会不会改 DOM、会不会调 document.write,所以只能停下来等这个脚本先下载完、执行完,再继续往下解析 HTML。就像一条单车道,前面一辆车抛锚了,后面的车全都得堵着。
这个过程叫“阻塞”。关键是,它不仅阻塞了后续 HTML 的解析,也阻塞了页面的渲染。用户在等待期间看到的就是白屏。如果这个脚本文件很大、网络又慢,那用户就眼睁睁看着空白页面干等,体验直接崩盘。
注意:现代浏览器(Chrome、Firefox 等)其实有预扫描机制,在遇到 script 时会提前发现后续的图片、样式表等资源并开始下载,所以"下载完全卡死"的情况没那么严重。但脚本的执行仍是同步阻塞的,只要脚本不执行完,后面的 HTML 就不会继续解析。
1.2 下载和执行是两件事,必须拆开看
很多初学者会把“脚本阻塞”归结为下载慢,但严格来说,需要区分两个阶段:下载 和 执行。
- 下载:通过网络请求把 js 文件从服务器拉到本地。
- 执行:浏览器把拿到的 JavaScript 代码跑一遍。
普通 script 标签的做法是:下载脚本 → 执行脚本 → 继续解析 HTML。也就是说,下载和执行都在关键路径上,任何一个慢都会卡住渲染。
而 async 和 defer 的作用,本质上就是改变这两个阶段与 HTML 解析的时序关系。一个负责让下载不用排队,一个负责控制执行时机。理解了这一点,后面的内容就顺理成章了。
1.3 用生活场景理解阻塞
把浏览器解析 HTML 想象成一个流水线工人,普通 script 就像工人必须在工位上等一个零件(脚本)送到,再亲手加工(执行),工位完全停摆。
如果给这个 script 加上 defer,相当于把这个零件放到旁边的缓冲料架,工人可以继续组装后面的零部件,等所有零件都组装完,再统一处理缓冲架上攒下的活。
如果给这个 script 加上 async,相当于零件送到后就立刻拿到工位加工,不管当前组装到哪一步,这个过程仍然会打断流水线,但零件还没送到的那段时间,工人不用干等,可以继续往前干。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深入拆解 async 和 defer 的工作机制
2.1 defer:等 DOM 解析完再执行
defer 的中文意思是“延迟”。语法很简单:
html复制<script src="app.js" defer></script>
加了 defer 之后,浏览器解析到 script 标签时:
- 继续并行下载这个脚本,不阻塞 HTML 解析。
- 等 HTML 全部解析完成后,再按照 script 在文档中出现的先后顺序依次执行。
也就是说,defer 脚本的执行时机是“HTML 解析完成之后,DOMContentLoaded 事件触发之前”。这个时机点非常合适业务代码,因为此时 DOM 已经完整了,脚本里想操作哪个节点都没问题。
defer 还有一个值得注意的特性:多个 defer 脚本的执行顺序一定和文档书写顺序一致。比如:
html复制<script src="a.js" defer></script>
<script src="b.js" defer></script>
即使 b.js 比 a.js 先下载完,执行时也会先执行 a.js 再执行 b.js。这个特性很重要,如果你的脚本之间有依赖关系,defer 是相对安全的方案。
2.2 async:下载完立即执行,不等任何人
async 的中文意思是“异步”。用法一样简单:
html复制<script src="analytics.js" async></script>
加了 async 之后,浏览器解析到 script 标签时:
- 继续并行下载脚本,不阻塞 HTML 解析。
- 一旦脚本下载完成,立即暂停 HTML 解析,执行这个脚本。
- 执行完再恢复 HTML 解析。
注意关键词:立即 。它不关心 HTML 解析到哪一步,也不等 DOM 解析完成。如果脚本里访问了一个还没解析到的 DOM 元素,就会报错。
多个 async 脚本的执行顺序不保证,谁先下载完谁先执行。比如:
html复制<script src="a.js" async></script>
<script src="b.js" async></script>
如果 b.js 文件更小、加载更快,那它就会先执行,a.js 后执行。所以 async 只适用于互相之间没有依赖关系的独立脚本。
2.3 没有 async/defer 时的默认行为
把默认行为和两种优化属性放在一起对比,会更直观:
| 属性 | 下载时机 | 执行时机 | 是否阻塞解析 | 执行顺序 |
|---|---|---|---|---|
| 无属性 | 遇到即下载 | 下载完立即执行 | 阻塞 | 按文档顺序 |
| async | 异步并行下载 | 下载完立即执行 | 执行时阻塞 | 先下完先执行 |
| defer | 异步并行下载 | HTML 解析完成后执行 | 不阻塞 | 按文档顺序 |
表格里有一行很关键:即使加了 async,脚本执行的那一刻还是会阻塞 HTML 解析的。很多人误以为 async 完全不阻塞,其实只是下载阶段不阻塞,执行阶段仍然会打断解析。defer 则连执行阶段都安排在解析完成之后,所以全程不阻塞。
注意:
async和defer都只对外部脚本生效,写在内联<script>...</script>里的代码加这两个属性是无效的。另外,如果脚本同时加了async和defer,浏览器会优先把脚本当成async处理,defer被忽略。
2.4 为什么 DOMContentLoaded 会受影响
DOMContentLoaded 是前端开发中很常用的事件,表示文档解析完成、DOM 可以安全操作了。这个事件触发时机和脚本加载方式有直接关系:
- 普通同步 script:下载和执行都会推迟 DOMContentLoaded 的触发。
defer脚本:会在 DOMContentLoaded 之前执行,所以也会推迟它的触发,但只推迟脚本的执行时间,不推迟下载。async脚本:因为它可能在文档解析的任何阶段执行,所以不确定是否推迟 DOMContentLoaded。
如果你在页面里用 addEventListener('DOMContentLoaded', ...) 做初始化,要注意这个时序问题。尤其用 defer 的脚本,里面操作都是安全的,因为执行时 DOM 已经解析完成,只是会稍稍延迟 DOMContentLoaded 事件。
3. 实操:不同场景下怎么选 async 和 defer
3.1 第三方脚本:统计代码、广告、埋点
这类脚本的特点是:功能独立,不依赖你页面的业务代码,也不被你页面的逻辑调用。Google Analytics、百度统计、埋点 SDK 都属于这一类。
最适合的设置是 async。
原因很简单:其一,它们不需要操作页面 DOM,或者即使操作也是独立封装好的;其二,执行顺序无所谓,统计代码晚点跑、早点跑都不影响业务功能。用 async 让它们下载完就尽快执行,既不阻塞页面解析,又能让统计尽量早生效,是各方面都能兼顾的选择。
实际操作时我一般这样写:
html复制<script async src="https://example.com/statistics.js"></script>
如果这个统计脚本依赖另外一个公共工具库,就不适合用 async 了,因为不能保证先后顺序。这时候要么把两个脚本合并成一个文件,要么考虑用 defer。
3.2 业务核心代码:依赖 DOM 的脚本
页面业务脚本通常是针对当前页面做交互绑定的,比如轮播图、表单校验、菜单栏收起展开。这些脚本一定会在代码里访问 DOM 节点,如果用 async,很可能遇到“脚本执行时对应的 DOM 还没被浏览器解析出来”的情况,就会报 Cannot read property 'addEventListener' of null 之类的错。
这类脚本应该用 defer,并且建议把多个存在依赖关系的脚本按顺序放好:
html复制<script src="utils.js" defer></script>
<script src="carousel.js" defer></script>
<script src="init.js" defer></script>
这样能保证:
- 下载阶段全部并行,节省整体加载时间。
- HTML 解析完成后再执行,DOM 一定存在。
- 执行顺序和书写顺序一致,
init.js里可以放心调用utils.js里定义的工具函数。
这个方案是我在业务项目里用得最多的,也是大部分无需打包的纯前端页面最合理的脚本加载方式。
3.3 模块脚本:type="module" 的默认行为
ES Modules(type="module")加载方式比较特殊,它默认就是 defer 的。也就是说:
html复制<script type="module" src="main.js"></script>
和下面的写法行为一致:
html复制<script src="main.js" defer></script>
模块脚本会等到 HTML 解析完成后按顺序执行,且天然支持依赖导入。不过模块脚本也可以加 async:
html复制<script type="module" async src="main.js"></script>
加了 async 的模块脚本会在下载完成后立即执行,需要注意它依然可能先于 DOM 解析完成执行。
另外,模块脚本还有个常见坑:跨域加载限制。type="module" 的脚本受 CORS 限制,从本地 file:// 协议直接打开 HTML 文件,模块脚本通常加载失败,需要在本地起一个静态服务器才能正常使用。这也是很多初学者写 demo 时,type="module" 报错的一大原因。
3.4 动态创建 script:让 async 默认开启
除了静态写在 HTML 里,前端也经常通过 JS 动态插入 script 标签,比如懒加载一个图表库、按需加载某个功能模块。
使用 document.createElement('script') 动态插入的脚本,默认就带有 async 行为。原因是动态创建的脚本不在 HTML 解析流程中,浏览器不会为它停顿,它下载完就会立即执行。
javascript复制const script = document.createElement('script');
script.src = 'https://example.com/chart.min.js';
script.onload = () => {
// 脚本加载完成后的回调
};
document.body.appendChild(script);
这段代码动态加载了脚本。由于默认是 async 行为,执行完可能就在当下,所以大多数情况需要在 onload 回调里再做后续初始化,确保资源可用。
如果想让它表现得更像 defer,可以在插入脚本前设置 script.async = false:
javascript复制const script = document.createElement('script');
script.src = 'https://example.com/chart.min.js';
script.async = false;
document.body.appendChild(script);
这样会改变动态脚本的执行时机,它会等待文档解析完成后执行,适合有依赖关系的动态脚本。不过这个用法相对少见,一般还是配合 onload 回调更直观。
3.5 实操出来的选择建议
我根据自己的实际项目经验,给一个比较稳妥的选择思路:
| 脚本类型 | 推荐属性 | 理由 |
|---|---|---|
| 统计/埋点/广告脚本 | async | 不依赖业务逻辑,不影响顺序 |
| 业务脚本(操作 DOM 或依赖其他脚本) | defer | DOM 完整,执行有序 |
| 模块化脚本 type="module" | 默认行为,特殊场景加 async | 天然支持依赖导入 |
| 动态插入的脚本 | 默认 async,通过 async=false 调整 | 灵活控制时机 |
| 需要立即支持页面关键交互的脚本 | 普通同步或放在 body 底部 | 保证第一时间执行 |
有一个很容易被忽视的点:如果某个脚本对页面首屏渲染至关重要,比如渲染首屏内容的框架运行时,那不建议加 async 或 defer,而是应该尽量放在 <head> 里以内联或同步方式加载。这类"关键路径脚本"越早执行越好,延时反而会导致首屏内容迟迟出不来。
4. 常见问题与排查技巧实录
4.1 加了 async 后报错 “element is not defined”
这个问题我在实际开发里见过很多次。原因是脚本用了 async,下载完成的时间点不确定,可能整个 DOM 都还没解析完,更不用说脚本里引用的某个按钮了。
排查思路:
- 打开 DevTools 的 Console 面板,看报错信息里是哪个变量或元素找不到。
- 在 Sources 面板里给脚本打上断点,看执行时
document.readyState是什么状态。 - 确认该脚本是否必须操作 DOM,如果是,换成
defer。
解决办法最简单:把 async 改成 defer,或者把逻辑放进 DOMContentLoaded 回调中。
4.2 热词提到的 failed to load module script 报错
这个报错很典型,尤其在写模块化脚本时:
text复制Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html"
常见原因有几个:
- 服务器没有正确配置 js 文件的 MIME 类型,返回了 HTML。
- 本地使用
file://协议打开 HTML,模块跨域加载被浏览器拦截。 - 路径错误,导致服务器返回 404 页面,而 404 页面是 HTML,于是报 MIME type 不匹配。
解决办法也不复杂:优先检查脚本路径;然后确保通过本地服务器(如 npx serve、python -m http.server)访问页面,不要直接双击 html 文件。如果你用的是打包工具(Vite、Webpack 等),它们内部已经处理好了模块加载,一般不会出现这个问题。
4.3 多个脚本执行顺序错乱,页面行为异常
有些项目的脚本不是自己写的,可能是接了好几个第三方库,也没有统一规范,结果出现“某个库还没加载,另一个库已经开始用了”的问题。
这种问题用 async 就会出现。如果脚本之间有依赖关系,无论 async 还是动态插入的默认行为都可能破坏顺序。
排查方法:
- 在 Network 面板看脚本的加载时间和顺序。
- 在 Console 看报错是否集中在某个库的全局变量或方法上。
- 查看脚本是否每个都加了
async。
正确做法:把有依赖关系的脚本改成 defer,并确保书写顺序正确。如果第三方脚本无法修改,考虑用动态加载的方式在 onload 回调里按顺序引入后续脚本。
4.4 外部脚本和行内脚本混用的坑
有时项目里既有外部脚本,又有行内脚本。行内脚本是不能加 async 或 defer 的,它会在解析到时立即执行。如果行内脚本依赖一个用 defer 加载的外部库,那执行时机必然对不上——行内脚本执行时,外部库还没执行完。
解决办法:
- 把行内代码抽成一个文件,用
defer引入,并放在外部库后面。 - 或者把行内代码包在
window.addEventListener('load', fn)里,延迟到所有资源加载完成。
不过第二种方式会把时机拖得很晚,影响体验。我更推荐直接搞成外部文件,反正项目构建时最终都要合并压缩。
4.5 排查 script 加载问题的小工具
除了 DevTools 的 Network 和 Console,推荐两个实用方法:
- Performance 面板:可以录制页面加载过程,直观看到脚本下载和执行的时间线。我排查白屏问题时常用它确认是哪个脚本卡住了主线程。
- DevTools 的 Coverage 面板:可以看脚本里有哪些代码确实执行了,哪些是多余的。对于优化脚本体积很有帮助。
如果某个脚本加载不出来,也可以直接在 Console 里输入 performance.getEntriesByType('resource') 查看资源加载详情,能拿到每个脚本的 startTime、duration、transferSize 等数据,比肉眼盯着 Network 面板更快。
5. 面试高频考点:async/defer 的底层原理与延伸
5.1 面试官爱问的几个问题及思路
前端面试中,async 和 defer 几乎是必考题。我整理了几个高频问题,并给出比较规范的思考方向。
问题一:async 和 defer 的区别?
回答要点:先说下载阶段都不阻塞解析,再讲区别——async 下载完立即执行,执行时可能阻塞解析,多个脚本执行顺序不保证;defer 等 HTML 解析完成后再执行,顺序和文档顺序一致。
问题二:页面上有大量 script,怎么优化加载?
回答思路:按脚本依赖关系和业务重要性分类。关键的放 <head> 内联或同步加载;次关键且互不依赖的用 async;有依赖关系且需要操作 DOM 的用 defer;非首屏必要的脚本可以考虑动态加载或 loading 属性。
问题三:DOMContentLoaded 事件什么时候触发?
回答要点:HTML 文档解析完成,且所有 defer 脚本执行完毕后触发。async 脚本不保证在它前后执行,所以 DCL 的时间点会受 async 影响但不一定被它阻塞。
问题四:type="module" 的脚本是异步加载吗?
回答要点:是,模块脚本默认 defer 行为,但支持加 async;模块脚本本身支持依赖导入,多个模块脚本按引用依赖树的顺序执行,不是简单按文档属性判断。
5.2 从 async/defer 延伸到渲染阻塞
理解了脚本加载机制之后,可以再往深走一步:为什么前端性能优化那么强调“减少渲染阻塞”?
因为 HTML 解析和 CSS 解析、JavaScript 执行都在主线程上。JavaScript 的下载和执行,都会与页面渲染抢占主线程时间片。尤其移动端网络环境差、CPU 算力弱,一个 2MB 的同步脚本就能让首屏白屏多一两秒。
所以现代前端性能优化有一整套链路:
- 脚本按需加载,移除不必要的首屏资源。
- 用
async/defer降低脚本对解析的影响。 - 压缩代码、Tree Shaking 减少体积。
- 使用 HTTP/2 多路复用、CDN 加速下载。
- 关键 CSS 内联,非关键 CSS 延迟加载。
async 和 defer 是这套链路里最底层、最不起眼但影响很大的环节。很多优化框架、工程化工具其实都在底层帮你处理了这些事情,但手动写页面或分析性能瓶颈时,理解这个基础仍然非常必要。
5.3 Vite 和 Webpack 打包后还有必要手动写 async/defer 吗
现在主流工程化框架(Vite、Webpack、Next.js 等)打包出来的 HTML 里,你会发现 script 标签带有 type="module" 或者自动加了 defer。这是构建工具帮我们处理好的,开发时确实不需要每次手动加。
但有两个场景你依然需要手写:
- 在 HTML 模板里引入第三方 CDN 的脚本。
- 做站点统计、广告、监控脚本的接入。
- 写纯静态页面或小程序 web-view 页面时,没有构建工具可依赖。
另外,即使构建工具默认加了 defer,如果你在模板里手动插入一个统计脚本,依然应该自己写 async。不然它默认同步,会阻塞页面解析,拖慢整体访问速度。
这算是一个不大不小的经验点:构建工具只覆盖它自己生成的标签,手动插入的标签它管不了,别以为项目用了 Vite 就可以随便写 script。
5.4 动态加载和代码分割的延伸思考
工程化项目里常见的代码分割(Code Splitting)本质上就是利用动态 import() 来实现的。它和动态创建 script 很像,但语法更优雅:
javascript复制const module = await import('./lazy-component.js');
// 到这里再使用 module
动态 import() 返回 Promise,天然支持异步加载,而且不会阻塞主线程的解析。它比手动 createElement('script') 的做法更适合模块化开发,因为浏览器会对加载结果进行缓存,重复调用不会重复请求。
前端性能优化的世界里,async/defer 只是第一道门。打开这扇门之后,你会看到还有 HTTP 缓存、Service Worker、资源预获取、关键路径渲染优化等更深层的内容。但无论哪一层,核心思想都绕不开两个词:下载时机和执行时机。
我自己早期在这个问题上踩过不少坑,特别是写原生页面的时候,把统计脚本和业务脚本全都堆一块儿,既不写 async 也不写 defer,结果页面白屏两三秒,排查半天才发现是脚本加载顺序和阻塞引起的。后来养成一个习惯:每次在 HTML 里写 script 标签,都会下意识想一下“这个脚本是给谁用的、什么时候执行合适”,选好属性再写。这个习惯帮我避免了很多线上问题,也希望对你有点帮助。
