ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端异步加载原理与性能优化:从async/defer到代码分割

前端异步加载原理与性能优化:从async/defer到代码分割 做了多年前端我越来越觉得“异步加载”这件事被很多人低估了。一提到性能优化大家第一反应往往是压缩图片、上CDN、开HTTP/2却忽略了最基础也最决定成败的一件事怎么把资源“按时按需”地交给浏览器。异步加载的底层逻辑是把每个关键资源的请求路径变成一条“非阻塞队列”让脚本不再卡住页面渲染让图片不再抢占首屏带宽让初始化逻辑不再全部堆在启动阶段。这篇原理篇我想从浏览器的工作机制出发把异步加载与性能优化的关系彻底拆一遍再给出可以直接落到项目里的操作步骤和踩坑记录。这篇文章适合三类人一是写业务页面但总觉得首屏慢、又定位不准原因的开发者二是刚接触性能优化、对async/defer/懒加载只有模糊概念的初学者三是在做移动端或桌面端应用、想理解“为什么异步能省下这么多时间”的工程师。后面所有内容我都会从原理讲到实操尽量不让你看完之后只会敲几个API而是能自己判断在什么场景下选什么方案。1. 为什么说异步加载是性能优化的基石1.1 同步执行下被阻塞的页面我们把浏览器想象成一个只有一条流水线的工厂。HTML是生产图纸CSS是配色方案JavaScript是负责加工的动作指令。如果流水线上的某个工位必须等机械臂完成全部指令才能动起来那后面所有工位就只能干瞪眼。这就是同步加载的处境浏览器在解析HTML时一旦遇到script标签它就会停下解析动作先去下载并执行完这个脚本再继续往下走。下载之外还要执行执行的代价可能比下载更高。哪怕脚本很小只要它里面有一段循环或者DOM操作引擎照样会占用主线程期间用户滑动页面、点击按钮都得不到响应。我实测过一个极端例子一个页面在head里放了一个1.2MB的第三方统计脚本首屏等了将近两秒才出现但其实页面自身渲染只需要200毫秒。这份脚本不是首屏必需资源却因为同步加载把整个渲染流程堵死了。这就是性能优化首先要解决的问题不是程序不够快而是资源到达的时机不合理。所谓时机不合理可能体现在三个维度。第一资源本身可以在空闲时加载但同步写法让浏览器只能串行处理第二资源可以由多个请求并行去取但HTML解析被一句句打断并行度被白白浪费第三资源对首屏根本不重要却占住了设备和网络资源导致真正要紧的图片和样式延后。“异步加载”要动的就是这三个维度把资源从“必须立刻到达”和“可以和其它请求同时到达”这两种状态中解放出来。1.2 异步加载的本质让出主线程异步加载的核心不是“晚点加载”而是“不阻塞主线程”。浏览器的主线程要同时负责解析HTML、计算样式、执行JavaScript、绘制像素和响应用户输入。任何一件工作占用的时间过长都会造成卡顿。异步加载的目的就是让资源的下载过程离开主线程交给浏览器内部的网络线程去处理让脚本的执行时机推迟到合适的时间点避免打断解析过程让长任务分化成更细粒度的时间片降低卡顿感。拿图片来说img标签本身并不会阻塞HTML解析图片下载是在网络线程异步完成的。如果把图片改成同步方式去读取文件流再插入DOM页面就会频繁卡死。浏览器天生就提供了异步的能力我们需要做的是把这种能力用对。脚本异步加载的思路也一样async或defer属性把“下载”和“执行”解耦出来浏览器可以在后台下载同时继续解析DOM等下载完成后再决定何时执行。理解了这个本质你会发现很多优化手段是相通的。代码分割Code Splitting是让某段逻辑在用户需要时才执行本质是加载时机优化懒加载是让视口之外的内容先不下载本质是资源优先级优化。它们都属于异步加载这个范畴因为它们的共同点是不阻塞主线程把加载动作延后到收益最大的时间点。1.3 性能优化金字塔异步在其中的位置把性能优化的常见手段排个金字塔最底层是网络与传输优化包括减少请求数、启用HTTP/2、合理设置缓存中间一层是资源体积优化包括代码压缩、Tree Shaking、图片格式转换最上层才是运行时性能优化包括异步加载、长任务拆分、渲染节流。这里我想强调一个多数人忽略的事实异步加载并不只属于最上层它同时影响了底层和中层。异步加载可以减少首屏无用资源的请求数底层它本身不减少体积但能把体积大的资源从关键渲染路径上挪走中层它还可以把耗时的初始化逻辑拆成多个阶段执行上层。所以我把异步加载称作纽带型优化。它和缓存策略配合可以给出“构建期精准命中缓存”的方案和代码分割配合可以把路由级的首屏JavaScript削减一半以上和渲染调度配合可以让动画不再受大数据请求影响。很多团队在写性能优化规范时第一件事就是规定所有脚本必须加上异步加载标记这个规定其实很合理。因为只要资源还是同步进入页面后面再大的压缩率、再好的缓存策略都可能被一个不合理的脚本标签打回原形。2. 浏览器渲染原理与加载时机2.1 从HTML解析到首次渲染要真正用好异步加载必须先知道浏览器是怎么把HTML变成像素的。简单来说浏览器拿到HTML后先生成DOM树同时CSS的解析会生成CSSOM树两者合并成渲染树再经过布局计算和绘制最终在屏幕上出现内容。关键点在于CSSOM的生成可能被样式表阻塞而DOM的生成会被脚本阻塞。这里有个细节CSS不仅会阻塞样式计算还会阻塞脚本执行。当浏览器遇到一个普通脚本而它前面的样式表还没加载完时浏览器必须等待样式表加载完毕再执行脚本因为脚本里可能查询元素的样式。这也是为什么性能规范里强调“CSS放头部、脚本放尾部”或“脚本异步化”的原因。头部同步脚本可能是最差的选择它既阻塞解析又依赖前置样式表整个页面都会等它。首次渲染的时间不是等HTML完全解析完才开始的浏览器会在解析到一定量内容后提前绘制这被称作“渐进式渲染”。但这个机制有个前提主线程没有被阻塞。一旦解析中途卡在脚本下载上面板就再也无法提前渲染。异步加载的目的之一就是保证主线程始终有空闲去执行“解析→布局→绘制”这条链路。2.2 脚本、样式表与阻塞行为浏览器处理一个普通script src...的流程是这样的下载脚本时HTML解析被暂停脚本下载完成后执行脚本解析继续。整个过程主线程处于等待状态。如果把脚本标签放入head且不带任何异步属性就是最彻底的同步阻塞并且脚本之间是串行的一个接一个执行。样式表的阻塞比较微妙。link relstylesheet本身不会阻断HTML解析但会阻断脚本执行和渲染。在浏览器渲染流程里只有样式表加载完成后才能生成最终的渲染树因此样式表延迟越久首次绘制越晚。这里就牵出一个常见认知误区“我把脚本全部放到body底部就行了。”实际上如果body前面有尚未加载完成的样式表底部脚本一样要等待那份样式表。异步加载不能解决样式表本身的耗时只能避免脚本与样式表互相叠加的二次阻塞。资源类型是否阻塞HTML解析是否阻断脚本执行是否阻断渲染建议加载位置同步script是是是body末尾或异步化CSS样式表否是是head区域尽快加载async script否不适用否任意位置独立执行defer script否不适用否任意位置DOM解析后执行提示上面“建议加载位置”是经验值不代表绝对规则。以CSS为例把它放在head是为了尽早加载、尽早阻塞脚本执行前的等待而异步脚本放在哪里问题不大因为下载和解析是并发进行的。这张表值得贴在工位上。多数性能问题都来自对这张表理解不到位要么在脚本里想当然地操作DOM导致白屏要么把脚本全部堆到head里以为是并行加载结果反而延长了等待。2.3 async、defer、动态创建三种标准的取舍async和defer常被混为一谈但语义完全不同。简单理解async是“下载完能执行就立刻执行不等也不让路”它的执行时机取决于网络速度和脚本大小因此多个异步脚本之间没有执行顺序保证defer是“下载完成先等着直到HTML解析完再按文档顺序执行”它把执行推迟到了DOMContentLoaded之前既保证不阻塞解析又保留了脚本顺序。属性下载时机执行时机顺序保证典型场景无(同步)遇到标签时下载完成立即执行有首屏关键逻辑async遇到标签时下载完成立即执行无独立统计脚本、广告defer遇到标签时DOM解析完成后按序执行有需要DOM的页面脚本、依赖顺序的业务脚本动态创建创建并插入时插入后下载完成执行可设置async按需加载的模块动态创建脚本是更灵活的方式通过document.createElement(script)再放到页面中脚本不会阻塞当前解析因为它是用户自定义时机触发的。它在SPA单页应用里用得最广路由切换后需要加载下一屏的代码模块时往往就是动态创建script标签或直接使用import()。选型时我的经验是不依赖DOM的第三方统计脚本用async依赖DOM、同时关心脚本顺序的用defer按按钮点击或滚动事件触发的模块用动态创建首屏核心脚本既不能异步也不能延后但要尽量小因为它必须提前执行。3. 前端资源异步加载的落地步骤3.1 脚本异步加载async与defer的正确选择落到项目里第一步先把所有不是首屏必需的内联脚本和外部脚本检查一遍。一个快速判断方法问自己“如果这个脚本晚3秒执行页面核心功能会不会受影响”如果不会它就应该异步化。具体操作步骤列出页面所有的script标签用浏览器网络面板按阻塞时间排序。把必须立即执行的脚本如首屏渲染逻辑、关键样式兜底逻辑保持同步但尽量压缩体积。把统计、埋点、客服弹窗、A/B测试等独立脚本改成defer或async。把依赖DOM结构的业务脚本统一改为defer确保在DOMContentLoaded前按序执行。用性能面板对比改造前后的白屏时间与可交互时间。这里有个容易翻车的细节async脚本的执行顺序不可控所以如果业务脚本之间有依赖关系比如先加载埋点模块、再加载基于埋点数据的业务模块就不能都加async。defer保留了顺序但执行时间都被推迟到解析完成后要小心别把“应该尽早执行的首屏逻辑”也放进defer里否则首屏虽然不卡DOM却可能延迟了首屏事件绑定。我实际见过一个团队把jQuery库也用了defer加载结果文档解析完成之后才执行jQuery所有依赖jQuery的DOM事件绑定被打到后面页面交互出现了半秒到一秒的空白。归根结底defer适合“解析完成后执行即可”的脚本不适合“越早越好”的关键逻辑。3.2 图片与水塘懒加载Intersection Observer实战图片是异步加载最典型的受益者。传统的懒加载方案会监听滚动事件通过计算元素相对视口的位置来判定是否进入可视区同时还要处理节流、兼容性等一堆问题。现在推荐直接用Intersection Observer API它由浏览器原生实现不占主线程计算还能精确判断元素与视口的交叉比例。实现一个图片懒加载的代码量其实非常少img>const lazyImages document.querySelectorAll(.lazy-img); const observer new IntersectionObserver((entries, obs) { entries.forEach(entry { if (!entry.isIntersecting) return; const img entry.target; img.src img.dataset.src; img.classList.add(loaded); obs.unobserve(img); // 加载后不再观察避免重复回调 }); }, { rootMargin: 200px 0px, // 提前200px开始加载减少滚动等待感知 threshold: 0.01 }); lazyImages.forEach(img observer.observe(img));这段代码的核心不是API本身而是两个细节。第一rootMargin设一点预加载距离用户滚动到图片边缘之前就已经开始下载体感更顺滑第二加载完成后一定要unobserve否则图片即使离开视口再回来也会不断触发回调造成无效计算。还有一类容易忽略的场景是“水塘图片”也就是页面下方大量非关键图片。不要一次性给所有图片都加懒加载可以按离首屏的距离分批绑定observer或者直接给距离较远的图片加上loadinglazy属性。原生loading也走浏览器调度成本更低但要注意loadinglazy在部分浏览器里对JavaScript脚本不生效只适用于iframe和img这类资源。水塘图的体验优化还需要配合占位符和宽高比预留否则懒加载真的能带来更大的布局抖动。3.3 动态import与路由级代码分割用构建工具Webpack、Vite、Rollup做代码分割时动态import()是异步加载的另一种落地形态。以前我们把第三方库直接写进主包现在通过import()可以让某个模块在用户触发特定页面或操作时才加载。以一个React/Vue项目为例路由切换对应的页面组件改成动态导入主包的体积可以明显下降// 之前一次性加载所有页面 // import HomePage from ./pages/HomePage; // import AboutPage from ./pages/AboutPage; // 之后按路由异步加载 const HomePage () import(./pages/HomePage.vue); const AboutPage () import(./pages/AboutPage.vue);构建工具会把动态导入的组件拆成单独chunk用户进入首页时只会加载首页相关代码。这个方案的收益肉眼可见中大型应用首屏JavaScript体积往往能减少40%到60%。代价是需要配置好chunk命名、预加载策略和服务端缓存否则会出现“点击路由时白屏半秒”的现象因为chunk正在网络下载。为了缓解这个体验问题可以配合prefetch在浏览器空闲时提前拉取可能用到的chunk。Webpack配置里写入magic comments即可const LoginPage () import(/* webpackPrefetch: true */ ./pages/LoginPage.vue);这样当用户浏览首页时浏览器会在空闲时间下载登录页代码用户真点击登录时几乎没有等待时间。prefetch的副作用是消耗额外带宽所以只对“很可能被访问”的页面使用不要给全站页面都加。3.4 预加载与预连接preload、prefetch、preconnect有人会把“异步加载”和“不预加载”划等号这是个误区。异步加载的本质是调度资源到达的时机而不是一律延迟。浏览器提供了几类“主动加载”能力link relpreload提前加载当前页面确定需要的资源且不阻塞渲染。link relprefetch提前加载用户下一步可能需要的资源优先级最低。link relpreconnect提前与目标服务器建立网络连接省去后续请求的DNS和TCP握手时间。link reldns-prefetch只做DNS预解析开销更小。类型优先级典型用途注意事项preload高首屏字体、关键图片、动态脚本用完即释放避免资源重复下载prefetch低路由级chunk、下一页数据慎用防抢带宽preconnect中第三方域名的已知请求最多连3个域名过多无意义dns-prefetch低域名解析较慢的CDN开销小收益有限preload有一个容易被忽视的坑如果你preload了一个图片之后页面里又正常引用了这张图浏览器会实际请求一次如果preload的URL和最终使用URL不一致甚至会造成双倍下载。所以preload的URL必须和最终资源URL严格匹配。4. 性能度量的关键指标与优化验证4.1 从FCP到LCP异步加载做了那么多之后怎么知道它到底有没有用靠体感不靠谱得看指标。Web性能核心指标里和异步加载关系最紧密的是FCPFirst Contentful Paint首次内容绘制、LCPLargest Contentful Paint最大内容绘制和TTITime to Interactive可交互时间。FCP是浏览器第一次绘制出任何内容的时间点异步加载减少了阻塞后FCP通常会有明显提升。LCP测量的是最大可见元素的渲染时间它更贴近用户“页面打开了吗”的真实感受。如果你把首屏图片放到了defer脚本后才插入DOM即使FCP很快LCP也可能很慢因为最大元素直到脚本执行完才出现。TTI衡量的是页面完全可交互的时间。异步脚本如果太多虽然不阻塞解析但大量脚本会在DOMContentLoaded后集中执行这段时间用户能看见页面却不能点击TTI就会很高。好的异步设计应该让脚本执行窗口尽量分散避免“渲染快但交互慢”的局面。4.2 用Performance API做异步资源测量想量化每次异步加载的收益可以在浏览器控制台里用Performance API直接拉数据。比如查看所有资源的加载耗时const resourceTimes performance.getEntriesByType(resource) .filter(item item.initiatorType script) .map(item ({ name: item.name.split(/).pop(), duration: item.duration.toFixed(2) })); console.table(resourceTimes);从中能看到每个脚本的download时长和execute时长帮助你定位是网络慢还是执行慢。还可以用performance.mark()在异步回调前后打点计算模块加载到执行结束的总体耗时特别适合验证动态import的收益。提示本地开发环境的性能数据容易失真建议用生产构建、开启无痕窗口、关闭网络节流的状态下测基线然后再测优化后数据对比才有意义。4.3 Lighthouse与真实环境验证Lighthouse是常用的一站式性能检测工具。跑一遍之后要看它给出的Opportunities列表那里面会直接指出哪类资源可以被延迟加载。但Lighthouse的数据基于模拟的慢网络代表的是“理想环境下的优化空间”最终还是要回到真实用户体验采集如Performance面板、Web Vitals去验证。我的做法是先用Lighthouse看方向再用Performance面板的录制功能抓真实加载过程最后用Performance API拉一次长列表对比优化前与优化后的资源耗时。优化是否有效不只看单一指标还看用户操作感受。把SDK脚本异步化后经常出现FCP和LCP不变、但TTI变短的情况这在真实用户体验里就是“页面可以更快点击了”值得记录和肯定。5. 异步加载中最容易踩的坑5.1 异步脚本的次序失控最常见的问题就是根本不理解async的执行时机把两个有依赖关系的脚本都加上了async。两个脚本并行下载短的先执行长的后执行业务逻辑如果依赖前置脚本的全局变量轻则报错重则页面白屏。解决思路有两类一类是把有依赖关系的脚本合并成单一脚本另一类是改用defer让它们在解析完成后按顺序执行。还有一种隐蔽场景动态创建的script保持了插入顺序但如果设了asynctrue执行顺序也不确定。我建议动态脚本尽量不设async由浏览器保持插入顺序除非你明确知道这个脚本不依赖任何其他脚本。5.2 懒加载导致的布局抖动懒加载最典型的副作用是页面高度变化。图片未加载时高度为0滚动到附近开始加载图片图片撑开高度页面内容往下跳用户明明在滚动却突然被弹回了上面的位置。这比不优化还糟糕。要根治必须在图片外层容器上预留宽高比。现代CSS可以用aspect-ratio属性简单有效.lazy-img-wrapper { aspect-ratio: 16 / 9; overflow: hidden; } .lazy-img-wrapper img { width: 100%; height: 100%; object-fit: cover; }aspect-ratio搭配object-fit: cover能保证图片加载前容器占位正确加载后图片也能统一裁剪比例。兼容老项目可以退回到padding-top百分比占位法但原理是一样的容器必须有固定比例不能等图片加载完再计算高度。5.3 双倍请求问题双倍请求在异步加载场景里挺常见。一是preload与正常请求重复下载同一资源二是懒加载图片与浏览器预加载策略冲突导致同一张图被请求两次三是动态创建的script标签如果页面上同时存在静态script引用也会重复请求。排查方法打开网络面板按名称排序如果某个资源出现两条记录就逐条对比发起者。preload导致的双倍请求要在HTML或服务端模板里删掉preload标签懒加载导致的重复则要检查是不是代码里先设了src又设置了>
返回列表