资讯详情

资讯详情

前端性能优化核心:异步加载与代码分割实战指南

做前端这么多年我见过最多的性能优化误区不是不会用工具而是把优化当成单纯的减重——把代码压缩一下、图片压一压就觉得完事了。等真正上线首屏还是慢交互还是卡用户照样流失。异步加载这个技术恰恰是解决这类问题的关键思路之一它不追求总资源变少而是追求该到的先到、不该到的别挡路。这篇原理篇的内容就是围绕异步加载展开讲清楚它为什么能提升性能、在什么场景下用、怎么做才能不踩坑。适合正在做前端工程化改造、或者被首屏加载速度困扰的开发者参考。1. 为什么异步加载能提升性能——先搞清楚瓶颈在哪1.1 浏览器渲染路径上的阻塞点很多人对性能优化的理解停留在请求数量多、文件体积大但实际上浏览器真正卡住的地方往往不在网络传输阶段而在解析和执行阶段。这里要先讲一个基础概念浏览器的关键渲染路径也就是从拿到 HTML 到把像素画到屏幕上的整个过程大致包含 DOM 构建、CSSOM 构建、样式计算、布局和绘制这几步。JavaScript 在这条路径上的地位很特殊——它在执行时可以修改 DOM 和样式所以浏览器遇到script标签时必须停下 HTML 解析先把脚本下载并执行完才能继续往下解析。这就是所谓的脚本阻塞解析。如果一个页面的脚本都放在head里且体积巨大那么用户看到的将是一段时间的空白页面什么内容都渲染不出来。CSS 同样存在阻塞但它的阻塞方式和 JS 不一样。CSSOM 构建不阻塞 HTML 解析却会阻塞渲染——因为浏览器必须先有完整的样式规则才知道页面长什么样。这也是为什么业内一直强调把 CSS 放头部、把 JS 放底部或异步化。把 JS 异步化本质上是把阻塞解析变成不阻塞解析让浏览器先完成 DOM 构建和首次渲染再回头处理脚本逻辑。我见过不少团队用语义化标签、压缩 CSS、拆分路由唯独对脚本加载方式不在意最后性能分数就是上不去。后来一看页面上有十多个script标签全是同步加载主线程被堵得死死的。这个教训说明异步加载不是优化手段里的锦上添花而是绕不开的核心环节。1.2 性能指标到底看什么聊异步加载之前得先确立一个度量标准不然优化就是无头苍蝇。目前业界主流的核心指标是 Web Vitals重点关注三个LCP最大内容绘制、INP交互到下一次绘制的延迟替代了之前的 FID、CLS累计布局偏移。LCP 直接反映首屏核心内容的加载速度通常要求 2.5 秒以内INP 反映页面交互的响应速度要求 200 毫秒以内CLS 反映页面视觉稳定性要求小于 0.1。这三个指标和异步加载的关系非常直接LCP 受资源加载顺序影响INP 受主线程被脚本占用情况影响CLS 则常因为图片、字体延迟加载而触发。用 Lighthouse 可以在本地快速跑出这些指标的模拟值但如果想看真实用户的分布还得靠 Performance API 埋点。简单做法是使用performance.getEntriesByType(paint)拿到 FCP 时间用new PerformanceObserver监听 LCP 条目再把数据上报到监控平台。这里给一个很基础的上报代码new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (entry.entryType largest-contentful-paint) { console.log(LCP:, entry.startTime); // 这里可以上报到自己的监控系统 } } }).observe({ type: largest-contentful-paint, buffered: true });指标不是用来汇报的是用来定位问题的。看 LCP 慢就要继续下钻是资源加载慢还是脚本执行阻塞了渲染还是图片本身太大。异步加载解决的是其中资源加载时机和脚本执行时序这两类问题多数情况下面临的瓶颈也正在这里。2. 异步加载的核心实现方式——各自解决的问题不同2.1 defer 和 async最基础也最容易用错HTML 里给script加上defer或async属性是最简单的异步加载方式。但这两个属性经常被人混着用其实它们的语义完全不同。defer的意思是延迟执行——脚本会继续下载但不阻塞 HTML 解析等整个文档解析完毕、DOMContentLoaded 事件触发之前再按顺序执行。多个defer脚本之间保持文档顺序。async的意思是异步执行——脚本下载不阻塞解析但下载完成后立刻打断解析并执行多个async脚本之间不保证顺序。用表格来对比最直观属性下载是否阻塞解析执行是否阻塞解析执行顺序保证适用场景无属性是是按文档顺序小体积、必须立即执行的脚本defer否否按文档顺序依赖 DOM 的普通脚本async否是下载完即执行不保证独立无依赖的统计类脚本这里有一个高频误区很多人以为async就是异步加载的全部其实async脚本的执行时机存在不确定性如果脚本之间有依赖关系用async就容易出现某个函数未定义的报错。所以除了埋点、统计这类完全独立的外链脚本推荐用async之外业务脚本我一般建议用defer。我在实际项目里踩过一次很典型的坑一个活动页加载了两个 SDK一个负责数据上报一个依赖前者的数据初始化。我把两个脚本都加了async上线后有 3% 的用户报了TypeError排查了半天才定位到是脚本执行顺序被async打乱了。后来改成defer问题直接消失。所以按顺序执行这个特性在脚本有依赖时就是救命的。2.2 动态 import 与代码分割把大包拆小再按需加载defer和async解决的是脚本加载时机但还有一个问题它们解决不了如果你的首屏页面本身就需要 2MB 的 JavaScript那不管怎么异步这 2MB 都得下载完才能跑起来。这时候需要的是代码分割Code Splitting核心工具是import()动态导入。现代构建工具webpack、Vite、Rollup对import()都有一等支持它会自动把动态导入的模块拆成独立的 chunk在真正执行到那行代码时才发起请求。以 Vue 和 React 项目为例最常用的做法是做路由级懒加载// 以 Vue Router 为例 const routes [ { path: /dashboard, component: () import(./views/Dashboard.vue) } ];React 里类似用React.lazy包一层动态导入配合Suspense提供加载状态const Dashboard React.lazy(() import(./views/Dashboard)); function App() { return ( Suspense fallback{Loading /} Dashboard / /Suspense ); }这两段代码看起来简单但背后的收益很大首屏只下载首屏需要的代码其他路由的代码在用户访问时才加载主包体积可能从 2MB 降到 400KBLCP 直接上一个台阶。不过代码分割不是路由拆分就结束了。我还习惯把第三方库单独拆出来利用浏览器缓存减少重复下载。webpack 里可以这样配置// webpack.config.js module.exports { optimization: { splitChunks: { cacheGroups: { vendor: { test: /node_modules/, name: vendor, chunks: initial, priority: 10 } } } } };把体积大、更新频率低的第三方库比如 UI 框架、图表库拆成一个独立的 vendor chunk能让用户在二次访问时命中缓存不用重新下载整份业务代码里的公共依赖。这里需要权衡的是拆得太细会导致请求数变多HTTP 握手成本反而上升拆得太粗又达不到缓存利用效果。实践下来vendor 包控制在 200KB 到 500KB 之间比较合理。2.3 预加载与预连接让浏览器提前干该干的活异步加载不只是延迟还包括提前。preload、prefetch、preconnect这三个资源提示Resource Hints在性能优化里同样扮演着重要角色。preload是告诉浏览器这个资源当前页面马上就要用请提前下载。典型场景是首屏大图、关键字体、以及 CSS 文件里引用的背景图。preconnect是提前建立到某个源origin的网络连接包括 DNS 查询、TCP 握手和 TLS 协商常用于需要跨域请求的第三方服务。prefetch则是利用浏览器空闲时间下载下一个页面可能用到的资源相当于给用户的下一步操作提前做准备。三者的使用方式很简单在 HTML 里加标签或者在 HTTP 响应头里加字段即可link relpreload asimage href/img/hero.webp link relpreconnect hrefhttps://api.example.com link relprefetch href/js/chunk-detail-page.js注意preload一定要配上as属性否则浏览器不知道资源的类型可能导致重复下载或无法利用。而且preload是一个强提示浏览器会优先处理如果用在不重要的资源上反而会挤占关键资源的带宽。我见过有团队把全站十几张图片全部preload结果首屏关键资源全部排队性能不升反降。正确的做法是只preload首屏渲染必需的那一两张图、那一两个字体文件。还有一个细节值得提字体的异步加载。字体文件如果在 CSS 里通过font-face引入并且是display: block默认那么浏览器在字体下载完成前不会渲染使用该字体的文本这会导致不可见的文本FOIT问题。建议设置font-display: swap让文本先用系统字体显示字体加载完再替换虽然会带来一些字体切换闪烁但至少用户能立刻读到文字。3. 把异步加载落地到真实项目——一个可复用的实操流程3.1 改造前的基线测量没有对比就没有优化任何性能优化项目第一步都是测量基线。这里的基线包括三个层面核心指标数值LCP、INP、CLS、资源加载清单每个资源的耗时、大小、以及主线程的繁忙程度。推荐的做法分三步走。第一步用无痕模式跑 Lighthouse记录优化前的性能分数和主要瓶颈项。第二步用 Chrome DevTools 的 Performance 面板录制一次页面加载过程观察主线程上哪些任务耗时最长、有没有长任务Long Task阻塞了交互。第三步用 Network 面板生成 HAR 文件梳理当前页面加载了哪些资源、按耗时排序找到拖后腿的资源。这里我要强调一个容易忽略的点测量环境要保持一致。在同一台机器、同一个网络条件下测最好用模拟中端设备的 CPU 降频和网络节流否则数据波动太大优化前后没办法对比。Lighthouse 里可以直接设置模拟 Moto G Power / 4G 网络的环境这是比较接近真实用户的场景。基线数据出来后需要给自己定一个明确的目标。比如LCP 从 3.8 秒降到 2.5 秒以内减少首屏 6 个同步脚本把首屏 JS 从 1.5MB 降到 500KB。目标越具体后续每一项优化做没做到位就越容易判断。3.2 代码分割实操从路由到组件再到三方库代码分割的落地顺序我建议先路由、再组件、最后三方库。路由分割收益最大因为每个路由往往代表一个完整的功能模块组件分割适合体积大但不常驻的组件比如弹窗、编辑器、图表三方库分割则是利用缓存特性做长期优化。以我最近重构的一个后台管理系统为例。改造前首屏加载了打包后的完整 bundle体积 1.8MBLCP 达到 4.2 秒。第一步把路由改成动态导入后首屏 JS 降到了 900KBLCP 变成 2.7 秒。第二步把系统里一个体积 400KB 的富文本编辑器改为点击弹出时才动态导入首屏 JS 又降了 400KBLCP 到了 2.1 秒。第三步把 echarts 拆成独立的 vendor chunk 并开启长期缓存二次访问时 LCP 稳定在 1.6 秒左右。这个改造过程踩过的坑也不少最典型的是路由切换时出现短暂的白屏或者闪烁。根本原因是组件还没加载完成页面就已经开始切换了。解决办法是配合Suspense或者路由守卫做过渡状态在组件加载期间展示骨架屏或 loading 提示。用户体验上一个稳定的 loading 状态比无反馈的白屏好得多。具体到配置层面Vite 项目不需要像 webpack 那样手动配置splitChunks它会按动态导入自动分割。但要注意一点Vite 默认对一个动态导入的模块会生成一个 chunk如果页面里动态导入很多小模块可能产生大量小文件首次访问时 HTTP 请求数量变多。建议在build.rollupOptions.output.manualChunks里做一层聚合把关联紧密的模块合并成一个 chunk。3.3 图片、字体与长列表容易被忽视的异步细节除了 JavaScript图片和字体是首屏性能的另外两个大头。图片方面现代浏览器已经原生支持loadinglazy和decodingasync两个属性但用的时候要分清楚loadinglazy是延迟加载适用于非首屏图片decodingasync是异步解码让图片边加载边渲染不阻塞主线程适用于所有图片。img srcproduct.jpg loadinglazy decodingasync alt产品图但是首屏关键图片LCP 元素千万不要加loadinglazy。因为懒加载可能会让浏览器推迟图片请求导致 LCP 反而不达标。正确的做法是给首屏图片加fetchpriorityhigh明确告诉浏览器这个资源优先级最高img srchero.jpg fetchpriorityhigh alt首屏主视觉还有一个现代 CSS 特性值得推荐content-visibility: auto。这个属性可以让浏览器跳过屏幕外元素的渲染和布局适用于长列表、评论区、折叠面板这类内容很长但初始不可见的区块。它的收益是降低首次渲染时的布局计算量让页面更快达到可交互状态。不过要注意如果元素的高度会变化使用content-visibility: auto可能导致滚动条跳动需要配合contain-intrinsic-size设置一个预估尺寸。字体也是首屏的一个隐性杀手。我见过一个项目为了统一品牌字体加载了包含五六个字重的字体文件每个字重都有 100 多 KBLCP 被迫等到字体下载完。异步加载字体有三个关键配置font-display: swap解决文本可见性问题unicode-range按需加载特定字符集构建工具里的字体子集化可以大幅压缩体积。4. 异步加载的常见问题与排查技巧实录4.1 异步加载后出现白屏或闪烁怎么办异步加载最大的副作用就是资源没准备好就开始渲染导致白屏、闪烁或者布局跳动。最常见的情况是路由懒加载后用户点击进入新路由页面先白一下等 chunk 下载完才渲染出来。解决思路有三个层次。第一层加载过程中给出视觉反馈用骨架屏或 loading 指示器填补等待时间。第二层对用户最可能马上访问的下一个路由做prefetch利用空闲时间提前下载让路由切换几乎无感。第三层如果是首屏脚本因为defer导致关键 UI 初始化延迟可以把初始化逻辑提前或者直接改为同步加载少量关键脚本。这里要注意prefetch是使用空闲带宽如果页面本身已经很重prefetch可能加剧资源竞争。实践中可以结合requestIdleCallback在浏览器空闲时才触发预取if (requestIdleCallback in window) { requestIdleCallback(() { const link document.createElement(link); link.rel prefetch; link.href /js/chunk-detail-page.js; document.head.appendChild(link); }, { timeout: 3000 }); }这段代码的意思是让浏览器在空闲时最长等 3 秒再加载下一个页面的 chunk不影响首屏的关键路径。4.2 脚本依赖顺序被打乱前面提到过async不保证执行顺序。但就算用了defer如果页面里同时混用同步脚本和defer脚本也可能出现执行顺序问题。defer脚本在所有同步脚本执行完毕、DOM 解析完成之后才执行。如果你在同步脚本里引用了defer脚本定义的全局变量运行时就会报错。排查这类问题我的建议是查看浏览器 DevTools 里的 Coverage 面板看脚本实际执行的覆盖率同时用 Performance 面板看脚本的执行时间戳。如果两个脚本的执行顺序和时间点和你预期不一致优先检查是不是async属性被错误地加上了。另外一个容易被忽略的情况是动态插入的脚本。通过document.createElement(script)动态创建的脚本在不加async属性的情况下默认表现得async——下载完就执行不看顺序。如果动态脚本之间有依赖需要手动控制加载顺序或者用 Promise 链串行加载。4.3 动态 import 带来的内存泄漏隐患代码分割后有一个问题很容易被忽略动态加载的组件在卸载后如果没有正确清理事件监听器、定时器或全局引用驻留在内存里的代码和 DOM 引用就会泄漏。尤其在单页应用里频繁切换路由累积的内存泄漏会让页面越来越卡最终影响 INP 指标。排查内存泄漏的方法是使用 Chrome DevTools 的 Memory 面板做堆快照对比操作前后各拍一次快照看哪些对象没有被回收。我曾在项目里发现一个弹窗组件在关闭后仍有事件监听器引用着 DOM 节点每次打开弹窗都泄漏一块内存。定位过程不复杂但如果没有异步加载这种问题不易暴露——因为所有代码都在初始包里常驻泄漏早就存在且会被统一发现。异步化之后问题变成动态加载的模块在卸载时是否彻底清理这个意识要建立起来。4.4 缓存策略与版本更新的博弈异步加载和浏览器缓存是相辅相成的。资源分割后文件名带上内容哈希如app.8f3a2b.js就能实现内容变了文件名变、内容没变命中缓存的效果。但这里有个陷阱如果你在主 HTML 里全量引用了这些带哈希的脚本而 HTML 本身缓存时间太长那么用户访问到旧的 HTML还是会请求旧版本的资源。稳妥的做法是HTML 文件不缓存或者短缓存比如no-cache带哈希的 JS/CSS 资源长缓存比如max-age31536000。这样发布新版本时用户必然拿到新的 HTML从而引用新的资源文件。还有一个小技巧对 vendor chunk 使用稳定不动的文件名而不是哈希名。因为第三方库更新频率低稳定的文件名可以让长期回访用户直接命中强缓存。但这要求你在升级依赖时注意缓存失效问题毕竟依赖变了但文件名没变用户会继续用旧代码。这两个策略各有取舍我一般建议业务代码用哈希名、第三方库用固定名然后靠版本号管理依赖升级时的缓存失效。4.5 一个实用的优化速查表把这么多年的经验整理成一张速查表方便读者在实际项目中对照场景推荐方案需要注意首屏脚本过多合并必要脚本、其余用 deferdefer 脚本有依赖时按顺序执行统计/埋点脚本使用 async 独立加载确认脚本无依赖关系路由级功能代码import()按路由分割配合 Suspense 或骨架屏大体积弹窗/编辑器点击时动态导入卸载时清理监听器和引用首屏关键图片常规加载 fetchpriorityhigh不要加 loadinglazy非首屏图片loadinglazy decodingasync注意占位尺寸避免 CLS品牌字体font-display: swap 字体子集化使用时注意 FOIT/FOUT 权衡下一页面资源prefetch 或空闲时加载避免抢占关键资源带宽这张表不是万能药但它覆盖了我这些年做性能优化遇到的高频场景。每次接到新的优化任务先对照场景找到对应方案再深入剖析具体代码。5. 关于性能优化的一点延伸思考异步加载不是孤立的技术它和性能优化的其他环节是咬合在一起的。比如你做了代码分割、做了资源预加载但如果接口请求没有优化、数据缓存策略不合理首屏依然可能慢。再比如异步加载减少了主线程负担但如果你的代码本身在运行时存在大量低效的 DOM 操作INP 照样不会好看。我的一个习惯是每次优化完重新跑一遍基线测量对比数据和目标差距。如果达到了目标不要急着庆祝再看一眼有没有引入新的问题——比如布局偏移变大、请求数变多、内存占用升高。性能优化是个平衡游戏每个指标都有取舍关键是在具体的业务场景里找到那个刚好合适的点。另外想提醒一句性能优化很难一次到位因为业务在变、依赖在变、用户设备也在变。异步加载这套方法论不需要经常换但具体到每个版本的依赖升级、每个新功能的上线都应该重新审视资源的加载策略是否还合理。把性能测量和监控做成持续流程比一次性的大改造更有价值。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →