ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

异步加载与前端性能优化:从白屏事故到底层原理

异步加载与前端性能优化:从白屏事故到底层原理 我最早认真研究异步加载是因为一个让我印象深刻的线上事故某个运营活动页上线后首屏白屏时间从平时的1.2秒直接飙到4秒以上用户点击按钮后页面像卡死了一样。当时排查了很久最后发现罪魁祸首就是一段同步加载的第三方统计脚本它把整个页面的解析和渲染全堵住了。从那以后异步加载这四个字在我心里的分量完全不一样了——它不只是优化手段而是一个前端工程师必须吃透的底层原理。这篇内容围绕异步加载与性能优化这个主题适合刚接触前端性能优化的开发者也适合那些已经会用异步但说不清底层逻辑的人。我会把原理、实操、排障放在一起讲尽量说人话不堆概念。1. 为什么性能问题绕不开异步加载1.1 从一次白屏事故说起那次活动的页面其实不复杂一个Banner图、几个商品卡片、一段基础样式。但线上就是慢了而且越是在中低端安卓机上越明显。后来我逐个脚本做打断测试发现罪魁祸首是head里那行直接引用的第三方埋点JS。它在下载和执行完成之前浏览器解析HTML的工作会全程暂停。这个问题的本质是什么呢HTML解析器在遇到script src...时不知道这个脚本内部要做什么必须先把脚本下载下来、解析完毕、执行完毕才能继续解析后面的DOM。如果这个脚本体积大、网速慢或者脚本内部本身有同步请求白屏时间就会跟着一起拉长。你想想用户访问一个页面最先看到的应该是结构化的内容和样式结果因为一段脚本整张页面都出不来这就是典型的关键路径没优化。从那个案例里我学到一件事异步加载不是Web独有的概念它本质上是把非必要不阻塞这件事做到了极致。无论Web页面、小程序还是游戏资源加载底层原理都类似——谁站在关键路径上谁就必须尽可能小、尽可能快、尽可能异步。1.2 时间线上的关键路径原理要理解为什么异步加载有效得先看一条最简单的页面加载时间线。浏览器收到HTML后会依次经历解析HTML、构建DOM、构建CSSOM、执行脚本、渲染页面。其中脚本的位置和执行方式直接影响用户什么时候能看到内容这个时间点。我习惯把这段过程类比成一条生产线工人解析器走到一个工作台script标签前如果工作台需要先等你把零件脚本文件从仓库服务器搬过来那整条产线都得等他一个人。异步加载相当于把零件搬到另一条支线上去加工产线主流程不停等页面核心内容出来了再回来看结果。这正是核心关键路径的概念。优化性能本质上就是缩短关键路径让首次能看见内容的时间点尽量提前。而异步加载就是缩短关键路径最常用的武器。2. 拆开异步加载的底层原理2.1 同步脚本为什么阻塞解析先按照传统的方式写一个脚本引用script src/main.js/script浏览器看到这个标签后会发起请求去拿代码。这个请求期间HTML解析器不会往后走必须等脚本下载完再执行完才能继续。为什么这么设计因为脚本有可能调用document.write改写当前文档内容浏览器为了保证一致性只好采用你写一份脚本就得让我等等你的策略。这个策略在早年间Web比较简单的时代是没问题的但现在的第三库一个比一个大动辄几百KB同步引用的代价就非常高了。一个常见的坑是多个同步脚本依次引用时它们还必须排队执行A脚本下完执行完才能去下B脚本。页面加载慢很多时候就是这么一条链排出来的。我排查过不少项目发现很多人其实知道同步脚本不好但不知道不好在哪只会笼统地说要加async。这样的认知不够真正懂了阻塞原因之后你才能判断哪些脚本该异步、哪些不该异步。2.2 async与defer的本质区别HTML里有两个属性专门解决脚本阻塞问题一个是async一个是defer。表面上它俩都是异步实际执行时机完全不一样。defer的意思是这个脚本会并行下载但执行要等到HTML完全解析完之后。如果有多个defer脚本它们会按在文档里的先后顺序执行。这个特性很适合那些依赖DOM内容、又希望不阻塞解析的脚本。async的意思是脚本下载是异步的下载完成后立刻执行不管此时HTML解析到哪一步。多个async脚本之间的执行顺序是不保证的谁先下载完谁先执行。光看定义容易懵我一般用个生活化的例子defer就像排队取号叫号不管你前面有多少号都得等到叫号窗口轮到你才能办事async就像插队谁跑得快谁先办。实际情况里外部库如果对执行顺序有要求应该用defer完全独立、跟页面结构无关的统计类脚本才用async。但是有个细节可能很多人没留意defer和async都只对带src的外部脚本有效内联脚本加了这两个属性会被忽略。2.3 动态import()与代码分割的底层逻辑利用async和defer优化首屏只是解决了脚本在页面里的加载方式问题。真正进阶的做法是从代码结构层面把资源拆开这就是动态import()和代码分割。模块化开发里我们经常会写import { renderChart } from ./chart.js这种静态import在构建时就会被打包进主文件里。如果chart.js两三百KB用户访问一个只需要简单展示文本的页面也要白白加载这段代码这就非常不划算。改成动态import就不一样了async function openChartModal() { const { renderChart } await import(./chart.js) renderChart() }这种方式的核心逻辑是代码并不在页面初始化时加载而是等用户真正触发某个交互比如点击按钮才去加载。打包工具会把动态import的内容单独拆成一个chunk文件用户首次进入页面时根本不会下载它。动态import听上去简单但底层实现牵扯到Promise、模块作用域、浏览器异步加载机制等多个层面。尤其要注意动态import返回的是一个Promise异步里如果出错一定要有catch兜底否则用户点击一个按钮毫无反应控制台全是报错体验比同步加载还差。在前端工程化里代码分割通常是Webpack或Vite帮我们自动完成的。原理上它们会把原来是单一文件包的代码块按一定的拆分规则拆成多个异步chunk并且在运行时提供一个__webpack_require__.e这样的加载函数来完成chunk的加载与缓存。这个函数本质上也依赖浏览器创建script标签的能力只不过做得更工程化、更可控。3. 用指标反推异步策略别凭感觉优化3.1 核心指标里藏着优化方向做性能优化最忌讳的就是我感觉快了一些。优化之后有没有变快要看指标而不能看感觉。目前业界衡量Web性能最常用的是Core Web Vitals这一组指标包括LCPLargest Contentful Paint最大内容绘制、INPInteraction to Next Paint交互到下一帧的时间、CLSCumulative Layout Shift累计布局位移。LCP衡量的是页面最大内容元素何时渲染完成通常对应首屏主图或大标题。如果一个页面LCP一直报警大概率就是首屏关键资源被后面的非必要脚本阻塞了。这时把非必要脚本改成defer或async往往立竿见影。INP这个指标现在越来越重要它衡量的是页面交互响应能力。用户在页面上点击按钮后长时间没有反馈很大可能是因为浏览器主线程被某段长任务占住了。这种长任务往往来自一段同步脚本或者复杂度高的渲染逻辑。把不紧急的任务拆成异步不阻塞主线程是优化INP最直接的手段之一。我建议团队在接入异步优化之前先记录一个性能基线。基线数据不需要多精确能对比就行。通常我会记录三项FCPFirst Contentful Paint、LCP、INP以及一个可交互时间TTI。优化做完后对比同一台测试机、同一网络下的前后数值差值才是真实效果。3.2 图片和媒体资源的异步加载策略虽然标题讲异步加载但实际优化里图片才是最大的体积元凶。多数页面脚本压缩到极致后可能只有100KB但一张高清图直接500KB起步。图片不做优化脚本优化得再好也白搭。现代浏览器对图片异步加载已经提供了原生支持img srcphoto.jpg loadinglazy decodingasync alt描述loadinglazy让浏览器在图片进入视口附近时才进行加载首屏外的图片再也不会抢首屏带宽。decodingasync则告诉浏览器不要因为解码这张图片而阻塞其他内容的渲染。这里有一个真实使用中的建议首屏内的关键图片不要加loadinglazy。为什么因为LCP这个指标统计的就是首屏最大元素的渲染时间。如果LCP图片是懒加载的浏览器反而会为了等待它而延迟绘制测量下来LCP反而更难优化。我自己之前就犯过这个错给首屏Banner加了个lazy导致一张图片渲染被延后了几百毫秒后来去掉lazy反而更快了。媒体资源的异步策略不仅仅指图片懒加载还应该包括非首屏视频使用preloadnone、字体文件使用font-display: swap、背景图使用CSS媒体查询按需加载。这些手段和脚本异步加载配合在一起整个页面的资源请求瀑布图会明显变得更平缓。4. 实操过程中的关键切入点4.1 网络请求优先级控制很多人在做性能优化时只盯着脚本的async和defer却忽略了另一个重要维度——浏览器的请求优先级。现代浏览器会对页面中的不同资源进行优先级排序但我们不能全指望浏览器自动判断。浏览器给脚本设置优先级通常取决于脚本所处的位置和属性。如果你把一段非关键的统计脚本放在head里同步加载浏览器无论如何都会把它当成高优先级资源处理。而实际上它应该在页面渲染完成之后再执行。我推荐大家去Chrome DevTools的Network面板里看资源的Priority列。你会惊讶地发现很多你以为无关紧要的请求优先级居然排在最前面。把这些请求的加载方式改成异步或者说把请求时机延后释放出来的关键路径空间远比想象中大。手动控制优先级还有一个办法是使用link relpreload它能提前预取关键资源。但是这里必须注意一个反面教训preload会强制浏览器下载资源用错了反而增加无谓请求。我见过有人把一堆用不到的字体、图片全部preload首屏资源反而膨胀到爆炸。preload只建议用于确定会被首屏使用且加载时机偏晚的资源。4.2 打包工具里配置异步chunk工程化项目里光靠手写async和defer是不够的还要正确配置打包工具。以Vite为例默认配置下动态import已经会被自动拆分为单独chunk。但有几个细节可以调整。// vite.config.js export default { build: { rollupOptions: { output: { manualChunks(id) { if (id.includes(node_modules)) { return vendor } } } } } }把依赖统一打成vendor包能提高缓存命中率。因为业务代码更新频率高依赖代码相对稳定如果把它们混在一起业务代码一改动整个大包缓存失效用户就得重新下载全部内容。拆开之后业务代码更新时vendor包还能继续使用浏览器缓存。但manualChunks也不是拆得越细越好。拆得太细页面初始化时需要的HTTP请求数会飙升尤其在HTTP/1.1环境下浏览器的并发连接数是非常有限的反而会拖慢加载。一般来说保持一个到两个公共vendor包就够了。Webpack项目里对应的是splitChunks配置module.exports { optimization: { splitChunks: { chunks: async, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendors, chunks: all } } } } }这里的chunks: async表示只拆分异步加载的模块。如果你发现某个组件一直没被拆出来多半是静态import导致的需要检查是否确实存在动态import分割点。4.3 移动端和小程序的异步时机移动端性能优化和桌面端有个很大的区别网络带宽更低、主线程更紧张、设备性能差异巨大。看过不少团队搞优化在Chrome DevTools里用模拟器测看起来还不错一放到真实手机上就露馅。原因就在于模拟器的CPU和网络环境太理想了。我在移动端实践里最关心的是两个时机首屏渲染时机和用户交互时机。首屏渲染时机决定了用户看到内容的快慢交互时机决定了用户操作时会不会卡顿。移动端有一个专门针对启动性能的优化思路尽可能减少启动时执行的代码量把数据请求和逻辑计算推迟到渲染完成之后。这里的原理和Web的异步加载完全一致只是表现形态不同。Android端优化启动性能时也常提到I/O的异步化就是说不要在启动过程中同步读取大文件或数据库这些操作会直接卡住主线程导致首帧延迟。小程序环境下的异步加载也有独特性比如分包加载机制。小程序会把主包和分包分别下载用户必须等主包下载完才能打开页面分包则可以在需要时再加载。这要求你在设计阶段就把页面结构拆好常用的功能放主包低频使用的页面放分包。异步加载在这里表现为分包预下载和按需注入本质还是把关键路径缩短。5. 常见问题与排查技巧实录5.1 问题速查表覆盖最常见的小问题我把它们整理成了一张速查表方便对照。现象可能原因排查办法白屏时间长关键路径上有同步脚本Network面板看阻塞请求加defer或async页面内容出来了但按钮点不动主线程被长任务占住Performance面板录制交互过程找长任务加了lazy图片反而更慢对LCP图片加了懒加载去掉首屏图片的lazy属性第三方脚本顺序错乱多个async脚本执行顺序不定改用defer或把脚本合并动态import报错网络异常或模块加载失败给import加catch兜底处理资源分裂过多HTTP请求数暴增chunk拆得太细合理合并公共包字体闪烁FOUT字体加载时机太晚使用font-display: swap配合合理预加载切换页面时资源重新加载缓存策略不当检查静态资源hash、HTTP缓存头这张表不一定覆盖所有场景但它能帮你快速定位80%的常见问题。看你的页面症状属于哪一类再去针对性排查比打开Performance录制一整段然后看不懂要高效得多。5.2 我的排查工作流排障工作流我习惯用三步走。第一步复现并录制。先在性能较差的真机或限制网速的情况下打开页面用Performance面板录一小段时间轴。重点看Main线程上有没有执行时间特别长的Task以及Network里每个资源的加载时间线。如果有一段超过200毫秒的黄色长任务那里就是阻塞点。第二步动手微调。找到阻塞点后先做一个最小改动。比如把一个脚本加上defer或者把一张图片改成懒加载然后重新录制。一次只改一处对比前后数据差别。很多人优化时喜欢一次性改很多地方改完发现好像快了但又说不清是哪一步起的作用下次遇到问题照样不会。第三步回归验证。优化完成后要在不同设备、不同网络条件下做重复验证。别忘了看真实用户数据只有真实用户端的数据才能真正反馈优化效果。比如使用Performance Monitoring工具收集线上的实时数据如果没有条件退而求其次也要用多设备手动验证。我印象很深的一次排查是页面本身加载很快但用户滚动时每隔几秒就卡一下。最后用Performance录制发现是一个轮播图库在滚动时执行了同步解码图片的操作大量图片的解码计算全部堆在主线程上。解决办法很简单把轮播图库切换成延迟渲染模式非可视区域的图片等滚动到附近再解码卡顿立刻消失。5.3 避坑异步加载不是越多越好最后说一个容易被忽略的坑。有些开发者觉得既然异步好把什么资源都改成异步加载就对了。这个思路是错的。异步加载本身也有成本比如async脚本无法保证执行顺序loadinglazy图片增加滚动时的解码压力动态import增加运行时判断逻辑。我在实际项目中给过一个内部原则首屏完整渲染所依赖的资源一律保持正常优先级非核心功能、延时功能所需的资源才考虑异步加载。稳定的优化是把每个决策建立在这条资源在不在关键路径上的基础上而不是建立在异步听起来高级的直觉上。还有一个具体的小技巧如果你必须动态往DOM里插入script标签记得设置async true。默认情况下通过document.createElement创建的script并不会自动异步加载它同样会阻塞页面。这个细节是我踩过一次坑才发现的——当时动态插了一段地图SDK页面卡了整整两秒查了半天才发现问题出在脚本插入方式上。6. 最后分享一点实际体会做性能优化这些年我最大的体会是性能优化不是炫技它是一种需要持续积累的约束感。你掌握的原理越多能做的事情就越多但真正让你走得更远的是愿意在一台中低端手机上反复调试、愿意在用户看不到的地方抠细节的态度。异步加载这个知识点单看似乎只是几个属性、一个API的事但把它放进真实的项目和业务场景里你会发现它连接着网络协议、渲染机制、构建工具、运行环境等一大堆东西。这也是为什么我一直建议团队新人在入门阶段就学原理篇而不是急着背一堆优化清单。只有把底层逻辑弄明白了遇到再复杂的问题你都能顺藤摸瓜找到那把钥匙。下次再遇到页面加载慢先别急着上各种重型工具回头检查一下是不是有什么东西不该站在关键路径上。把该异步的异步把该拆的拆掉问题往往就迎刃而解了。
返回列表