尧图精选

前端性能优化核心:彻底搞懂异步加载的defer、async与动态导入

🕒 发布时间:2026/10/1 19:26:43 📁 来源:尧图网络
1. 异步加载的本质从“排队检票”到“多窗口办理”这两年无论是做前端还是做客户端大家挂在嘴边最多的词一定有“性能优化”。可聊得多了反而容易把最基础的东西忽略掉。这个章节想聊的异步加载恰恰是性能优化体系里最底层、最核心的一块基石也是我做了多年性能优化之后觉得最值得反复琢磨的原理。先说一个最常见的误区。很多人觉得“异步加载”就是把script标签往body末尾一扔或者给脚本加上async属性就算完事。这种理解不能说错但距离“原理”还差得远。异步加载真正的价值在于它改变了浏览器处理资源的顺序模型。什么是顺序模型我常用一个生活化的类比来解释。早期的浏览器处理网页资源就像是火车站只有一个售票窗口所有人必须排成一队前面的乘客买完票后面的才能往前走。传统script标签放在head里就是这个效果——浏览器碰到脚本就必须停下来下载、解析、执行这期间整个页面的渲染全部冻结后续哪怕已经下载完毕的HTML和CSS都得原地等待。这个“排队”的过程专业术语叫渲染阻塞Render Blocking。异步加载干的活相当于把那个唯一的售票窗口改成多个窗口甚至让用户提前把票买好到站直接进。浏览器遇到带有async或defer属性的脚本不会停下渲染流程而是在后台把脚本文件拉下来等空闲时机再执行。这么一改页面的白屏时间、首屏可交互时间都会肉眼可见地缩短。从原理层面拆解浏览器有一个主线程所有的JavaScript执行、DOM解析、样式计算都发生在主线程上。同步加载的脚本会直接“霸占”主线程谁来了都得排队。而异步加载的本质是利用浏览器底层的事件循环Event Loop机制把资源下载和执行拆成两件独立的事情下载可以丢给网络线程池并行进行执行再回到主线程排队。这就是为什么异步加载能在不破坏代码逻辑顺序的前提下大幅缩短关键渲染路径。我见过很多项目优化做了半年图片压缩、CDN加速、代码混淆全部都上了但效果始终不理想。最后定位下来问题根本不在资源大小而是head里那七八个同步脚本硬生生把首屏拖到了三秒以后。这说明一个道理性能优化的排序里加载策略的优化优先级要高于资源体积优化。资源再小只要是同步链路上的阻塞节点它的代价都会指数级放大。不管你是做Web、小程序还是Hybrid App理解异步加载的原理都能帮你找到性能瓶颈的“七寸”。下面这些内容我会把原理讲透再给出可以直接抄作业的实操方案。1.1 浏览器的关键渲染路径要理解异步加载为什么管用先得搞清楚浏览器从拿到HTML到屏幕上出现画面的完整链路。这条链路叫关键渲染路径Critical Rendering Path由五步构成HTML被解析成DOM树CSS被解析成CSSOM树DOM和CSSOM合并生成Render Tree渲染树Layout布局计算每个节点的几何位置Paint绘制把像素画到屏幕上这里面有个细节值得注意DOM的解析过程是可以被JavaScript中断的。为什么因为浏览器不知道脚本里会不会有document.write()这样的操作也不知道脚本会不会通过getElementById之类的方法查询当前已经解析出来的节点。为了保证数据的完整性碰到同步脚本只能暂停解析等脚本跑完再继续。这就是阻塞的本质。它不是网络慢导致的而是浏览器为了“确定性”付出的代价。CSS也阻塞渲染但阻塞方式和JS不一样。CSS不会中断DOM解析但它会阻断Render Tree的构建。换句话说CSS没加载完DOM构好了也没用画不出来。理解了这一点就能明白为什么业界总说“CSS要尽量提前内联JS要尽量异步加载”——它们阻塞的环节完全不同。1.2 同步加载的代价到底有多大我用一个具体场景说明白这个代价。假设某个页面的head里有这样一个脚本script srchttps://cdn.example.com/lib-util.js/script再假设这个脚本文件体积是200KB网络往返时间RTT是100ms带宽是2Mbps。粗算一下下载这个文件需要至少800ms。再加上解析执行JavaScript的时间——V8引擎解析200KB源码大约需要50-100ms——整个流程至少白费了1秒。这1秒只是表象。真正的放大器在后面浏览器是流式解析HTML的。如果遇到脚本时DOM才解析到一半后半段HTML的解析工作全部搁浅那么依赖这些DOM元素的渲染、布局、绘制全部顺延。一个200KB的脚本可能最终拖慢了所有内容呈现前后加起来两三秒的额外延迟。更闹心的是咱们前端有个“1秒定律”——用户等待超过1秒流失概率就会大幅上升。同步加载浪费掉的恰恰是最昂贵的首屏时间。有了这个认知基础再来看异步加载的两种主流手段很多问题就能自己想明白了。2. 异步加载的三大主力方案defer、async与动态导入异步加载听起来是个笼统的词实际落地的时候就三板斧defer、async、动态import()。很多人分不清defer和async的区别面试被问倒。这不能怪大家因为这俩属性从字面上看确实很像。但原理和适用场景差异很明显我尽量用大白话讲透。2.1 defer把脚本推迟到DOM解析完成之后defer这个属性的行为官方定义是“表示脚本将在文档解析完成后、发出DOMContentLoaded事件之前执行”。它的执行流程是这样的HTML解析开始 → 遇到defer脚本立即开始下载不阻塞解析 → HTML解析完成 → 所有defer脚本按文档顺序执行从这里能看出defer的两个特征下载不阻塞解析脚本下载和HTML解析同时进行执行不抢时机所有defer脚本耐心等DOM解析完再按顺序执行所以defer特别适合那些依赖完整DOM结构、且要求内部执行顺序的脚本。比如页面底部要用的功能库、需要操作全站节点的统计脚本、商品页的交互逻辑用defer最保险。我举一个实战中踩过坑的例子。曾经有个团队把埋点脚本加上了async属性结果发现同一个页面里埋点数据的上报顺序经常是乱的。排查了半天才想起来async不保证执行顺序两个都标了async的脚本谁先下载完谁先执行。行为埋点A依赖埋点B先上报用户身份信息结果B晚到了链路直接断了一半。后来把两个脚本改成defer顺序稳定了问题才彻底消失。2.2 async下载完立刻执行不等待任何人async的行为模式和defer正好相反。它的执行流程是这样的HTML解析开始 → 遇到async脚本立即开始下载不阻塞解析 → 下载完成立刻暂停解析执行脚本 → 执行完成继续解析HTML看出区别了吗async的执行时机是“下载完就执行”没有等待、没有排序、没有保证。如果两个async脚本同时下载先回来哪个就先执行哪个。DOM可能已经解析完了也可能还没解析完。所以async只适合那些不依赖DOM结构、也不依赖其他脚本顺序的独立功能。典型场景包括第三方广告脚本独立运行晚一点执行问题不大数据上报/埋点脚本不操作DOM图表库懒加载等容器出现之后加载但自身逻辑独立注意async脚本执行时依然会阻塞HTML解析。只是它把“下载”这个最耗时的环节从主线程剥离开了。下载虽然并行执行仍然抢占主线程。所以async脚本不要放太多否则执行阶段照样卡主线程。2.3 defer与async的选择一张表说清楚我总结过一张表格团队里的人每次拿不准的时候就看这个对比维度deferasync下载是否阻塞解析否否执行是否阻塞解析否等解析完是下载完就执行执行顺序按文档顺序不保证执行时机DOM解析完成后DOMContentLoaded之前下载完成立即执行适用脚本依赖DOM、有顺序要求独立、无依赖、允许延迟一句话总结拿不准的时候用defer明确独立且可以乱序的时候用async。这个原则应对绝大多数业务场景都够用。2.4 动态import()与代码分割异步加载的高级形态defer和async解决的是“单个脚本文件什么时候加载”的问题。但在现代前端工程里我们面对的不是一个文件而是几百个模块。理想状态是首屏只加载首屏需要的代码其他代码等用户真正用到某个功能时再加载。这就轮到动态import()登场了。// 传统静态导入 import { reportData } from ./analytics; // 动态导入按需加载 const handleClick async () { const { reportData } await import(./analytics); reportData(button, click); };动态import()是ES2020正式标准化的语法底层原理返回了一个Promise。当这段代码执行到import()那一步浏览器才会去发起网络请求加载对应的模块文件。配合Webpack、Vite这些构建工具import()会被编译成“代码分割”Code Splitting的语法每个动态导入的模块自动打包成独立的chunk文件。我见过最夸张的优化案例一个后台管理系统首屏JS从4.2MB优化到1.1MB核心动作就是给路由改成动态导入。原本是把十几个业务模块全量打包成一个bundle浏览器得一次性下载4.2MB优化后首屏只下载登录页和布局框架的代码其余模块跳到对应路由才加载。首屏加载时间从6.8秒降到2.3秒用户的体感天差地别。这个优化的前提是框架支持路由级懒加载。以Vue为例写法是这样的// 路由配置中 const routes [ { path: /dashboard, component: () import(../views/Dashboard.vue) }, { path: /user-management, component: () import(../views/UserManagement.vue) } ];React生态对应的是React.lazy和Suspenseconst Dashboard React.lazy(() import(../pages/Dashboard)); function App() { return ( Suspense fallback{Loading /} Dashboard / /Suspense ); }这些写法本质都是异步加载在应用层的高级运用。核心思想就一个把不必要的代码从首屏加载链路中剥离出去。3. 性能优化实战从指标拆解到优化落地说完了异步加载的原理很多人会问那我具体怎么判断哪里该异步、哪里不该异步呢这得有方法论支撑不能拍脑袋。这章我拆解一套自己在实战里反复验证过的流程。3.1 先定指标再谈优化优化之前必须建立衡量标准。不然改动之后说不清效果也没法验证得失。前端的核心性能指标现在基本统一到Web Vitals上了三个关键数字即所谓的核心网页指标每一项目对应不同的用户感知LCPLargest Contentful Paint最大内容绘制衡量首屏主要内容出现的时间。目标是低于2.5秒。这个指标直接跟“首屏资源加载顺序”挂钩。INPInteraction to Next Paint衡量用户交互到画面反馈的延迟。约等于“操作跟不跟手”低于200毫秒算优秀。页面上的同步长任务会严重拖垮它。CLSCumulative Layout Shift衡量页面布局抖动程度。低于0.1算良好。图片未预留空间、异步内容插入导致位移会直接拉爆这个指标。这三大指标里异步加载改善最明显的是LCP和INP。LCP好了用户看到主要内容的时间快了INP好了交互卡顿少了。有了这些指标作为标尺优化的方向就清楚了。3.2 资源加载优先级把带宽让给最重要的资源有了指标接下来要做的是给资源排优先级。很多人把优化等同于“减小体积”但实际项目中更常见的问题是重要资源没有优先加载次要资源反而抢占了网络通道。浏览器有一个资源加载优先级的调度机制通常会按资源类型和位置给不同请求打上不同优先级的标记。图片、脚本、CSS、字体优先级各不相同。这本来没问题但业务代码往往把规则搞乱了。我见过的一个典型场景首屏有一张主视觉大图而head里又插了两三个第三方统计脚本。浏览器会自动给脚本分配较高优先级结果重要的大图反而排队等待导致LCP拉胯。解决的思路通常两步走给重要资源提高优先级比如首屏大图用fetchpriorityhigh属性给非关键资源降低优先级第三方脚本全部异步加载并且加上loadinglazy懒加载属性!-- 首屏主图优先加载 -- img srchero-banner.jpg fetchpriorityhigh alt主视觉 / !-- 非首屏图片懒加载 -- img srcdetail-img.jpg loadinglazy alt详情图 /fetchpriority和loadinglazy这两个HTML原生属性是近几年浏览器性能优化体系里补上的重要工具。原理层面fetchpriority直接影响浏览器内部的请求调度器让高优先级的请求更早出发网络请求loadinglazy则告诉浏览器在资源出现在视口附近之前不要发起请求。这两行属性的成本几乎为零收效却很直接。我自己的项目里给首屏大图加了fetchpriority之后LCP从2.8秒优化到1.9秒就改了一行代码。3.3 CSS的异步加载别把所有CSS都全量阻塞前面说CSS会阻塞渲染很多人以为那把CSS全改为异步加载就行了。这个思路是错的。关键CSS必须同步渲染否则首屏会闪出无样式内容FOUCFlash of Unstyled Content。正确的姿势是把CSS拆成两块关键CSSCritical CSS覆盖首屏需要的样式体积通常很小直接内联进HTML的head里非关键CSS其余样式通过异步方式加载异步加载CSS有好几种做法。最可靠的方式是用JS动态创建link标签等页面空闲了再插入// 把非关键CSS放在页面加载后期再加载 window.addEventListener(load, () { const link document.createElement(link); link.rel stylesheet; link.href /css/non-critical.css; document.head.appendChild(link); });还有更讲究的做法是用浏览器原生的media属性切换技巧先用一个浏览器必然匹配的media值让CSS变成非阻塞加载加载完再切换回正常值。不过说实话日常开发用JS插入的方式就够用了原理简单维度清晰。3.4 实操示例一个页面从3.5秒到1.2秒的完整路径这里分享一个我近期改过的真实案例。某营销落地页原始加载时间3.5秒左右实验室环境模拟4G网络主要的性能瓶颈有三个head里有4个同步JS文件合计320KB首屏图片没有设置优先级被脚本请求抢占了带宽某个体积较大的图表库在首屏根本用不到却全量加载了200KB优化步骤按优先级排列第一步改造脚本加载方式把4个同步JS分析一遍其中一个统计数据上报脚本独立无依赖改成async另外三个是页面交互逻辑依赖DOM结构改成defer。!-- 修改前 -- script src/js/tracker.js/script script src/js/app.js/script script src/js/UI-lib.js/script script src/js/analytics.js/script !-- 修改后 -- script async src/js/tracker.js/script script defer src/js/app.js/script script defer src/js/UI-lib.js/script script defer src/js/analytics.js/script第二步给首屏图设置优先级img src/img/kv.jpg fetchpriorityhigh /第三步图表库改为动态导入原本这个图表库在统计图表区域用到但该区域位于页面第二屏。改成只在滚动到该区域时才加载。const loadChart async () { const { renderChart } await import(./heavy-chart-lib); renderChart(); }; // 用IntersectionObserver监听图表容器进入视口 const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) { loadChart(); observer.disconnect(); } }); observer.observe(chartContainer);改完以后在同样的模拟环境下做A/B对比结果很直观指标优化前优化后变化LCP2.9s1.1s下降62%页面完全加载3.5s1.8s下降49%首屏网络请求数2311减少52%总传输体积1.8MB1.1MB下降39%重点不是数字本身而是优化路径的排序先解决阻塞加载再解决资源优先级最后做按需加载。这三步的性价比是逐级递减的但每一步都很必要。4. 常见性能问题与排查实录顺着实战流程走总会遇到一些理论解决不了的坑。我把自己做性能优化时高频踩到的问题整理成查版本的速查表按问题现象、根因、解决路径三个维度列出来方便大家排查时对号入座。4.1 首屏白屏时间过长现象页面打开一片空白等很久才出现内容根因大多数情况是同步脚本或未内联的关键CSS阻塞了渲染排查思路DevTools Performance面板录制加载过程查看Main线程上有没有长条形任务占据大量时间检查哪些网络请求发起的顺序靠前凡是早于首屏内容的请求都要逐一确认是否真的需要那么早加载解决路径全部脚本检查一遍能异步就异步首屏CSS内联剩余CSS异步字体文件用font-display: swap属性避免字体加载阻塞文本渲染4.2 图片延迟加载但首屏大图反而不出来现象加了loadinglazy属性之后首屏大图反而加载得很慢根因loadinglazy的工作原理是“接近视口时加载”但某些浏览器对“接近”的判断比较保守首屏图片在初始化时并没有真正接近视口导致加载被推迟解决路径首屏图片不要加loadinglazy改为fetchpriorityhigh只有非首屏图片才加懒加载4.3 动态导入导致首屏反而多了额外请求现象把路由改成了懒加载第一屏却发起了很多chunk文件请求总请求数暴增根因路由懒加载粒度拆太细了一个页面拆了几十个chunk浏览器同时请求大量小文件握手开销超过了体积收益解决路径用体积阈值控制分割粒度。一般模块小于30KB的不值得单独拆成chunk。构建配置里可以把小模块聚合在一起把真正的大模块独立出来4.4 第三方脚本把页面拖垮了现象线上页面慢点开Network面板发现一堆第三方域名请求根因广告、统计、客服、AB测试各种第三方脚本全部同步加载有的还重复执行解决路径评估每个第三方脚本的必要性可砍就砍不能砍的全改为async并放在页面最底部高优场景可以给第三方脚本加defer并延后触发页面核心交互完成后再加载它们4.5 性能优化后代码逻辑出现运行顺序错乱现象改了异步加载之后原来能跑的功能故障了报错“某某变量未定义”根因原本同步执行的脚本之间存在隐式的执行顺序依赖。改成异步之后这种顺序依赖被打破了排查思路全局搜索报错变量查看它在哪些脚本里被定义、哪些脚本里被使用。如果存在跨文件的变量引用说明两个文件之间有强耦合需要合并成一个文件或用import显式管理依赖我在这里额外强调一点性能优化做之前先给现有脚本之间的依赖关系理一遍。有依赖关系的脚本优先考虑合并或使用defer完全没有依赖的再考虑async。不要为了追求极致的异步效果把老代码的顺序逻辑打乱那是得不偿失的。4.6 测试环境优化效果明显线上却失效现象本地测试LCP在1秒内线上却是4秒根因线上环境多了CDN回源、网络波动、并发带宽争抢等变量解决路径用Lighthouse或WebPageTest这类工具设置模拟4G网络和多设备环境测试对比不同地域节点的性能数据查找网络分层的问题所有优化效果验证不要只看本地一次连续测3轮取平均值5. 移动端与多端场景的异步加载差异化实践很多优化方法论是围绕桌面浏览器生态建立的但放到移动端和各类小程序容器里情况会变一套。这章单独拿出来说就是因为异步加载在多端环境里的“坑”足够多值得单独交代。5.1 移动端的网络和内存约束移动端跟桌面端最大的不同是网络不稳定、内存和CPU有上限。4G和5G环境下DNS解析耗时、TCP连接建立成本、带宽波动都比有线网络严重得多。再加上手机浏览器后台会自动回收标签页内存这会直接影响异步加载的体验。一个我实测过的结论移动端HTTP/1.1下同域名并发连接数限制是6个HTTP/2下虽然能多路复用同一条连接但移动网络的丢包重传代价很高。所以在移动端做异步加载通常比桌面端更激进——能拆就拆能懒就懒能预判就预判。另外移动端的硬件解码资源有限大量图片同时解码很容易造成帧率下降、掉帧卡顿。异步加载在这里的体现是图片懒加载不只是为了省流量也是给浏览器的解码和渲染管线减负。5.2 小程序环境下的异步加载小程序以微信小程序为例的架构和浏览器不同它运行在双线程模型中逻辑层JS引擎和渲染层WebView各自独立通过原生桥接通信。这个特性让异步加载在策略上有针对性调整分包加载微信小程序原生的“分包”机制本质就是把不常用页面和代码拆分到单独的分包包体中进入对应页面时才下载。同Web里的代码分割原理完全一致首包体积控制小程序主包上传体积限制是2MB超了就得拆。合理拆分分包能让主包体积掉到1MB以内进入体验明显加快异步API调用小程序提供了wx.request等异步API数据请求不要阻塞页面渲染。首屏页可以用骨架屏占位数据到了再异步刷新5.3 Hybrid App里的异步加载策略混合开发Hybrid领域也有自己的性能特点。WebView加载页面的过程跟浏览器内核基本一致但多了“离线资源包”的概念。实践中的方案是把业务JS和CSS提前打包进App安装包运行时从本地加载网络只负责拉取增量更新。这个策略的本质还是异步加载——本地资源加载不依赖网络自然消除了网络等待给异步加载带来的不确定性。6. 打好异步加载这一层地基才能继续往上层走从这个章节一路讲下来不知道你有没有一个整体的感觉异步加载在性能优化体系里不是孤立的一个技巧而是整个资源调度系统的基础。没有这层基础后面做再多的体积压缩、缓存优化、CDN加速效果都会打折扣。我在实际项目中最大的体会是性能优化不是一个“做了就好”的动作而是一个持续迭代的过程。今天解决了脚本阻塞明天可能又冒出来第三方插件的性能问题这周做完了资源优先级排布下周新的业务功能加进来可能又把优先级搅乱了。所以我建议团队把性能优化的检查做成常规流程每次版本发布之前用性能预算Performance Budget的概念卡一道红线——超过规定阈值的改动不允许上线。这样异步加载和性能优化的成果才能保持住不会随着迭代慢慢回退。再分享一个小技巧收尾。做性能优化的时候手边常备一个“对比清单”每次改动之前把改动前后的性能数据记录下来。不要只记最终的数字中间的排查过程、修改方案、最终效果都按时间线写清楚。我自己踩过几次坑之后发现整理这些笔记的过程本身就是对原理理解加深的过程。很多说不清道不明的性能问题写多了自然就通了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →