ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

异步加载与性能优化:从原理到实战的完整指南 02-08-原理篇-异步加载与性能优化这篇内容我琢磨了很久异步加载和性能优化这几个字在面试题和项目复盘里出现的频率实在太高了但真正能把两者串成一条完整链路讲清楚的人并不多。很多人知道defer和async有区别知道Webpack能拆包但换个场景就不知道怎么下手了。这篇文章不打算从教科书定义开始而是从我自己在现网环境踩过的坑和优化经验出发把异步加载的核心原理、工程落地、指标验证和常见问题一次说透。适合谁看前端工程师、移动端开发者以及那些正在做页面性能优化但总觉得“优化了又好像没优化”的同学。如果你手里已经有一个线上项目无论是Web、H5还是混合应用这篇文章能给你一套可以直接照着用的排查思路和优化路径。1. 异步加载的底层逻辑页面慢的根源是“等”1.1 浏览器解析页面时的“流水线堵车”很多人一开始理解不了为什么异步加载能提升性能觉得“文件不都是要加载的吗早加载晚加载总量不变”。这个想法对但又不完全对。浏览器加载页面并不是简单的“下载完成就渲染”而是一条严格的解析流水线HTML在解析过程中遇到script标签必须停下来下载并执行脚本执行完才能继续往后解析HTML。这个行为叫作“解析阻塞”。想象一条手工流水线工位A的任务是读图纸工位B的任务是拧螺丝。如果图纸上写着“读到这一页必须立刻找旁边的人聊十分钟”整条线都得等。浏览器处理普通同步脚本就是这个效果。在一个没有做任何异步处理的页面上如果head里有若干个同步JS文件浏览器会依次下载、依次执行用户看到的内容迟迟无法呈现页面一直白屏。而绝大部分首屏功能根本不需要等所有脚本都执行完才有意义用户要的是“能看到、能点到、不白屏”而不是“等所有代码跑完”。1.2defer、async和解码阻塞这里就必须聊到两个最基础的异步加载手段defer和async。普通script标签HTML解析遇到就停先下载下载完立即执行执行完继续解析。script defer下载不阻塞HTML解析但执行时机被推迟到HTML解析完毕后、DOMContentLoaded事件触发之前。多个defer脚本之间保持顺序执行。script async下载也不阻塞HTML解析下载完成后立即执行执行过程依然会阻塞HTML解析。多个async脚本之间的执行顺序无法保证谁先下载完谁先执行。选择上的一句大实话是如果脚本之间有依赖关系用defer如果脚本完全独立比如埋点SDK、AB实验工具用async如果不能确定外部脚本是否安全可靠那就不加任何属性放在页面底部按顺序执行。除了脚本本身浏览器还有一个很容易被忽略的资源——CSS。CSS默认是渲染阻塞资源只要HTML解析过程中遇到了link relstylesheet浏览器就会暂停脚本执行等待CSS下载并构建完CSSOM。这导致了一个经典怪现象脚本明明用了defer但DOMContentLoaded还是等了好久因为前面挂了一个体积很大的CSS文件。我在一次现网排查中就发现某个页面的首屏CSS从服务端返回需要600ms而JS全部走了defer逻辑理论上“所有脚本执行完”的时点其实是被CSS卡住的。把首屏CSS拆小、做内联处理、后续CSS按需加载之后体感速度一下子起来了。这一点很多人做异步加载的时候都容易忽略。1.3 异步加载的本质不是“减少下载”异步加载解决的真正问题不是“下载文件变小”而是“缩短关键路径”。关键路径指从页面URL输入到用户首次看到有效内容这中间必须完成的所有步骤。同步脚本和CSS之所以是关键的因为它们在关键路径上异步加载的核心操作是把“不必需在首屏完成”的资源移出关键路径——用一句简化的话说就是延迟那些能让用户看到首屏的事腾出带宽和处理能力给真正的首屏内容。异步加载能提升页面性能靠的是一个非常朴素的模型只要不让脚本在解析期间阻塞浏览器页面HTML就能更快解析完成DOM构建得更快首次绘制的时间也就提前了。但是这里必须提醒一句异步加载不是万能药。如果一个页面是纯交互密集应用比如在线编辑器、复杂仪表盘所有交互逻辑都集中在少数几个组件上那就算把全部代码拆成100个异步模块该执行的逻辑一条不会少用户该等的还是得等。这种情况下优化重点反而要转向长任务拆分、缓存策略和代码体积削减。异步加载只是性能优化工具箱里的一把工具离了它不行但也不能只靠它。2. 工程落地代码分割、动态导入和资源优先级2.1dynamic import()异步加载在工程里的核心形态defer和async解决的是让浏览器“下载不阻塞”的问题但现代前端工程里我们更常谈的异步加载形态是dynamic import()也就是运行时动态导入模块。传统写法是import { report } from ./report.js这种静态导入会在构建阶段被静态分析最终打成一个包里在页面初始化时就会被拉下来。动态导入写法是async function loadReport() { const { report } await import(./report.js) report() }关键区别在于动态导入的内容会被构建工具单独拆成一个chunk只有在运行时真正执行到import()那一刻浏览器才会去下载这个chunk。这在用户触发某一个操作前这部分代码的下载和执行都不会发生。这种能力衍生出三类非常实用的优化第一路由级代码分割。把每个路由页面对应的组件和逻辑单独拆包用户访问首页时只下载首页代码其他页面代码等路由跳转时再加载。第二组件级懒加载。列表页首屏下方的长列表内容、弹窗组件、图表库这些不是用户一进来就需要的内容完全可以等用户滚动到附近或点击按钮时再动态加载。第三条件加载。比如根据用户权限决定加载哪些模块或者根据运行环境移动端还是桌面端加载不同逻辑。以我们一个后台项目为例最初打包产物只有一个约2MB的vendor.js加一个1MB的业务包。用户首屏加载时光解析执行这段JS就要近3秒时间低端设备更惨。后来把路由级代码分割加上首屏Javascript从3MB降到700KBLCP最大内容绘制从4.2秒降到了2.1秒这个优化没有改任何业务逻辑效果却非常明显。2.2 构建配置里的拆包逻辑代码分割的落地依赖构建工具。拿Webpack和Vite举例Webpack中常用的配置是optimization.splitChunksVite中则通过build.rollupOptions实现类似效果。我一直建议把拆包原则定为“业务代码按路由拆基础库按稳定性拆一次性大库单独拆”。具体来说像vue、react、react-dom这类基础库可以打成一个vendor基础包设置maxAge缓存时间足够长因为它们几乎不变利用缓存可以减少重复下载。像echarts、xlsx这类几百KB甚至上MB的体积巨大但只在特等场景使用的库必须拆独立chunk并且最好结合动态导入使用绝不能让用户一进首页就白白加载它们。但拆包也不是越碎越好。拆得太碎会导致页面初始化时产生几十个并行HTTP请求尤其在没有HTTP/2的旧环境下连接数和网络往返时延反而拖慢性能。我的实操经验是每个chunk体积保持在20KB到100KB之间比较适宜对于首屏必须的chunk可以接受稍大一点但不宜超过200KB。对于必须全局使用的大基础库我通常选择单独立包不会和业务代码混在一起。Vite项目中一个常见的坑是配置了splitting但没注意manualChunks导致异步路由文件没有被正确识别。排查方法也简单构建完成后看dist/assets下面是否生成了多个独立文件如果只有一个明显过大的JS文件说明异步加载大概率没有生效。2.3 资源优先级preload与prefetch的区别动态导入解决了代码懒加载的问题但有时我们又需要反过来对下一阶段会用到但当前还没触发的资源提前拉取。这个操作叫预加载两种主要方式preload是告诉浏览器这个资源当前页面就要用请优先加载。比如某个字体文件、首屏首屏的hero图片。它会让资源进入“高优先级队列”配合正确的as属性使用下载时机明显提前。prefetch是告诉浏览器这个资源以后可能要用可以空闲时加载。典型应用是用户即将跳转到下一个路由提前把这个路由的chunk prefetch下来跳转时秒开。实际优化中我最喜欢的组合是利用打包工具的路由懒加载插件自动生成prefetch链接。比如Webpack的prefetch魔法注释const nextPage () import(/* webpackPrefetch: true */ ./next-page.vue)这段代码会提示Webpack在构建阶段为next-page的chunk生成link relprefetch标签浏览器会在网络空闲时提前下载该chunk。用户真正进入下一页时因为资源已经缓存加载几乎是瞬时的。但prefetch也要注意克制。如果页面里有太多prefetch资源浏览器会在后台拼命下载导致用户当前的网络争用反而伤害首屏性能。我的经验是全站prefetch的资源控制在2-3个关键页面以内不能无差别标注。2.4 图片异步加载loadinglazy与fetchpriority说完JS还得说说图片。图片在网页资源里占总字节量通常超过60%。图片异步加载的基石是loadinglazy它让浏览器在图片接近可视区域时才加载。不过这里有个一直容易被搞反的概念loadinglazy和async、defer不同它不是让图片“延后下载”而是让图片“进入视口前才下载”。配合IntersectionObserver手动实现懒加载的旧方案现在基本不需要了原生属性已经覆盖绝大多数场景而且原生实现不会产生自己写监听器时的性能开销。后来出现的fetchpriorityhigh则更精细它允许我们告诉浏览器哪张图片是首屏最重要的比如首屏的大标题Banner图给它设置fetchpriorityhigh浏览器就会优先加载。其余的非首屏图保持lazy即可。有个真实案例让我印象很深电商活动页首屏有一张大的活动海报和一堆商品图。最初没有做任何优先级控制浏览器默认会把所有图片的加载顺序排在前面导致首屏海报迟迟不出来。给海报加fetchpriorityhigh后页面LCP直接降了1秒多这在移动端性能优化上是非常典型的收效方式。3. 性能指标与量化验证优化到底有没有用3.1 核心指标概念FCP、LCP、TTI、TBT做性能优化如果只凭“感觉变快了”那和没做没有区别。我用到的量化指标基本就是Web Vitals那一套再加两个工程指标。FCPFirst Contentful Paint首次内容绘制指页面第一次出现任何文本、图片或画布内容的时间。这个指标反映的是用户“开始觉得有东西加载出来了”的时刻。LCPLargest Contentful Paint最大内容绘制指首屏最大元素的渲染时间。它比FCP更能代表用户的真实感知因为FCP可能是很小的一个loading条用户其实什么实质内容都没看到。TTITime to Interactive可交互时间指页面已经稳定到可以可靠响应交互的时点。它会通过动态暂停机制规避长任务的影响。移动端非常关心TTI因为低端设备上主线程被长任务占满时用户点击按钮半天没反应。TBTTotal Blocking Time总阻塞时间指从FCP到TTI之间所有长任务超过50ms的任务的阻塞时间总和。这个指标和long task监控直接相关。在手机性能优化和Android启动性能优化里这些指标同样适用只是视角略有差异移动端更关注CPU占用、线程调度和应用冷启动阶段Web端则更强调页面渲染链路。但底层逻辑是相通的——都是把关键路径上的慢操作移出启动流程用异步手段缓解阻塞。3.2 性能预算与回归监控优化做完之后最关键的一步是建立性能预算。没有预算优化成果会在下一次需求迭代中被悄悄“吃回去”。我常见的做法是在CI流程里设置Light CI策略通过Lighthouse CI跑预算检查对FCP、LCP、TTI设上限超了就报警。有些人可能会觉得“这些指标太敏感稍微加个功能就超标”所以预算的设定需要结合项目实际情况预算过严会拖慢开发效率预算过松则失去意义。我的参考值是这样的指标优秀良好需优化FCP0-1.8秒1.8-3秒3秒以上LCP0-2.5秒2.5-4秒4秒以上TTI0-3.8秒3.8-7.3秒7.3秒以上TBT0-200ms200-600ms600ms以上这套标准在移动端H5场景要更严因为移动端CPU算力弱建议把LCP目标定为1.8秒以内。你会发现最终限制因素往往不是网速而是CPU解析JavaScript的开销。3.3 量化验证用Performance面板和Navigation Timing指标有了怎么观测具体数据我日常的流程是第一打开Chrome DevTools的Performance面板录制一次页面加载观察主线程上的任务颜色。黄色长条是Scripting紫色是Rendering绿色是Painting。如果加载过程中有大量黄色长条连续出现说明JavaScript执行占了太大比重此时要思考哪些逻辑可以延后、哪些代码可以拆出首屏。第二用performance.getEntriesByType(navigation)拿到真实的导航时序数据。里面有一组很关键的时间戳domContentLoadedEventEnd、loadEventEnd、responseEnd。如果再配合资源级别的performance.getEntriesByType(resource)可以直接看到每个chunk的真实下载耗时和开始时间就能精确判断哪些资源在关键路径上、是否真的实现异步加载。这种数据驱动的方式比纯靠浏览器插件看个概览更能定位问题。4. 移动端与手游场景的性能优化差异4.1 移动端性能优化和Android启动优化需要什么移动端性能优化是热搜词里绕不开的方向和Web端异步加载的核心逻辑高度一致。Android应用启动时系统要执行的任务非常多加载资源、初始化Application、构建UI、启动首帧。如果Application.onCreate()里同步做了太多耗时操作启动时间会直线飙升。解决方案就是异步化把那些不直接影响首帧的初始化任务放到子线程或者使用AndroidX Startup库做初始化任务的任务调度把不需要立即渲染的Fragment改为懒加载把大型Banner位改成占位图加异步加载把网络请求延迟到首帧绘制完成之后再发起。这和浏览器里把JS脚本移出关键路径、延迟到最后一刻加载的思路是完全一样的。H5嵌在移动端里还有一个很典型的坑没有利用好系统浏览器预加载能力。比如在Android WebView中首次访问H5时可以提前创建WebView预热的实例让HTML、CSS、JS提前完成下载页面真正跳转时直接呈现。iOS的WKWebView也有类似策略。这本质上是异步加载思维的工程化延伸——不是不加载而是在用户还不需要看的时候预先准备好。4.2 手游性能优化里的“异步”思维手游性能优化看起来和Web完全不搭界但它里面的异步思想对我的前端优化很有启发。手游场景下最典型的问题是主线程绘制卡顿因为游戏主线程要同步处理渲染、逻辑、动画、输入。优化方案就是做数据预加载、纹理异步加载、资源分包下载用到再加载。这和我们把图表库动态导入用到再下载本质上是同一种策略。另一个启示来自手游的帧率管理——把耗时操作拆成小任务分布到多帧执行避免任何一帧阻塞超过16.6ms。在Web里也有对应操作用requestIdleCallback把非关键任务安排到浏览器空闲周期执行或者把一个大的同步计算拆成多个setTimeout、requestAnimationFrame碎块避免长任务占用主线程。这种跨领域的思路碰撞对拓宽性能优化的视野很有价值但具体落地时还是要回到各自的平台机制上。5. 实际踩坑记录异步加载带来的新问题5.1 懒加载导致的布局偏移和白色闪屏懒加载最常见的副作用是布局偏移CLS差。比如页面下方有一张广告图设置了loadinglazy图片没有占位高度。加载前它高度为0加载完成后突然把下方内容往下推用户正在阅读的段落瞬间跳位体验很糟糕。解决方式是在渲染前明确指定图片的宽高比例或者使用CSS的aspect-ratio属性提前占位。例如.lazy-image-wrapper { aspect-ratio: 16 / 9; }这样浏览器知道空间已经预留了加载完成后不会产生位移。还有一种场景是弹窗组件的懒加载用户点击按钮后才动态加载弹窗代码但弹窗加载期间按钮点击后没有任何反馈用户以为是没点到又连点了几下。处理方法是点击后立刻显示一个极简的本地loading态同时后台加载模块加载完再填充内容。这个体验细节很多人一开始都想不到但实际用户碰多了就会骂。5.2 异步加载后首屏反而更慢的怪现象有一段时间我做了异步加载优化上线后监控数据显示LCP反而变高了。排查之后发现的根因是懒加载触发得太晚部分异步资源刚好阻塞了原本可以提前复用的HTTP连接。还有一次是prefetch和preload混用同一个chunk既被preload标记为高优先级又被prefetch标记为低优先级浏览器资源调度时产生了内部冲突有些浏览器会选择把资源从缓存中丢弃然后重新下载。我现在处理这类问题有个固定检查顺序第一打开Network面板看第一个请求和最后一个关键请求的时间差判断是否有过多串行请求。第二检查哪些资源是“误入”关键路径的比如一张被loadinglazy的图却出现在首屏可视区浏览器必须等它下载完才能完成LCP记录。第三看服务端是否支持HTTP/2支持的话可以大胆并行加载不支持的话过碎的资源拆包反而得不偿失。5.3 回归监控缺失优化成果被悄悄抹平异步加载优化上线后经过几轮迭代老规矩又会回归需求不断新增开发者不知道性能预算是多少或者不关心慢慢地在页面里又塞进大量同步模块。结果就是优化成果在两个月内又回到原点。我现在做优化一定会搭配三项配套第一CI流水线接入Lighthouse CI对关键指标设立硬性阈值超了就红牌。第二部署RUM真实用户监控比如用Performance Timeline API加自定义埋点持续上报真实用户的数据而不是只看本地无痕模式下测出来的数值。第三代码评审时关注diff里是否有新增动态导入提醒开发者尽量把新模块做成异步加载形态。这套机制不一定能把性能维持在100分但至少让团队每次合并代码时都看得到指标变化。优化这种事最怕的不是做不好而是没人管。5.4 长任务拆解的实操写法假设一个表单页面在初始化时要从localStorage里同步读取一批数据做数组遍历、排序、过滤总耗时在100ms以上这在低端设备上就是一个明显的长任务。一个简单有效的拆法是把这部分逻辑改造成分批处理const tasks processBigData() // 分成4批每批之间让出主线程 const chunkSize Math.ceil(tasks.length / 4) requestIdleCallback(function process() { const nextChunk tasks.splice(0, chunkSize) handleChunk(nextChunk) if (tasks.length 0) { requestIdleCallback(process) } })requestIdleCallback能确保每批任务都在浏览器空闲窗口执行不会堵塞用户交互。但要注意一点requestIdleCallback在低优先级环境下执行可能被无限期推迟。关键任务不要依赖它可以退回来用setTimeout包裹的切片方式虽然不够优雅但在兼容性上更稳妥。写在最后异步加载与性能优化这事我做了几年最大的体会是优化不是“一次性技术动作”而是一个持续迭代的过程。你需要掌握基础原理知道浏览器怎么工作、关键路径怎么算、什么指标更能反映真实体验也需要在工程化工具里把拆包、动态导入、预加载这些手段落到配置上更需要建立一套监控和回归机制确保优化效果长期有效。异步加载和性能优化的组合是很典型的“拿时间换空间”的思维合理地延迟非关键路径资源的加载时间从而为关键资源腾出网络和CPU空间。这个思路把起步数据降下来却不改变最终功能的完整性——用户以后要用随时能拿到只是不占用你现在的加载预算。最后一句话想送给正在做性能优化的朋友不要让指标绑架业务也不要让业务杀掉指标。性能优化要和开发效率并行不能成为团队的负担。找到那个平衡的度优化的价值才能真正发挥出来。如果你刚接触这个领域先从找到自己项目中最大的首屏JS文件开始拆它会让你很快看到整套异步加载的力量。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进