资讯详情

资讯详情

async和defer同时出现会怎样?浏览器脚本加载机制详解

前段时间做代码评审同事提交里有一行script async defer src...。我问他为什么两个属性一起写他说是从公司某个老模板里直接抄来的自己也说不清效果。这不算个例很多人被问到“async 和 defer 同时出现会怎样”时答案都停留在“一个延迟执行一个立即执行”的模糊层面。其实这个问题的答案非常直接——async 优先defer 被忽略。但要想真正弄懂它需要把浏览器解析脚本的完整流程梳理一遍。这篇文章就把这层逻辑拆开先讲清楚两种属性各自做什么再讲规范为什么这样设计最后给出实际项目里的处理方式希望对正在做前端性能优化或补脚本加载知识的读者有帮助。1. 这行代码到底从哪来async defer 同时出现的真实场景1.1 代码评审里的高频发现我先说个现象。过去几年我陆续看过几十个不同规模的前端项目几乎每个项目里都能翻到async defer并列的 script 标签。有些在首页头部有些在第三方 SDK 接入代码里还有一些藏在广告位脚本、数据上报脚本中。问当事人为什么要这样写得到的回答大致分三类第一类是“模板里带的不敢删”第二类是“记得这样写能保证下载不阻塞但具体行为说不清”第三类干脆说“网上搜到说两个一起用性能最好”。这三类回答都有一个共同点没有人真正验证过浏览器会怎么处理这两个属性。等我把行为差异讲清楚之后大多数人第一反应都是“原来这么多年都白写了”。所以这个问题的第一层价值就是帮我们避免继续在代码库里传播一个说不清的写法。1.2 历史模板与复制粘贴的遗留问题为什么这种写法流传这么广我查过一些老教程和开源项目的历史提交发现async defer同时出现很大程度上是一个“兼容性降级”的历史残留。在 async 刚被广泛支持的那几年有些开发者为了兼容不支持 async 的旧浏览器会故意写async defer期望现代浏览器走 async 逻辑旧浏览器忽略 async 后走 defer 逻辑。这个思路在一个特定历史时期里不能说完全没道理但它建立在“旧浏览器对 defer 的支持也足够好”的前提下。问题在于随着浏览器版本迭代今天所有主流浏览器都支持 async这种“降级写法”已经失去了存在的意义反而会给代码维护者造成理解负担。更麻烦的是如果团队里有人不知道这个历史背景他看到async defer时会误以为“两个属性同时生效”进而对脚本加载时机产生错误判断最终排查问题时会走很多弯路。1.3 为什么这个问题值得写透从表面看这只是一个属性组合的冷门知识点但深挖下去它牵扯出的是浏览器脚本加载机制的整体脉络HTML 解析器如何被脚本阻塞、defer 和 async 在下载和执行两个阶段的差异、DOMContentLoaded 与脚本执行顺序的关系、预加载扫描器的作用、动态插入脚本和 module 脚本的特殊规则。这些内容单独拎出来每一个都能写一篇文章但用一个具体问题把它们串起来反而更好理解。我写这篇文章的目标也很直接先给结论再拆原理最后给出能落到代码审查和性能优化里的具体建议。你不用把规范背下来只需要看完之后能清楚判断“我这个脚本到底该用 async 还是 defer”以及“看到别人写 async defer 时该怎么处理”。2. 先梳理基础行为三种脚本属性的下载与执行差异在回答“同时出现会怎样”之前必须先把“单独出现会怎样”讲透。很多误解都是因为对单一属性的行为边界不清晰。2.1 传统 script整个页面等它一个不带任何属性的script src...是浏览器最古老的脚本处理方式。解析器遇到它时会停止解析后面的 HTML先发起下载请求等下载完成并且执行完脚本内容后再继续解析剩余文档。这个等待过程是“下载 执行”双重阻塞。用生活场景类比你在流水线上组装玩具结果发现少了一个零件只能停下整条线等零件送到再继续。传统 script 就是这个“停下整条线等零件”的角色。如果这个脚本体积大、网络慢页面白屏时间就会明显拉长。所以早期前端优化都会强调“把普通 script 放到 body 底部”目的就是让解析器尽量先处理完页面内容再被脚本阻塞。2.2 defer文档解析完再干活defer 属性改变了两个关键点。第一下载过程不阻塞 HTML 解析解析器遇到带 defer 的脚本时会发起下载请求然后继续往下解析第二执行过程被推迟到“文档解析完成之后、DOMContentLoaded 事件触发之前”并且多个 defer 脚本严格按照文档顺序执行。还是用流水线类比defer 相当于你提前把零件订单发出去继续组装其他部分等到整条产线都走完了再统一处理这些后到零件。defer 脚本通常放在 head 里因为反正下载阶段不阻塞解析放在前面反而能更早发起请求缩短整体等待时间。需要注意的是defer 的“执行等待”不只等自己下载完还要等整个文档解析完。如果一个 defer 脚本下载很快它也必须等到解析器处理完所有 HTML 才能执行如果一个 defer 脚本下载很慢DOMContentLoaded 会一直等它执行完才触发。这两条规则很多人会忽略但它们对理解 DCL 时机非常重要。2.3 async下载完就插队async 属性的核心语义是“下载不阻塞解析下载完成后立即执行”。解析器遇到带 async 的脚本会像 defer 一样先把请求发出去然后继续解析但脚本一旦下载完成就会立刻插入到当前解析流程里执行不管此时页面解析到哪个位置。多个 async 脚本之间没有顺序保证谁先下载完谁先执行。这跟 defer 的“按文档顺序执行”有本质区别。继续用流水线类比async 相当于零件一到手你就停下当前工作当场把它装好装完再继续之前的组装步骤。如果好几个零件同时到先到的先装不管它是不是清单上排前面的那个。因为 async 不等待文档解析完成所以它执行时页面结构可能只构造了一部分甚至 DOM 还没完全可用。这也决定了 async 适合放那些不依赖 DOM、不依赖其他脚本的独立逻辑比如访问统计、第三方监控、广告脚本。2.4 三种行为的对照表为了后面讨论方便我把三种脚本行为对比如下属性组合下载是否阻塞解析执行时机执行顺序DOMContentLoaded 是否等待无属性是下载和执行都会阻塞下载完成后立即执行文档顺序不适用解析被阻塞DCL 自然在之后defer否文档解析完成后DCL 触发前文档顺序是所有 defer 脚本执行完才触发async否下载完成后立即执行不保证先下载完先执行否async 脚本不保证在 DCL 前async defer否行为等同 async下载完就执行不保证否这张表里最关键的一行就是最后一行async defer最终的呈现效果等同于单独的 async。为什么会这样下一节从规范层面解释。3. 规范给出的标准答案async 优先defer 被忽略3.1 先给结论HTML Standard 的脚本处理模型规定得非常干净如果 script 元素带有 src并且 async 属性存在就走 async 分支defer 属性只在 async 不存在时才参与判断。换句话说同时写async defer时浏览器会直接把 defer 当作不存在行为和只写async完全一样。这一点我在多个浏览器里实测过包括 Chrome、Safari、Firefox行为完全一致。脚本下载完成后立即执行不等待文档解析不保证顺序也不参与 DOMContentLoaded 的等待队列。3.2 规范流程里的判断顺序要理解为什么 async 优先可以看浏览器解析器遇到一个带 src 的 script 元素时的大致判断流程解析器遇到 script 标签读取 src、async、defer、type 等属性。如果脚本需要执行有 src 且 type 不是浏览器不认识的自定义类型等发起下载请求。检查是否有 async 属性如果有将该脚本标记为“异步脚本”下载完成后立即执行。如果步骤 3 没命中再检查是否有 defer 属性如果有将该脚本标记为“延迟脚本”等文档解析完成后按顺序执行。如果步骤 3 和步骤 4 都没命中该脚本就是普通脚本解析器停下来等它下载并执行。从这个流程可以清楚看到async 的判断链路排在 defer 前面。只要 async 存在defer 根本走不到被处理的环节。这也是为什么我说“async 优先defer 被忽略”不是经验总结而是规范里明确写死的处理顺序。3.3 一个常见误解它不是“看情况二选一”网上很多讨论里有人会把这个问题的答案描述成“如果脚本下载完时页面还没解析完就按 async 执行如果页面已经解析完了就按 defer 执行”。这个说法是错的。如果浏览器的处理逻辑真是“看情况二选一”那么同一个async defer脚本在不同网络环境下表现会不一致这在规范层面是不可接受的。事实上只要 async 属性存在解析器从一开始就把脚本归入 async 执行路径defer 属性从头到尾就不会参与决策。无论脚本下载快还是慢无论页面解析完成与否它的执行时机都由 async 规则决定下载完成即执行。我猜测这个误解的产生是因为有人把“async 脚本可能出现在 DOMContentLoaded 之前也可能出现在之后”这个现象误解成了“浏览器会根据解析进度动态切换到 defer”。这两件事完全不同前者是 async 脚本自身的执行窗口特性后者却不存在的机制。理解清楚这一点很多后续排查都会顺畅很多。4. 从解析器视角还原时间线同一份脚本不同写法下的执行路径4.1 defer 场景一个标准的按序执行流程假设 HTML 页面里有这样两个脚本script defer srca.js/script script defer srcb.js/script如果在 DevTools 的 Performance 面板里记录一次完整加载时间线大致是这样的解析器遇到第一个 script 标签发起 a.js 下载请求不阻塞继续解析。解析器遇到第二个 script 标签发起 b.js 下载请求不阻塞继续解析。无论 a.js 和 b.js 谁先下载完执行都要等文档解析完成。HTML 解析结束后先执行 a.js再执行 b.js全部执行完再触发 DOMContentLoaded。这里最容易被忽略的是第四步的顺序保证。a.js 即使比 b.js 慢很多也必须等 b.js 执行完之后再执行因为 defer 脚本的执行顺序与文档顺序一致。如果一个页面里 defer 脚本之间有依赖关系这种顺序保证就很关键。4.2 async 场景执行时机由网络决定如果把上面两个脚本改成 asyncscript async srca.js/script script async srcb.js/script时间线会变成解析器遇到第一个 script 标签发起 a.js 下载请求不阻塞继续解析。解析器遇到第二个 script 标签发起 b.js 下载请求不阻塞继续解析。假设 b.js 体积更小或者 CDN 更快b.js 先下载完成此刻立即执行 b.js解析器暂停。b.js 执行完解析器继续解析 HTML。稍后 a.js 下载完成立即执行 a.js。执行完的 async 脚本不会等待彼此也不会等文档解析完成。在这个场景里如果 a.js 里引用了 b.js 定义的变量执行顺序颠倒后就会直接报错。这也是 async 脚本必须互相独立的原因。4.3 两个常被忽略的细节第一个细节async 脚本的执行过程仍然可能阻塞解析。很多文章说 async 不阻塞页面严格来说不完整。它不阻塞的是“下载”这个阶段但脚本一旦下载完成执行时解析器一样会停下来等它跑完。执行时间越长对页面解析和渲染的影响越大。所以即使是用 async 加载的第三方代码如果它本身很重首屏性能一样会受影响。第二个细节DOMContentLoaded 对两类脚本的等待规则不同。所有 defer 脚本都必须在 DCL 之前执行完这意味着一个下载慢的 defer 脚本会直接拖延 DCL 触发时间。async 脚本则完全相反如果它在 DCL 触发时还没下载完DCL 不会等它页面会先触发完成事件脚本晚点到了再执行。放到实际场景里如果团队用 DCL 时间作为首屏性能指标两者对指标的影响路径完全不同。这两个细节在同时写async defer时也成立——因为行为等同 async所以页面不会等它但它的执行过程依然可能阻塞解析。理解这些才能真正把握脚本加载对性能的影响。5. 边界情况与例外动态脚本、module、老浏览器都会改写答案前面说的都是“常规外链脚本”的情况。实际开发中还有几种写法会改变 async 和 defer 的真实行为我把它们单独列出来因为很多人就是在这几个场景里栽了跟头。5.1 动态插入的 script 节点defer 从来不起作用用 JavaScript 动态创建 script 标签是非常常见的按需加载方式const s document.createElement(script); s.src http://example.com/foo.js; s.async true; s.defer true; document.head.appendChild(s);在这种场景里defer 属性没有任何作用。规范规定 defer 只对“解析器插入的脚本”parser-inserted script有效也就是 HTML 源码里直接书写的 script 标签。动态创建出来的脚本不在这个范围内所以就算你同时设置 async 和 defer真正生效的仍然是 async。还有一个相关细节动态脚本的 async 属性默认就是 true。也就是说如果你创建 script 标签后插入到文档里即使不设置 async它默认的行为也是“下载完就执行”不会阻塞解析。只有当你显式设置s.async false时它才会下载后尽快执行但因为不是 parser-inserted同样不走 defer 逻辑。这个坑在做 SDK 注入、动态加载远程组件时特别容易踩到。我之前看到过一个项目在动态加载脚本时给节点同时加了 async 和 defer意图是让它在旧浏览器里晚一点执行结果行为完全由 async 决定根本达不到预期效果。5.2 typemodule 的默认 defer 语义ES Module 脚本的情况更值得注意。一个普通 module 脚本script typemodule srcm.js/script它的默认加载行为等同于 defer下载不阻塞解析、按文档顺序执行、执行时机在文档解析完成后和 DCL 之前。所以我经常说模块脚本天生就是 defer 的。那如果给 module 脚本加上 async 呢script typemodule async defer srcm.js/script过程和普通脚本一样async 属性会让脚本在下载完成后立即执行不再保证按文档顺序也不再保证在 DCL 之前。defer 属性在 module 脚本上没有任何额外效果因为 module 不写 defer 也已经是延迟执行了。所以这里仍然是 async 优先而且这个优先级同时压过了“模块默认的 defer 语义”和“显式的 defer 属性”。这个知识点对现代前端项目很重要因为很多团队都已经切到 ES Module 开发。如果你在某个页面里看到typemodule async defer这样的组合可以直接判断它的行为等价于typemodule async。5.3 老浏览器降级传说把 async defer 当兼容方案的历史原因前面提到过async defer同时存在有一部分历史原因是为了降级。具体来说早期的 Internet Explorer 不支持 async但支持 defer。有些开发者想让新版浏览器用 async 的高性能路径旧浏览器则退回到 defer 的执行时机于是写出了两个属性并列的代码。在那些旧浏览器已经退出主流视野的今天这个降级方案没有任何保留价值。一方面所有现代浏览器都对 async 有完整支持另一方面如果旧浏览器对 defer 的支持也存在缺陷这个降级并不能保证行为一致。新项目里再这么写反而会让整个团队对脚本加载时机的理解变得混乱。如果是为了兼容性而写注释里一定要写清楚原因如果只是顺手抄来的建议直接改成单一属性。5.4 预加载扫描器为什么下载看似都是并行的还有一个现代浏览器特有的机制值得提一下就是预加载扫描器preload scanner。浏览器的主解析器在处理普通 script 时会被阻塞但预加载扫描器会提前浏览后面的 HTML发现带 src 的资源就先把请求发出去。这就是为什么我们在 Network 面板里经常看到一个普通 script 还没下载完后面的 async 或 defer 脚本已经在排队下载了。预加载扫描器会同时预取 async 和 defer 脚本所以这两类脚本在“下载不阻塞解析”这件事上表现得很接近。它们的差异主要体现在执行阶段而不是下载阶段。这个机制对理解async defer组合的影响在于如果有人在 HTML 里写了async defer预加载扫描器照样会提前发现并下载它所以下载阶段看不出异常但到了执行阶段因为 async 优先它的执行时机不再受 docment 解析完成这个条件约束。Network 面板里的 Waterfall 图如果只看下载段很容易放过这个隐藏差异必须结合 Performance 面板里的 Main 线程任务才能看到问题。6. 项目里遇到这种写法我建议按这个顺序处理理论部分讲完了最后落到实际工作中。如果代码评审时看到async defer或者你自己在开发时犹豫要不要这样写可以参考下面的处理思路。6.1 按脚本依赖关系决定最终属性遇到一个脚本先别急着选属性先问三个问题这个脚本依赖 DOM 吗如果它需要操作页面节点、监听表单、读取元素尺寸defer 更合适因为能保证文档解析完成后再执行。用 async 的话脚本可能在 DOM 还没构建完时执行大概率会报错。这个脚本依赖其他脚本吗如果一个脚本需要用到另一个脚本定义的全局变量或函数它们之间存在隐式依赖就必须用 defer 并且保证标签顺序正确。async 不保证顺序一旦一个脚本先执行依赖它的脚本就直接挂掉。这个脚本完全独立吗如果它是统计代码、错误监控、广告脚本这类不依赖页面结构的独立逻辑可以用 async让它在下载完成后尽快执行不会延迟 DOMContentLoaded。按照这三个问题走下来一个脚本应该用 async 还是 defer 基本是明确的。只有当它在三问之后仍然模棱两可才需要进一步结合业务来定。6.2 评审时看到 async defer 的三项检查在代码评审里遇到async defer我一般建议做三项检查第一确认脚本有没有被 defer 语境下的“顺序保证”所依赖。比如它后面还有其他 defer 脚本并且后者依赖前者的执行结果这时候用 async 会把顺序彻底打乱。第二确认脚本有没有被页面其他代码隐式依赖。如果初始化逻辑写在另一个普通脚本里这个 async 脚本可能还没执行完那边就直接调用了。第三确认写这个属性的人是否理解自己在做什么。如果只是抄来的建议改成明确的单一属性并顺手加一行注释说明选择理由。这一套检查做完大部分async defer都会被改成defer因为真正适合用 async 的独立脚本也不会有人多写一个没用的 defer 进去。6.3 用 DevTools 和 Performance API 验证加载行为如果对脚本执行时机没有把握不要靠猜直接用工具验证。我常用的方法有三种。第一种是用 DevTools 的 Performance 面板。录制一次页面加载在 Main 线程任务里找到脚本的 Evaluate Script 事件看它和 Parse HTML、DOMContentLoaded 事件之间的相对位置。如果脚本执行在 Parse HTML 结束之后、DCL 之前那是 defer 的执行窗口如果脚本执行在解析中段强行插入那就是 async 行为。第二种是看 Network 面板的 Waterfall。脚本行会显示“下载完成到开始执行”之间的间隔。defer 脚本即使下载完成也要等解析结束才执行所以这里会有一段明显等待async 脚本下载完通常很快开始执行等待时间极短。第三种是用 Performance API 在脚本内部打点console.time(script-execute); window.addEventListener(DOMContentLoaded, () { console.timeEnd(script-execute); });如果脚本在 DCL 之前执行timeEnd 输出的时间表示“脚本执行到 DCL 的间隔”如果脚本在 DCL 之后执行timeEnd 会输出负值。这个方法虽然粗糙但能快速判断脚本执行窗口在哪一侧适合在代码里临时验证。6.4 最后的一点个人经验回头看开头那个评审最后我们把async defer改成了defer因为那个 SDK 需要操作页面上的 slot 节点。这个问题的最佳价值不在于记住“async 优先”这五个字而在于愿意把同时写两个属性的那次操作当成一次理解浏览器加载机制的机会。以后我再看到这种标签第一反应不是直接删而是按上面这套逻辑拆一遍它为什么在这里它依赖什么它在什么时候执行才是正确的然后才动手改。这个过程多走几次你对脚本加载的理解会明显上一个大台阶排查线上加载问题时也会更有底气。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →