ARTICLE DETAIL

资讯详情

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

异步加载与性能优化实战:从原理到落地的完整指南

异步加载与性能优化实战:从原理到落地的完整指南 异步加载这个词很多人第一次听到会觉得它是个高级技巧但真正在项目里踩过坑的人都明白它更像是一把双刃剑——用对了页面秒开、体验丝滑用错了白屏、闪烁、数据错乱问题一个比一个难查。我做了十多年前端和客户端性能相关的工作从最早的脚本合并压缩到后来的按需加载、预加载、懒执行几乎每一代方案都亲手落地过。这篇内容不打算给你背概念而是把异步加载和性能优化这条链路拆开讲清楚它到底在解决什么问题、底层是怎么运转的、实际项目里应该怎么落地以及那些文档里不会写的坑。不管你是刚接触性能优化的新手还是已经做过几轮优化但总觉得效果不明显的老手应该都能从里面找到能直接用的东西。1. 异步加载到底在解决什么核心矛盾1.1 同步加载的代价主线程被堵死的那几秒要理解异步加载得先看清楚同步加载的问题出在哪。浏览器或者客户端在解析到需要加载的资源时如果是同步方式它会停下来等这个资源下载完、解析完、执行完才继续往下走。这个过程里主线程是被占住的用户看到的就是白屏或者卡住不动。我用一个生活化的类比你去餐厅点餐同步加载就像服务员点完你的单站在厨房门口一直等你这道菜做完才去服务下一桌。后面排队的客人全在干等。异步加载则是服务员点完单就去服务别人菜好了再端过来。区别就是这么直接。具体到数据上一个没做优化的页面首屏要加载的脚本可能有几百KB甚至上MB在一般网络环境下下载加解析执行的时间轻松超过两秒。这两秒里用户什么都干不了跳出率就是这么上去的。所以异步加载要解决的核心矛盾就是资源加载的耗时和主线程可用性之间的冲突。1.2 关键渲染路径哪些资源必须同步哪些可以推迟不是所有资源都能异步。这里有个概念叫关键渲染路径指的是浏览器从收到HTML到渲染出首屏内容必须经过的一系列步骤。处在这条路径上的资源比如首屏必需的样式、首屏渲染依赖的脚本你强行异步反而会导致页面闪烁或者布局错乱。我的经验判断标准是这样的首屏可见内容依赖的样式必须同步或者内联否则会出现无样式内容闪烁。首屏渲染必须的数据和逻辑可以同步但要尽量小控制在几十KB以内。非首屏的模块、弹窗、图表库、富文本编辑器一律异步。埋点、统计、客服组件全部异步而且可以延迟到空闲时段。把资源按这个标准分完类你会发现真正必须同步的东西其实很少。大部分体积都是被那些可能用得上的模块撑起来的这些就是异步加载的主战场。1.3 异步不等于更快一个容易被忽略的认知很多人有个误区觉得只要把资源改成异步加载性能就上去了。实际上异步只是把等待从主线程转移走了资源该下载还是要下载该占带宽还是占带宽。如果你的首屏本来就依赖这个资源异步加载只会让首屏更晚拿到它体验反而更差。所以异步加载的正确姿势是配合优先级调度和预加载一起用。比如一个非首屏的图表库你可以在首屏渲染完成后立刻开始预加载它等用户真的点到图表页时资源已经在本地缓存里了这时候的异步加载几乎是零等待。这才是异步加载真正的价值——不是不加载而是在正确的时间加载。2. 异步加载的几种实现路径与底层机制2.1 动态脚本注入最原始也最灵活的方式动态创建脚本标签是最经典的异步加载手段。原理很简单通过脚本创建一个script元素设置好src插入到文档里浏览器就会异步去下载和执行它不会阻塞当前解析。function loadScript(url, callback) { const script document.createElement(script); script.src url; script.async true; script.onload () { callback callback(); }; script.onerror () { console.error(加载失败:, url); }; document.head.appendChild(script); }这段代码看着简单但有几个细节值得说。async属性设为true表示这个脚本的下载和执行都不阻塞其他操作但要注意它和defer的区别async是下载完立刻执行执行顺序不保证defer是下载完等文档解析完再按顺序执行。如果你加载的多个模块之间有依赖关系用async就可能出问题得用defer或者自己维护加载队列。我踩过的一个坑是动态注入的脚本如果加载失败默认是静默的页面不会报错只是功能缺失。所以onerror回调一定要写而且要有重试机制。我们当时的做法是失败后换一个备用地址重试一次再失败就降级到基础功能。2.2 模块化规范下的异步从AMD到动态import早期的AMD规范就是为异步加载而生的require([‘module’], callback)这种写法天然支持异步。后来CommonJS是同步的不适合浏览器。真正让异步加载变得优雅的是ES Module的动态import。button.addEventListener(click, async () { const { renderChart } await import(./chartModule.js); renderChart(data); });动态import的好处是语法层面就支持打包工具能识别并自动做代码分割把chartModule单独打成一个chunk只有真正执行到这行代码时才去下载。这比手动动态注入脚本要干净得多依赖关系也由打包工具处理好了。但这里有个性能陷阱如果用户点击按钮才去下载这个chunk网络慢的时候会有明显的延迟感。解决办法是预加载在首屏空闲时用import()提前触发下载或者用link relpreload提示浏览器提前拉取。这样用户点击时资源已经就绪体验就顺了。2.3 资源提示preload、prefetch、preconnect的取舍浏览器提供了几种资源提示指令用对了能显著改善异步加载的体验用错了反而浪费带宽。指令作用适用场景注意事项preload提前加载当前页面即将用到的资源首屏关键字体、异步chunk要配合as属性否则可能重复加载prefetch空闲时预加载未来可能用到的资源下一个页面、非首屏模块优先级低别滥用否则抢带宽preconnect提前建立连接第三方域名资源域名多时收益递减dns-prefetch提前做DNS解析第三方域名成本低可以多用我的实操建议是preload只给真正关键、马上要用的资源一般不超过三四个prefetch给下一个交互大概率会用到的模块preconnect给核心第三方服务别超过四个域名否则每个连接都要握手反而拖慢。2.4 懒执行与懒加载把加载和执行分开看很多人把懒加载和异步加载混为一谈其实它们关注的点不同。异步加载关注的是什么时候下载懒执行关注的是什么时候运行。举个例子一个页面里有个复杂的计算函数代码已经打包进主包了但只有用户触发某个操作才需要跑。这时候代码已经下载了但你可以延迟它的执行比如用requestIdleCallback在浏览器空闲时再跑或者干脆等到真正需要时再调用。这就是懒执行。懒加载则更多用在图片、视频这类资源上通过Intersection Observer监听元素是否进入视口进入时才设置真实的src。这个方案现在已经是标配了比早年用scroll事件监听性能好太多因为它是异步的不会频繁触发布局计算。3. 性能优化的度量不量化就谈不上优化3.1 核心指标从加载到可交互的每个阶段做性能优化最怕的就是凭感觉。你觉得快了用户可能觉得没变化。所以必须有一套量化指标。业界常用的几个核心指标首次内容绘制页面第一次画出内容的时间点反映用户多久能看到东西。最大内容绘制视口内最大元素绘制完成的时间反映主要内容多久可见。首次输入延迟用户第一次交互到页面响应的延迟反映主线程是否被阻塞。累积布局偏移页面加载过程中元素意外移动的程度反映视觉稳定性。交互到下次绘制交互后页面更新的延迟反映交互流畅度。这几个指标里和异步加载关系最密切的是首次输入延迟和交互到下次绘制。因为异步加载如果调度不当会在主线程上堆积大量任务导致用户点击时响应不过来。3.2 怎么采集这些数据真实用户监控的落地实验室数据比如本地跑个性能测试只能反映理想情况真实用户的网络、设备千差万别。所以必须做真实用户监控把指标采集上来。// 采集最大内容绘制 new PerformanceObserver((entryList) { const entries entryList.getEntries(); const lastEntry entries[entries.length - 1]; reportMetric(LCP, lastEntry.startTime); }).observe({ type: largest-contentful-paint, buffered: true });采集的时候要注意几个点一是数据要分设备、分网络类型聚合不然平均值没意义二是要采集分位数比如75分位、95分位平均值会被极端值拉偏三是采样率要控制全量上报对服务端压力大一般采个百分之几就够了。我个人的经验是优化前先跑一周数据把基线定下来然后每做一个优化就观察对应指标的变化。没有基线你根本不知道优化有没有效果。3.3 性能预算给团队一个可执行的约束光有指标还不够得有个目标。性能预算就是给关键指标设定阈值比如首屏加载不超过两秒、主包体积不超过两百KB。这个预算要写进构建流程里超了就报警甚至阻断发布。我们团队的做法是在CI里加一个体积检查每次提交如果主包体积增长超过设定阈值就自动在合并请求里评论提醒。这样能防止性能在不知不觉中劣化。很多项目的性能问题不是一次大改动造成的而是每次加一点点、日积月累堆出来的。4. 实战中的异步加载策略设计4.1 路由级分割最粗粒度也最有效的切分单页应用里按路由做代码分割是最容易见效的。每个路由对应一个chunk用户访问哪个页面就加载哪个chunk首屏只需要加载当前路由的代码。const routes [ { path: /home, component: () import(./views/Home.vue) }, { path: /dashboard, component: () import(./views/Dashboard.vue) } ];这个方案的好处是切分清晰、维护简单。但要注意如果两个路由共享大量组件打包工具可能会把公共部分抽出来这时候要检查抽出来的公共chunk是不是太大太大的话首屏还是会被拖累。解决办法是进一步细分把公共部分再拆成更小的模块。4.2 组件级分割把重组件从主包里踢出去路由分割之后往往还有个别页面特别重比如带富文本编辑器、图表库、地图的页面。这时候要做组件级分割把这些重组件单独拆出去。判断标准很简单如果一个组件的体积超过五十KB而且不是首屏必须的就应该异步加载。加载时机可以选在组件即将进入视口时或者用户有交互意图时比如鼠标悬停在按钮上。这里有个技巧对于弹窗类的重组件可以在用户点击打开按钮的瞬间开始加载同时显示一个加载态。因为弹窗本身有动画加载的那几百毫秒可以被动画掩盖掉用户感知不到。4.3 数据层的异步接口请求的并发与串行性能优化不只是代码加载数据请求同样重要。一个页面如果串行发五个接口每个两百毫秒加起来就是一秒。改成并发总耗时取决于最慢的那个可能就三百毫秒。// 串行不推荐 const user await fetchUser(); const orders await fetchOrders(user.id); const coupons await fetchCoupons(user.id); // 并发推荐 const [user, orders, coupons] await Promise.all([ fetchUser(), fetchOrders(), fetchCoupons() ]);但并发也不是无脑用。如果接口之间有依赖比如订单接口需要用户ID那就没法完全并发。这时候可以考虑把用户ID缓存在本地或者让后端提供一个聚合接口一次返回所有数据。聚合接口的好处是减少往返次数坏处是灵活性差改一个字段可能要动整个接口。我的建议是首屏用聚合接口后续交互用细粒度接口。4.4 预加载策略把等待藏到用户察觉不到的地方预加载的核心思想是提前做用户下一步要做的事。常见的预加载时机首屏渲染完成后预加载下一个最可能访问的路由。用户鼠标悬停在链接上时预加载目标页面。页面空闲时预加载高频使用的模块。// 首屏完成后预加载 window.addEventListener(load, () { requestIdleCallback(() { import(./views/Dashboard.vue); }); });预加载要控制好度不能把所有东西都预加载了那样等于没做分割。我的经验是预加载的资源总量控制在主包的百分之三十以内而且优先级要低于当前页面正在用的资源。5. 那些文档不会告诉你的坑5.1 异步加载导致的执行顺序错乱这是最常见也最难查的问题。多个异步脚本如果互相有依赖加载完成的顺序是不确定的。比如A模块依赖B模块但A先加载完了执行时发现B还没到就报错了。解决办法有两个一是用打包工具处理依赖让它们打进同一个chunk保证顺序二是自己维护一个加载队列确保依赖先加载完。我倾向于第一种因为手动维护队列容易出错而且随着模块增多会越来越复杂。5.2 重复加载同一个模块被加载了两次这个问题在动态import和手动脚本注入混用时特别容易出现。比如你用动态import加载了某个模块同时又在别处用script标签加载了同一个文件浏览器会下载两次。虽然第二次可能命中缓存但解析和执行还是会重复。排查方法是看网络面板里有没有重复的请求或者看打包产物的chunk列表有没有重复的模块。预防办法是统一用一种加载方式别混着来。5.3 加载失败没有兜底用户看到的是空白异步加载的资源如果加载失败默认是没有提示的。用户看到的就是功能缺失或者空白区域。所以每个异步加载点都要有失败处理重试、降级、提示。async function loadWithRetry(loader, retries 2) { for (let i 0; i retries; i) { try { return await loader(); } catch (e) { if (i retries) { showFallback(); throw e; } await sleep(300 * (i 1)); } } }重试的间隔建议用指数退避第一次等三百毫秒第二次等六百毫秒避免网络抖动时疯狂重试。5.4 预加载过度带宽被抢首屏反而变慢预加载用过头是很常见的。我见过一个项目首屏加载时预加载了七八个路由的chunk结果首屏自己的资源被挤到后面加载时间翻倍。判断预加载是否过度可以看首屏关键资源的加载开始时间。如果它们被推迟了说明预加载抢了带宽。解决办法是给预加载设置低优先级或者延迟到首屏关键资源加载完再开始。5.5 缓存策略不当该缓存的没缓存不该缓存的缓存了异步加载的chunk如果文件名不带哈希浏览器可能缓存旧版本导致用户拿到过时代码。如果带哈希每次发版文件名都变又会导致缓存全部失效。正确的做法是chunk文件名带内容哈希同时HTML入口文件不缓存或者短缓存。这样发版时HTML更新引用的chunk哈希变了浏览器去拉新chunk没变的chunk哈希不变继续用缓存。这个策略配合得好发版对用户的影响能降到最低。6. 不同端的异步加载差异6.1 移动端网络和设备是双重约束移动端的性能优化比桌面端复杂因为网络不稳定、设备性能参差不齐。同样的异步加载策略在高端机上很流畅在低端机上可能就卡了。移动端要特别注意几点一是包体积要更严格地控制因为下载速度慢二是要减少主线程任务因为移动端CPU弱三是要考虑电量频繁的网络请求和计算会耗电。我做过的一个移动端项目把首屏主包从八百KB压到三百KB首屏时间从三秒多降到一点五秒效果非常明显。压缩的手段主要是路由分割加组件分割把非首屏的东西全踢出去。6.2 桌面端更关注交互流畅度桌面端网络通常不是瓶颈瓶颈更多在主线程。所以桌面端的异步加载要更关注执行时机避免长任务阻塞交互。可以用requestIdleCallback把非紧急的任务放到空闲时段执行用scheduler.yield如果环境支持主动让出主线程。这些手段能显著改善交互到下次绘制指标。6.3 小程序与混合应用受限于容器能力小程序和混合应用的异步加载受容器限制不能像浏览器那样自由地动态注入脚本。小程序的方案通常是分包加载把不同页面分到不同分包里进入分包页面时才下载。分包策略和小程序的分包大小限制有关主包一般限制在两MB以内所以主包要尽量精简把能放分包的都放分包。分包之间还可以做预下载在进入分包页面前提前下载好。7. 一套可复用的异步加载落地清单7.1 优化前的基线采集与目标设定动手之前先做三件事采集当前性能基线、确定优化目标、列出可优化的点。基线要覆盖核心指标目标要具体可量化比如首屏时间从三秒降到一点五秒。可优化的点按收益排序优先做收益大、改动小的。通常顺序是路由分割、组件分割、资源压缩、缓存策略、预加载。7.2 分割粒度的判断标准分割太粗效果不明显分割太细请求数太多。我的判断标准是单个chunk体积控制在一百KB以内。首屏chunk数量控制在五个以内。非首屏chunk按路由或功能模块划分。高频使用的公共模块单独抽出来但体积别超过五十KB。7.3 监控与回归优化不是一次性的性能优化做完不是结束而是开始。要持续监控指标发现劣化及时处理。建议每周看一次性能报表每次发版后对比指标变化。回归测试里要加上性能检查防止新功能把性能拖回去。我们团队的做法是每次合并请求都跑一次体积检查超预算就阻断。7.4 常见问题速查表问题现象可能原因排查方向首屏白屏时间长主包太大、同步资源太多看主包体积、关键路径资源点击无响应主线程被长任务阻塞看长任务、首次输入延迟页面闪烁异步样式加载晚于内容检查样式是否内联或同步功能偶发缺失异步加载失败无兜底看错误日志、加失败处理发版后用户报错缓存了旧chunk检查文件名哈希和缓存策略这套清单我在几个项目里都用过基本能覆盖大部分异步加载和性能优化的问题。当然每个项目情况不同具体策略还要根据实际情况调整。最后分享一个我自己的体会性能优化最忌讳的就是一次性大改。大改风险高、难回滚、效果还不一定好。更好的做法是小步快跑每次改一个点测一次数据有效果就保留没效果就回退。这样积累下来性能会稳步提升而且每一步都有数据支撑团队也更容易接受。异步加载只是性能优化里的一个环节把它和缓存、压缩、渲染优化结合起来用效果才会最大化。
返回列表