defer与async深入解析:执行机制、性能对比与工程实践 📅 发布时间:2026/9/18 21:07:04 👁 浏览次数: 把defer和async彻底讲透是我写这篇的初衷。这俩属性在简历里出现的频率极高但能把它们真正讲清楚的人我面试了这么多年遇到的并不多。很多人只知道“defer 是延迟执行async 是异步执行”再往深问一句“那它们对 DOMContentLoaded 的影响有什么不同”或者“多个脚本同时用执行顺序到底是怎样的”就开始含糊了。这篇文章我打算用一种比较“笨”的方式来写先把执行机制掰开揉碎讲清楚再给实测数据最后丢几个我在真实项目里排查过的线上故障。你如果能跟着走一遍下次再有人问起 defer 和 async你可以直接给他画出完整的执行时间线。1. 从“浏览器解析 HTML 遇到脚本会发生什么”说起在对比 defer 和 async 之前必须先搞清楚一个基础问题浏览器在解析 HTML 文档时遇到一个普通的script标签到底会发生什么这一步如果不牢固后面所有结论都是空中楼阁。1.1 普通 script 的默认行为解析被暂停下载并执行完再继续当 HTML 解析器从上往下扫描文档碰到一个没有defer、没有async、也没有typemodule的script srcxxx.js时浏览器会做这样几件事立即停止对 HTML 文档的解析——后面还有多少标签没解析都得先等着。发起网络请求下载这个 JavaScript 文件。如果是内联脚本则跳过下载直接进入下一步。下载完成后立即交给 JavaScript 引擎执行。执行完毕再继续解析剩余 HTML。这个过程就是日常所说的“解析阻塞parser blocking”。注意这里的阻塞对象是“HTML 解析器”不是整个页面渲染。但由于 HTML 解析被卡住后续 DOM 节点的生成、CSSOM 的构建、布局和绘制都会被迫延后用户看到的直接表现就是白屏时间变长。我见过不少开发者把“阻塞”理解为“脚本执行慢导致页面卡”其实更准确的说法是只要脚本还没下载完页面连后续 HTML 都解析不了更别提渲染了。下载时间在网络差的时候可能达到几百毫秒甚至数秒这段时间用户面前就是一片空白。1.2 为什么浏览器要设计成阻塞单线程的必然选择很多人会问为什么浏览器不一边下载脚本一边继续解析 HTML这不是更快吗答案是JavaScript 的单线程模型不允许。浏览器的主线程既要负责 HTML 解析又要执行 JavaScript。脚本运行期间完全可能去操作 DOM——比如document.getElementById(app)、document.createElement(div)。如果 HTML 解析线程和脚本执行同时进行两边同时对 DOM 树做增删改查页面结构就会处于一个不可预测的中间状态。为了避免这种竞争条件浏览器干脆一刀切解析和脚本执行不能同时发生。所以“阻塞”不是设计缺陷而是单线程模型的必然结果。defer和async的诞生本质上不是打破了单线程约束而是重新安排了“下载”和“执行”这两个动作与“HTML 解析”之间的时序关系让它们尽量不互相拖后腿。1.3 一句话理解三种脚本的差异为了帮你建立直觉先把结论摆在前面普通 script遇到就下载下载完就执行全程阻塞解析。defer遇到就下载下载不阻塞解析等 DOM 解析完了按顺序执行。async遇到就下载下载不阻塞解析下载完立刻执行执行顺序不保证。后面所有细节都是在这三句话上展开。2. 执行时机与下载时机理解 defer 和 async 的关键维度很多讲 defer 和 async 的文章上来就丢一张对比表但读者看了还是迷糊原因是没建立一个分析框架。我建议把“脚本的一生”拆成两个独立维度下载时机和执行时机。2.1 下载时机网络请求发生在什么时候对于没有特殊属性的普通脚本下载发生在“解析到 script 标签那一刻”并且解析被暂停。这意味着如果页面有 10 个脚本浏览器会一个一个地下载、执行痛苦的网络往返被串行展开。对于defer和async脚本下载时机是完全一样的解析到标签时发起下载但下载过程与 HTML 解析并行不阻塞解析器。现代浏览器对同一个域名的并发连接数通常为 6 左右因此多个 defer/async 脚本可以同时下载显著缩短整体等待时间。这是两者唯一的“共性”。从下载这个维度看defer 和 async 没有任何区别。真正的分野全在“执行时机”。2.2 执行时机defer 的“等 DOM 解析完再执行” vs async 的“下载完就执行”这是整篇文章最核心的部分值得多花点笔墨。defer 的执行时机无论脚本什么时候下载完成统一等到整个 HTML 文档解析完毕之后再按文档顺序执行。而且在执行完成后才触发DOMContentLoaded事件。换句话说defer 脚本保证了执行时 DOM 已经完整可用多个 defer 脚本按 HTML 中的出现顺序依次执行它在DOMContentLoaded之前执行。async 的执行时机下载一旦完成立刻插入执行不管此时 HTML 解析到了哪里。一个 async 脚本可能在 HTML 解析到一半时执行——此时后面的 DOM 节点还不存在也可能在解析前就执行如果脚本本身来自缓存下载瞬间完成。多个 async 脚本的执行顺序完全取决于谁先下载完与文档中的书写顺序无关。这两个差异直接决定了它们的适用场景defer 适合“依赖 DOM 且有顺序要求”的脚本async 适合“完全独立、无依赖顺序”的脚本。2.3 如何记住给一个生活类比如果觉得概念抽象我用一个生活化的类比帮你固化记忆。把“HTML 文档解析”想象成“装修房子”。“挂载 DOM 节点”像“摆放家具”“触发 DOMContentLoaded”像“装修完工可以验收”。普通 script装修队伍正在铺地板遇到一个箱子脚本必须停下来把箱子里的东西安装好才能继续铺地板。defer装修队伍遇到箱子先把箱子搬到角落并行下载继续铺地板。地板全部铺完、家具摆好DOM 解析完成再统一打开箱子安装按序执行。async装修队伍遇到箱子先把箱子搬进来并行下载但箱子一落地不管地板铺到哪立刻拆箱安装。安装完再继续干活。这个类比当然不是完全精确但足以帮你瞬间建立直觉。后面遇到任何关于 defer/async 的判断题都可以拿这个模型套一套。3. defer 的完整工作流程与适用场景既然说清楚了基础机制这一节专门把 defer 单独拎出来把它的完整生命周期和真实场景讲透。3.1 defer 脚本的生命周期三个阶段一个script defer srcmain.js从浏览器遇到它开始会经历三个阶段第一阶段并行下载阶段。浏览器发起对 main.js 的网络请求同时继续解析 HTML。下载是在后台进行的解析器不会被卡住。注意如果 main.js 的下载速度极快比如命中了强缓存它可能在 HTML 解析完成之前就已经下载完毕——但此时它并不会立刻执行。第二阶段等待 DOM 解析阶段。如果脚本已经下载完但 HTML 还没解析完脚本会进入一个“已下载待执行”的队列默默等待。这里的队列是浏览器内部维护的专门存放 defer 脚本。如果 HTML 先解析完但脚本还没下载完浏览器则会继续等待下载完成。第三阶段DOM 解析完成后按序执行。当整个 HTML 文档被完整解析成 DOM 树之后浏览器会取出该队列中的所有脚本按照它们在 HTML 文档中出现的顺序依次执行。全部执行完毕后再触发DOMContentLoaded事件。有一个细节值得强调defer 脚本一定会等到 DOM 解析完成才执行即使这个过程中DOMContentLoaded事件已经“准备好”了也会被推迟。这也就是为什么说defer 会延迟 DOMContentLoaded 的触发——如果你在页面里有监听 DOMContentLoaded 的逻辑它的执行时间会被 defer 脚本拖后。3.2 多脚本场景defer 是有序的现代页面几乎不可能只引用一个脚本。当你有多个 defer 脚本时script defer srca.js/script script defer srcb.js/script script defer srcc.js/script这三个脚本会并行下载但执行时一定严格按照 a → b → c 的顺序。这个顺序由它们在 HTML 中出现的先后决定与下载完成时间无关。即使 c.js 第一个下载完它也得排在 a、b 后面执行。这一点非常关键也是 defer 区别于 async 的最大优势——当你需要保证脚本执行顺序时defer 是唯一能做到“并行下载 串行执行”的属性。提示这个串行执行是有序的但并不意味着执行完 a 才会开始执行 b 的“编译阶段”只是说 b 的“执行阶段”必须等 a 的“执行阶段”结束。现代 JavaScript 引擎的优化足够复杂这里不展开但顺序保证是确定的。3.3 defer 的适用场景与典型用法基于 defer 的特性它最适合以下场景页面主逻辑脚本需要操作 DOM、依赖 DOM 结构完整比如绑定事件、读取元素属性。defer 执行时 DOM 一定已经就绪不用担心getElementById拿到 null。有依赖关系的多脚本比如先加载 jQuery再加载基于 jQuery 的插件。用 defer 可以保证 jQuery 先于插件执行。不要求“越早执行越好”的脚本你只关心最终功能正常不关心它是否在最短时间内执行。defer 的“晚执行”对你没有影响。实际工程里如果一个脚本没有特殊要求我的默认推荐就是加defer。它兼顾了“不阻塞解析”的性能优势和“DOM 就绪后执行”的正确性保障是绝大多数业务脚本的最优解。4. async 的完整工作流程与适用场景说完 defer再来专门看 async。它的行为模型与 defer 完全不同理解错位就会写出有隐患的代码。4.1 async 脚本的生命周期下载完即执行script async srctrack.js从浏览器遇到它开始同样经历几个阶段第一阶段并行下载阶段。与 defer 相同浏览器在后台发起下载HTML 解析继续进行不产生阻塞。第二阶段下载完成立刻执行。无论此时 HTML 解析到了哪个位置浏览器都会暂停解析器将控制权交给 JavaScript 引擎立即执行 track.js。执行完毕再回到解析流程继续处理剩余的 HTML。这里的关键差异在于async 脚本不等待 DOM 解析完成。如果脚本下载得非常快甚至命中缓存瞬间完成它可能在 HTML 还远远没解析完时就执行了。如果脚本是一个大型第三方库下载慢HTML 可能早已解析完脚本才姗姗来迟地执行——此时 DOM 反而是完整的。所以 async 脚本的执行时机完全不确定完全取决于网络状况与解析速度的相对快慢。你可以把它的时机理解为“下载完成的那个瞬间随机插入解析流程中的某个点”。4.2 多个 async 脚本执行顺序完全不保证与 defer 的严格有序相反多个 async 脚本的执行顺序是不可预测的script async srcx.js/script script async srcy.js/script如果 x.js 先下载完就先执行 x.js如果 y.js 先下载完就先执行 y.js。而谁先下载完又取决于文件大小、网络速度、服务器响应时间等多个不可控因素。即使 x.js 在 HTML 里写在 y.js 前面也没有任何保证。这个特性决定了 async 脚本之间不能有任何逻辑依赖。如果 x.js 需要调用 y.js 定义的函数那在 async 模式下这个依赖关系随时可能断裂——x 先执行时y 的函数还不存在一调用就报错。4.3 async 的适用场景与典型用法async 并非不能用关键在于找对场景。它适合具备以下两个特征的脚本完全不依赖其他脚本的执行结果——自己独立运行不调用外部函数。不在乎 DOM 是否已经解析完成——要么不用 DOM要么会自己监听load或DOMContentLoaded事件。典型例子包括统计分析脚本如友盟、百度统计、GA独立上报早一点晚一点执行都不影响统计准确性。广告脚本广告位通常有自己的容器脚本内部会等 DOM 生成而且多个广告 SDK 之间没有依赖关系。A/B 测试脚本需要尽早参与页面渲染但又不希望阻塞 HTML 解析async 可以尽量早地注入实验逻辑。第三方监控/告警 SDK如前端错误监控它们一般监听全局错误事件与业务代码无直接耦合。注意不要把业务脚本随意加async。如果你的脚本里调用了一个全局函数、操作了某个 DOM 节点或者依赖另一个库的存在async 就可能是 bug 之源。这类带依赖、带 DOM 操作的脚本老老实实用 defer 才是正解。5. defer 和 async 二者关键的五个差异对比前面用了大量篇幅把机制讲透这里用一张表格把核心差异收拢起来。你完全可以把它作为速查表收藏。对比维度deferasync下载是否阻塞 HTML 解析否并行下载否并行下载执行时机等 HTML 解析完成后DOMContentLoaded 之前下载完立即执行时机不确定执行顺序按文档中出现顺序严格执行不保证按下载完成顺序执行执行时 DOM 是否就绪一定就绪不一定可能解析中途对 DOMContentLoaded 的影响会延迟其触发可能延迟取决于执行时机适用场景业务脚本、有依赖关系的脚本、需操作 DOM 的脚本独立第三方脚本、统计脚本、无依赖脚本这张表里最有信息量的两个点“执行顺序”这一行面试官常以此设陷阱。你只要记住“defer 有序async 无序”就够了。“DOM 是否就绪”这一行这是决定脚本能否安全操作 DOM 的底层原因。async 执行时 DOM 状态不可控所以脚本内部不能直接假设 DOM 存在。还有一个附加知识点值得提一下同时写defer和async时async会覆盖defer的行为。比如script async defer srcmain.js在支持 async 的浏览器里会按 async 处理还是 defer 处理答案是async 生效defer 被忽略。这是历史遗留的兼容技巧旧版浏览器不支持 async 时这个写法会退化为 defer 行为而在现代浏览器中async 优先。不过实际开发中没必要这么写明确选一个就好。6. 实测数据与性能影响白屏时间、脚本完成时间、TTI理论讲得再漂亮不落到数据上总感觉不踏实。我专门跑了一组小实验用最简单的 HTML 页面分别用普通 script、defer、async 三种方式加载同一批脚本看它们在真实浏览器里的表现差异。6.1 实验设置测试页面一个包含约 2000 个 DOM 节点的静态 HTML 页面。待加载脚本三个独立的 JS 文件a.js、b.js、c.js每个约 50KB。测试工具Chrome DevTools 的 Performance 面板模拟慢速 4G 网络。指标HTML 解析完成时间、三个脚本全部执行结束时间、DOMContentLoaded 触发时间。需要说明的是这组数据是我在自己机器上的实测反映的是相对趋势不同环境会有数字差异但相对关系是稳定的。6.2 三种方式的数据对比加载方式HTML 解析完成时间脚本执行完成全部DOMContentLoaded 触发时间普通 scripthead 内约 620ms约 610ms约 625msdefer约 300ms约 520ms约 525msasync约 300ms约 500ms约 505ms从这个实验能直观看到几个结论第一defer 和 async 都能显著提前 HTML 解析完成时间。普通 script 在 head 里会阻塞解析直到脚本下载并执行完才继续因此解析完成时间被拖到了脚本执行结束之后620ms 左右。而 defer/async 的脚本下载并行化解析在 300ms 左右就完成了——差距超过一倍。这在真实场景里就意味着白屏时间明显缩短。第二defer 与 async 的脚本执行完成时间差异不大。在实验里async 比 defer 稍快500ms vs 520ms这是因为 async 脚本下载完立刻执行不需要等待 DOM 解析完。但这点差距在高性能网络环境下几乎可以忽略而且它是用“执行时机不可控”换来的值不值得要看你脚本的性质。第三DOMContentLoaded 触发时间与脚本执行完成时间高度相关。defer 脚本必须等到 DOM 解析完成并执行完才触发 DOMContentLoadedasync 脚本因为可能赶在解析完成前执行反而不会过度延迟 DOMContentLoaded。比较意外的是在慢速网络下async 的 DOMContentLoaded 甚至比 defer 略早。6.3 对真实项目性能的影响从实验数据外推到真实项目如果一个页面有 5 个外部脚本全部不加属性放在 head 里解析会被严重阻塞首屏渲染会非常慢。全部改为 defer 之后解析不被阻塞首屏会明显加快代价是 DOMContentLoaded 稍微延后。如果你的页面脚本之间没有依赖、也不操作 DOM完全可以选择 async 来追求极致的加载性能。但绝大多数业务脚本做不到“无依赖、无 DOM 操作”所以 defer 是更稳妥的默认选择。很多性能优化文章会告诉你“去掉阻塞脚本”“把脚本移到 body 底部”“使用 defer/async”但它们很少解释为什么。我希望上面的数据能让你心里有数defer/async 的核心价值是把“下载”从“解析链路”中解放出来换来更快的首屏渲染。7. 实际操作中的经验与踩坑记录最后这部分是实战中摸索出来的经验。我在项目里见过不少因为不理解 defer/async 而产生的线上 bug梳理几个典型场景供你参考。7.1 坑一defer 脚本里的 DOM 操作真的安全吗答案安全但要区分是“DOM 节点已解析”还是“页面资源已加载”。defer 只保证 DOM 树已经构建完成不保证图片、iframe、字体等资源已经加载完成。比如你在一个 defer 脚本里写const img document.getElementById(hero); console.log(img.naturalWidth); // 可能得到 0naturalWidth反映图片是否加载完成。defer 脚本执行时DOM 节点确实存在但图片资源可能还在加载中此时naturalWidth是 0。如果脚本要依赖图片尺寸做布局这里就会踩坑。正确做法是监听window.load所有资源加载完成再读取或者直接监听图片自身的load事件。这个坑的本质是defer 解决的是“DOM 就绪”不是“资源就绪”。两者概念要分清。7.2 坑二async 脚本如果在 DOM 解析中途执行操作 DOM 会报错我第一次用 async 时也踩过这个坑。把一段操作页面元素的代码放进 async 脚本结果浏览器报Cannot read property addEventListener of null。原因很简单脚本执行时目标 DOM 节点还没被解析出来。从此我给自己定了一条规矩操作 DOM 的代码要么放进 defer 脚本要么在 async 脚本里自己监听DOMContentLoaded或load。不要迷信“async 快”就无脑给所有脚本加 async。对一个本来应该安全操作 DOM 的脚本来说async 带来的那几十毫秒性能提升远远抵不上它在特殊网络条件下带来的不确定性。7.3 坑三动态创建的 script 标签默认是 async 行为这是很多面试题不会明说、但实际开发经常遇到的细节通过 JavaScript 动态插入的script元素document.createElement(script)appendChild默认是异步加载的相当于 async 行为。比如const script document.createElement(script); script.src main.js; document.body.appendChild(script);这个 main.js 会在下载完成后立即执行不管页面当前状态如何。如果后续代码依赖 main.js 执行结果就有了竞态风险。解决办法是给动态脚本设置script.async false让它退化为按序执行的 deferred 行为或者监听script.onload回调在回调里执行依赖逻辑。这个细节我在生产环境里排过不少雷尤其常见于“按需加载 SDK”的场景。写出script.async false这行代码能避免大量莫名其妙的偶发报错。7.4 坑四内联脚本无法使用 defer/async还有一个经常被忽略的事实defer和async属性只对外部脚本有src属性生效。对于内联脚本比如script // 这里写的是内联代码 /script加不加 defer/async 都不影响它的执行时机——它依然是同步的解析到哪里就执行到哪里。如果你想延迟一段内联代码的执行只能把它包进一个外部 JS 文件再引 defer或者用setTimeout、事件监听模拟延迟。这一点在实际开发里也容易造成认知差有人以为给内联脚本加上 defer 就会等 DOM 解析完再执行实际根本不是这么回事。8. 工程化建议现代前端构建中该如何选型现在前端的工程化程度已经很高很多人已经没有机会手动写script标签了。但理解 defer/async 依然重要——因为你随时可能在写原生项目、维护旧页面、或者调试构建产物时与它们相遇。8.1 在构建工具中看 defer/async以常用的构建工具为例Vite开发模式下用 ESM 加载模块生产构建输出的是script typemodule。模块脚本默认是 deferred 执行如果不用async属性所以不会阻塞解析。webpack HtmlWebpackPlugin默认会在打包后的 HTML 里注入script defer或普通 script。部分版本默认就带 defer具体以你使用的插件配置为准。自定义构建如果你的脚本是通过模板引擎直接拼 HTML就需要手动指定 defer/async。如果你在做性能优化审计建议在 DevTools 的 Network 面板里点击某个脚本看它的 “Initiator” 和 “Timing”再结合 Performance 面板确认它是否阻塞了解析。很多时候问题不是“用 defer 还是 async”而是“这个脚本根本是第三方库却没有任何加载策略”。8.2 面对“该选哪个”时我的决策逻辑遇到“这个脚本应该用 defer 还是 async”的问题我一般按下面这个顺序判断这个脚本是外部文件吗——如果是内联代码两个属性都不生效跳过这一题。脚本之间有没有依赖关系——有则排除 async。如果用 defer且依赖链很长要注意脚本内依赖的加载顺序仍然按 HTML 顺序。脚本是否需要操作 DOM——需要则强烈建议 defer。async 可能会导致拿到空节点。脚本是否越早执行越好——是并且独立无依赖选 async。比如统计脚本晚几秒执行也能完成上报。其他情况——默认 defer。这套逻辑在大多数页面里都能直接套用不用每一次都纠结。8.3 性能优化时还应该关注什么最后多说一句defer/async 只是脚本加载优化的一个维度。性能优化是一个系统性问题脚本数量、体积、缓存策略、CDN 分布、HTTP 版本都是变量。我见过不少团队把一个脚本从普通改为 defer就觉得完成了性能优化但页面上其实有 20 个 200KB 的第三方库光改属性是救不回来的。合理的手段应该包括合并小脚本减少 HTTP 请求。区分“首屏必需脚本”和“可延迟脚本”把非关键的都延迟加载。对主业务脚本用 defer对独立第三方脚本统计、监控用 async。配合代码分割与按需加载让首屏只加载真正需要的代码。defer 和 async 是前端性能优化工具箱里非常趁手的两把工具但它们解决的是“下加载时机”的问题。想成为真正靠谱的前端工程师要理解它们各自的脾气知道什么场景该用哪一把也要知道什么时候该换更大的工具。希望本文能把这两个概念彻底钉进你的知识体系里下次遇到它们时你心里是不慌的。