资讯详情

资讯详情

前端性能优化核心:异步加载原理、落地方式与实战指南

1. 异步加载到底解决了什么问题做过前端或者客户端性能优化的朋友应该都有过这种体验页面代码越堆越多首屏打开越来越慢白屏时间从原来的500毫秒变成一秒两秒用户早跑了。刚开始我以为是网络问题后来把Network面板打开一看一个首屏加载了2MB的JS、1MB的CSS还有一堆没在首屏出现的图片全挤在那条加载链里。问题很明确我们让用户为根本看不到的内容付了费——这个费就是等待时间。异步加载的核心思想其实特别朴素现在不用的东西就别现在加载。但就是这句朴素的话落地到工程里却牵扯出一整套设计和权衡。从图片懒加载到路由级代码分割从Web Worker到Android端的异步初始化背后共享的是同一个底层逻辑——把关键路径上的任务砍短把非关键任务踢出主流程。这也是为什么性能优化里异步加载几乎是所有优化手段的地基。你去做LCP优化、做启动耗时优化、做TTI优化最后大都会落到某些资源是不是可以晚点再加载这个决策上。这篇文章我打算把异步加载从原理到实操完整拆一遍包括它到底在优化什么、有哪几种落地方式、每种方式适合什么场景、踩过哪些坑以及怎么用数据验证优化到底有没有效果。不管你是做Web前端、Android客户端还是做跨端应用这套思路都是通用的。我会把我在实际项目里验证过的东西直接写出来能帮你少走不少弯路。先说说异步加载之所以能提升性能的根本原因。浏览器和操作系统的渲染机制里有一条绝对的主干道——主线程。DOM的解析、样式计算、布局、绘制、JavaScript的执行全部挤在这条主干道上。同步加载的意思是一个任务没走完后面所有任务全部排队等着。你加载了一个同步脚本HTML解析器就得停下去拉脚本、编译脚本、执行脚本全部搞完才继续解析后续标签。页面内容越靠后用户看到首屏的时间就越晚。异步加载的本质就是把不阻塞首屏渲染的任务挪到主干道之外或者挪到不那么紧急的时间点。这里有个非常重要的认知异步加载不是减少工作量而是调整工作的时间分布。比如路由懒加载该写的代码一行没少但写代码的那2MB文件从首屏必须下载并执行变成了用户进入某个路由时才下载执行。对于首屏来说它要处理的工作量就变少了所以首屏变快了。但对于整个应用生命周期来说总工作量并没有减少甚至可能因为分包拆得不合理还增加了请求次数。理解了这一层你就不会盲目地凡事皆异步而是会想清楚哪条路径是用户最关心的哪条路径是可以往后放的。在做具体方案之前还需要构建一个最基本的理论框架事件循环机制。JS是单线程的但通过事件循环把任务分成了同步任务、宏任务、微任务、渲染步骤等不同队列。setTimeout和setInterval把任务延迟到宏任务队列Promise.then、MutationObserver把回调排到微任务队列requestAnimationFrame把任务排在渲染之前requestIdleCallback把任务排在浏览器空闲时段。这些API是异步加载的调度器你选择哪个API实际上是在选择任务在事件循环的哪个阶段执行这会直接影响用户的感知流畅度。后面讲具体场景时我会再回来说它们之间的取舍。2. 异步加载的五种主流落地方式异步加载不是某一种单一技术它是一族方案的统称。我按实际项目的使用频率把它们的实现原理、适用场景和踩坑点都列一下。理解这些方案的差异是做好选型的前提。2.1 资源级的异步加载defer与asyncHTML里加载外部脚本默认是同步阻塞的。你写了一个script srcapp.js放在head里浏览器的HTML解析器就会卡住下载并执行完这个脚本才能继续渲染。这其实是最古老的性能杀手。后来有了defer和async两个属性但它们的工作原理并不一样。defer的意思是脚本的下载和HTML解析并行但执行被推迟到HTML解析完毕之后。多个defer脚本会按照文档顺序依次执行。async的意思是下载是异步的但一旦下载完成立刻中断HTML解析去执行脚本。多个async脚本谁先下载完谁先执行跟文档顺序无关。从行为上看defer更适合那些依赖DOM结构已经准备好的脚本async更适合那些独立性强的脚本比如统计脚本、广告脚本。这里有一个常见误区很多人觉得在标签上加了async或者defer就万事大吉其实不是。如果你有一个2MB的脚本加defer只是让它执行得晚一点下载仍然占了带宽、也会在解析完毕后占用主线程执行。所以资源级的异步加载只解决了不被阻塞解析的问题没有解决加载了不该加载的东西的问题。代码分割要配合路由懒加载去做资源级异步只是其中一块拼图。2.2 按需加载图片懒加载与组件懒加载图片懒加载是最常见也最容易上手的异步加载实践。核心逻辑是图片的真实地址不直接写在src里而先放在>/** * 图片懒加载工具类 * 用法new LazyLoad({ selector: .lazy-img }) */ class LazyLoad { constructor(options {}) { const defaultOptions { selector: .lazy-img, root: null, // 默认使用浏览器视口作为观察根元素 rootMargin: 0px 0px 200px 0px, // 提前200px开始加载提高加载感知流畅度 threshold: 0.01, // 元素进入视口1%时触发避免元素还在边缘就加载 loadedClass: is-loaded }; this.options { ...defaultOptions, ...options }; // 保存所有未加载的图片引用 this.images Array.from(document.querySelectorAll(this.options.selector)); this.init(); } init() { if (!(IntersectionObserver in window)) { // 降级方案直接加载所有图片 this.images.forEach(img this.loadImage(img)); return; } this.observer new IntersectionObserver((entries) { entries.forEach(entry { // 只有isIntersecting为true时才真正触发加载 if (entry.isIntersecting) { const img entry.target; this.loadImage(img); // 图片一旦开始加载就不需要再被观察了及时解除观察 this.observer.unobserve(img); } }); }, { root: this.options.root, rootMargin: this.options.rootMargin, threshold: this.options.threshold }); this.images.forEach(img this.observer.observe(img)); } loadImage(img) { // 把data-src的真实地址还原到src同时兼容data-srcset响应式图片 const src img.getAttribute(data-src); if (src) { img.src src; img.removeAttribute(data-src); } const srcset img.getAttribute(data-srcset); if (srcset) { img.srcset srcset; img.removeAttribute(data-srcset); } // 加载完成后标记状态用于后续样式控制 img.addEventListener(load, () { img.classList.add(this.options.loadedClass); }); // 加载失败时尝试用默认占位图兜底 img.addEventListener(error, () { img.src data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw; img.classList.add(load-error); }); } } export default LazyLoad;几个关键参数值得细说rootMargin我这里设置了0px 0px 200px 0px意思是在视口底部往下扩展200px作为触发区域。这样用户滚动到某张图片前的200px时浏览器就已经开始加载它了。等到图片真正进入视口大概率已经加载完成用户感知不到图片突然出现的卡顿。这个200px不是一个固定值在网速较慢且图片较多的场景可以调整为300px但也不能设太大——设置太大等于提前加载了大量还没看到的图片性能优化就失真了。threshold我设为0.01元素哪怕只有1%进入视口就触发。如果设为0在某些浏览器里会有边界情况——元素刚好在视口边缘但没有实际露出时可能不触发所以用0.01来规避。unobserve一定要调用。图片一旦开始加载就不需要继续监听它的位置变化了不加这行的话观察器会一直持有这些元素引用滚动过程中还会反复计算交叉状态白消耗性能。4.3 避免布局抖动的占位方案与图片渲染细节图片懒加载最大的隐藏坑是布局抖动导致的CLS指标恶化。普通图片渲染有几个阶段HTML解析到img标签时只知道宽高属性图片资源加载完成后浏览器需要用实际尺寸重新布局。如果你没有预先为图片占位置加载完成后页面高度突然增加下面的内容全部往下跳用户正在阅读的位置就会被顶走——这个体验非常糟糕。解决方案有两个CSS预设宽高比和占位背景色。CSS预设宽高比现在最优雅的实现是aspect-ratio属性它让元素在图片加载前就占据和最终渲染尺寸接近的空间.lazy-img { width: 100%; aspect-ratio: 16 / 9; /* 图片宽高比从设计稿或接口数据中获取 */ object-fit: cover; /* 裁剪而不是拉伸保证视觉不扭曲 */ background-color: #f0f0f0; /* 加载前的占位底色弱化空白感 */ }用aspect-ratio的好处是高度在CSS计算阶段就已经确定浏览器不需要等图片加载完成再做二次布局。不过前提是你得知道图片的宽高比。如果是CMS后台随便上传的图片建议在服务端做一次图片信息解析把宽高比下发给前端如果是固定尺寸的封面图直接用固定比例就行。实在拿不到比例的情况就给一个min-height的保底值配合背景色兜底。还有一个细节是不要给懒加载图片加过渡动画。很多人喜欢给图片加一个淡入效果opacity从0到1但opacity动画本身会触发合成层创建如果页面上同时有几十张图片在滚动中淡入性能开销反而大。content-visibility属性其实是一个更好用的方案——它可以让浏览器跳过屏幕外元素的渲染工作但兼容性目前还有限在团队技术栈允许的情况下可以作为补充手段。4.4 加载失败兜底与网络切换场景处理真实网络环境比开发环境复杂得多。3G弱网、4G信号切换、Wi-Fi断连这些都会导致图片加载失败。如果不做兜底页面上会出现一片破图用户感知尤其差。我的兜底方案分了三层第一层是loading属性给所有图片加上loadinglazy作为浏览器的原生兜底。现代浏览器已经原生支持懒加载即便没有引入任何JavaScript代码loadinglazy也能让浏览器自动推迟屏幕外图片的加载。但注意原生loadinglazy的触发时机由浏览器决定开发者无法精细控制提前量也没有失败回调所以它适合做兜底不适合做主方案。第二层是上面代码里的error回调图片加载失败时替换为一张极小体积的1x1透明GIF占位图避免破图图标显示。同时给图片加一个load-error类方便后续通过样式表现加载失败状态。第三层是网络状态变化后的手动重试机制。监听navigator.onLine和online事件网络恢复后重新扫描页面上还有哪些>const LoginPage React.lazy(() import(./pages/Login)); const DashboardPage React.lazy(() import(./pages/Dashboard)); const UserManagePage React.lazy(() import(./pages/UserManage)); function RouterConfig() { return ( Suspense fallback{PageSkeleton /} Routes Route path/login element{LoginPage /} / Route path/dashboard element{DashboardPage /} / Route path/users element{UserManagePage /} / /Routes /Suspense ); }Vue Router则更简单直接在路由配置里用动态import返回组件即可const routes [ { path: /dashboard, component: () import(./views/Dashboard.vue), meta: { title: 工作台 } } ];代码分割的核心收益不只是首屏体积变小还有一个隐藏收益是缓存命中率变高。业务代码和公共库代码分开打包后公共库的hash几乎不变用户第二次访问其他页面时公共库直接命中缓存只需要下载业务chunk即可体验提升非常明显。5.2 移动端启动性能优化中的异步加载应用异步加载在移动端的重要性比Web更突出因为移动端设备的主线程资源更紧张而且用户对启动速度的要求更高——从点击图标到看到首帧超过两秒用户就会开始不耐烦。Android端启动优化的核心矛盾是Application的onCreate里有大量初始化任务SDK初始化、数据库创建、缓存预加载、埋点模块启动这些任务如果全部同步执行在onCreate里启动耗时会被拉得很长。主流方案包括两类第一类是启动器框架如AndroidX StartUp。它的设计思想是把初始化任务抽象成一个一个有依赖关系的Task框架根据依赖关系构建一个有向无环图在没有依赖关系的Task之间并行执行有依赖关系的Task保持顺序执行。这本质上就是一种异步加载——把原本串行、阻塞、全部挤在主线程的初始化流程改成了并行、按需、可延后的调度流程。第二类是更细力度的任务延后。那些非首屏必需的初始化比如推送SDK初始化、地理位置获取、日志上传可以放到首帧渲染完成之后再启动。启动耗时被首帧之前这个窗口严格约束首帧之前的任务越少启动速度越快。任务可以细分为必须在主线程、可以在子线程、必须首帧前、可以首帧后四类逐一打标签后重新编排。这里有一个常见的坑子线程初始化看起来是异步了但如果多个子线程都去访问同一个共享资源比如同一个SQLite数据库或者都在做大量CPU计算线程之间的锁竞争和CPU争抢反而会让主线程更卡。所以异步不意味着无脑开线程合理的做法是控制线程数尽量合并同类任务避免线程颠簸。5.3 前端框架中的异步组件与副作用处理在React和Vue之外跨端框架和小程序框架里也能做异步加载只是方式要跟着框架走。Taro/uni-app这类跨端框架页面本身是按路由拆分的但页面内部仍可以通过动态import把模块级的大依赖拆出去。比如页面里有一个图表组件用了ECharts这个图表组件体积很大但只出现在页面底部那就可以把它单独拆出来等用户滚动到底部时再加载。异步组件的加载过程里还有一个容易被忽视的副作用——状态丢失。如果你在组件加载完成前用户就已经开始了某个交互操作等组件加载完成后这个交互状态可能和组件的初始化状态冲突。比如用户在Modal打开前就点击了确认按钮而承载确认逻辑的异步组件还没加载完点击事件就丢了。所以异步组件场景下要做的事不只是加载组件还要处理加载期间的交互事件缓冲和重放。如果这个组件承载了关键业务流程我会倾向于把它的加载时机提前而不是极限延后——性能优化不能以牺牲体验一致性为代价。5.4 长列表的虚拟滚动与异步渲染结合长列表场景比如信息流、聊天记录、商城商品列表一次性渲染几百上千个DOM节点即使所有资源都本地已有渲染本身也会卡顿。虚拟滚动是长列表性能问题的标配解法只渲染可视区域内的元素其他区域留空占位。这和懒加载的逻辑一脉相承——都是不渲染、不加载当前看不到的东西。虚拟滚动的实现逻辑比图片懒加载复杂一些需要计算可视区域内显示哪些行根据滚动位置实时更新。成熟的库有react-window、vue-virtual-scroller等也可以自己实现。自己实现的关键参数是预估行高——如果每条内容的行高是固定的计算很简单如果高度不固定比如文本长短不一就需要预估行高加上渲染后的实际高度校准。校准过程中上滑或下滑时的滚动条跳动是高频问题一般通过给未渲染区域预设一个估算高度来规避。和异步渲染结合的方式是每条列表项内部如果有图片或按钮等资源仍然可以配合懒加载或按需加载策略。列表项滚动进入视口时才加载其内部资源列表项滚出视口就销毁或回收其DOM结构。这样叠加之后长列表才能做到万条数据也不卡的流畅度。6. 异步加载的常见问题与性能排查实录这部分是我在实际项目里踩过、也在团队里手把手带人排查过的典型问题。按问题现象、排查思路、最终解决三列整理成速查表。6.1 高频问题速查表问题现象可能原因解决方案懒加载图片加载前出现布局跳动图片容器未设置宽高比使用aspect-ratio或固定宽高预留占位懒加载后LCP指标反而变差首屏LCP元素也被加了懒加载对LCP元素禁用懒加载改为立即加载异步组件加载后页面白屏/报错动态import的模块加载失败或路径错误检查打包输出的chunk路径配置webpackChunkName加ErrorBoundary滚动页面卡顿、掉帧scroll事件监听回调里做了getBoundingClientRect或大量DOM操作改为IntersectionObserver如必须监听scroll用requestAnimationFrame节流大量异步请求同时发起导致接口排队懒加载组件一次性全部触发控制并发数重要资源优先加载其他进入延迟队列使用原生loadinglazy后图片始终不加载图片在首屏或根元素设置了0高度检查图片父容器高度是否为0或改用IntersectionObserver方案Android启动时子线程初始化导致主线程卡顿子线程任务过多且争用共享资源统一用StartUp框架管理任务依赖与调度控制线程池数量异步加载了公共库导致重复加载多个chunk都打包了同一份公共代码配置依赖自动拆分splitChunks或manualChunks抽取公共依赖为单独chunk6.2 疑难问题排查方法论与案例复盘这里挑一个最典型的案例复盘。我负责过的一个H5活动页优化前加载很流畅做完异步加载改造之后反倒变卡了。排查时先用Performance面板做了录制发现主线程被一处占用了很久的任务给堵住了。追下去才发现异步加载的组件包里不小心把ECharts也打了进去而ECharts初始化时会去解析大量主题配置和series配置这一执行就是300ms的长任务。组件是异步了但组件内部的初始化逻辑却扔在了主线程上执行——异步加载只解决了下载时机没解决执行开销。这个案例给了两个非常重要的结论第一异步加载之后一定要复查异步组件内部的初始化逻辑。组件的JavaScript下载和执行本身就是两件事下载是网络层面的事执行是主线程层面的事。下载异步了但执行时如果做了大量同步计算照样会卡住主线程。遇到这类情况应该把ECharts这类重组件的初始化也改为异步渲染或者Worker内计算至少也要把初始化逻辑放在空闲时间执行。第二异步加载的效果要通过性能数据和用户反馈双重验证。我后来在优化前后分别采集了FCP、LCP、TBT三个指标用数据确认了优化方向是对的。只凭感觉变快了来评估性能优化很容易被错觉误导。还有一个辅助手段是录制用户操作轨迹回放时注意观察滚动过程有没有卡顿掉帧这种主观感受配合客观数据才能准确评估优化是否成功。6.3 异步加载的边界不要为了异步而异步聊了这么多还是要泼一盆冷水。异步加载不是越彻底越好它有一个合理边界。判断依据很简单用户在这个时间点会不会用到这个东西如果用户登录后第一屏就是数据看板而看板要依赖的核心数据请求被你异步到了空闲期才发那首屏就是一片空白等待——这就是异步用过头了。同理如果某个异步组件承载的是用户最核心的操作入口把它延后加载就需要权衡是缩短首屏加载时间重要还是保证用户立刻能用核心功能重要另外不是所有东西都适合拆分。有些模块虽然体积大但被多个路由共享比如公共的UI组件库、请求库、工具函数这样的模块强制拆分成异步chunk反而会导致每个页面都要重新请求一份拷贝。这种情况下应该把它打进公共依赖chunk利用浏览器缓存长期复用。代码分割的正确粒度应该是模块访问频率低且独立而不是纯粹按文件大小切。我做性能优化这么长时间体会最深的一点是性能优化的本质是理解用户行为把资源花在用户最需要的地方。异步加载只是一个手段不是目的。真正合理的架构不会为了懒加载把所有图片全部加loadinglazy也不会为了让首屏更快就把所有代码全部拆成异步。它是在理解用户的访问路径、理解资源的依赖关系之后做出的一个全局最优解。7. 我沉淀的一套异步加载自检清单按我以前带团队的做法每次做完全站性能优化Review之后会逐个页面跑一遍自检清单。现在把它也分享出来可以作为你项目里异步加载优化的验收标准。[ ] 首屏LCP元素是否被误加了懒加载LCP图片必须显式设置fetchpriorityhigh且立即加载。[ ] 所有懒加载图片是否预留了宽高比能否在没有网络的情况下复现布局跳动[ ] 路由级代码分割是否执行了首屏JS体积相对优化前下降了多少[ ] 异步组件是否有合理的加载失败兜底是否有ErrorBoundary包裹[ ] 公共依赖是否被正确抽取而不是被打进多个chunk[ ] 长列表场景是否使用了虚拟滚动而不是一次性渲染全部DOM[ ] 非关键脚本是否使用了defer/async统计脚本是否放到了页面底部[ ] 有没有做过优化前后的Lighthouse对比FCP/LCP/TBT是否都有改善[ ] 移动端启动阶段Application.onCreate里是否还有可延后或可切换子线程的初始化任务[ ] 异步任务之间是否存在共享资源竞争线程或Worker数量是否合理这套清单我每次性能复盘都会过一遍能过滤掉大部分低级失误。它不复杂但每一条都来自实际生产环境里的教训。异步加载这个主题看起来是单个技术点但做深了会发现它其实横跨网络加载、渲染机制、线程调度、框架设计多个层面是一个值得长期深耕的方向。我建议你把今天聊到的几个方案资源异步、代码分割、任务调度、移动端启动优化在自己的项目里各找一个场景试着落地然后拿数据说话再对比优化前后的指标变化。这个过程走完一遍你对异步加载和性能优化的理解会比你看十篇文章都深。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →