做前端这么多年,我见过最多的性能优化误区,不是不会用工具,而是把优化当成单纯的"减重"——把代码压缩一下、图片压一压,就觉得完事了。等真正上线,首屏还是慢,交互还是卡,用户照样流失。异步加载这个技术,恰恰是解决这类问题的关键思路之一,它不追求"总资源变少",而是追求"该到的先到、不该到的别挡路"。这篇原理篇的内容,就是围绕异步加载展开,讲清楚它为什么能提升性能、在什么场景下用、怎么做才能不踩坑。适合正在做前端工程化改造、或者被首屏加载速度困扰的开发者参考。
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 降到 400KB,LCP 直接上一个台阶。
不过代码分割不是"路由拆分就结束了"。我还习惯把第三方库单独拆出来,利用浏览器缓存减少重复下载。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 rel="preload" as="image" href="/img/hero.webp"> <link rel="preconnect" href="https://api.example.com"> <link rel="prefetch" 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.8MB,LCP 达到 4.2 秒。第一步把路由改成动态导入后,首屏 JS 降到了 900KB,LCP 变成 2.7 秒。第二步把系统里一个体积 400KB 的富文本编辑器改为点击弹出时才动态导入,首屏 JS 又降了 400KB,LCP 到了 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,图片和字体是首屏性能的另外两个大头。图片方面,现代浏览器已经原生支持loading="lazy"和decoding="async"两个属性,但用的时候要分清楚:loading="lazy"是延迟加载,适用于非首屏图片;decoding="async"是异步解码,让图片边加载边渲染,不阻塞主线程,适用于所有图片。
<img src="product.jpg" loading="lazy" decoding="async" alt="产品图">但是首屏关键图片(LCP 元素)千万不要加loading="lazy"。因为懒加载可能会让浏览器推迟图片请求,导致 LCP 反而不达标。正确的做法是给首屏图片加fetchpriority="high",明确告诉浏览器这个资源优先级最高:
<img src="hero.jpg" fetchpriority="high" alt="首屏主视觉">还有一个现代 CSS 特性值得推荐:content-visibility: auto。这个属性可以让浏览器跳过屏幕外元素的渲染和布局,适用于长列表、评论区、折叠面板这类内容很长但初始不可见的区块。它的收益是降低首次渲染时的布局计算量,让页面更快达到可交互状态。不过要注意,如果元素的高度会变化,使用content-visibility: auto可能导致滚动条跳动,需要配合contain-intrinsic-size设置一个预估尺寸。
字体也是首屏的一个隐性杀手。我见过一个项目为了统一品牌字体,加载了包含五六个字重的字体文件,每个字重都有 100 多 KB,LCP 被迫等到字体下载完。异步加载字体有三个关键配置: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-age=31536000)。这样发布新版本时,用户必然拿到新的 HTML,从而引用新的资源文件。
还有一个小技巧:对 vendor chunk 使用稳定不动的文件名,而不是哈希名。因为第三方库更新频率低,稳定的文件名可以让长期回访用户直接命中强缓存。但这要求你在升级依赖时注意缓存失效问题,毕竟依赖变了但文件名没变,用户会继续用旧代码。这两个策略各有取舍,我一般建议业务代码用哈希名、第三方库用固定名,然后靠版本号管理依赖升级时的缓存失效。
4.5 一个实用的优化速查表
把这么多年的经验整理成一张速查表,方便读者在实际项目中对照:
| 场景 | 推荐方案 | 需要注意 |
|---|---|---|
| 首屏脚本过多 | 合并必要脚本、其余用 defer | defer 脚本有依赖时按顺序执行 |
| 统计/埋点脚本 | 使用 async 独立加载 | 确认脚本无依赖关系 |
| 路由级功能代码 | import()按路由分割 | 配合 Suspense 或骨架屏 |
| 大体积弹窗/编辑器 | 点击时动态导入 | 卸载时清理监听器和引用 |
| 首屏关键图片 | 常规加载 + fetchpriority=high | 不要加 loading="lazy" |
| 非首屏图片 | loading="lazy" + decoding="async" | 注意占位尺寸避免 CLS |
| 品牌字体 | font-display: swap + 字体子集化 | 使用时注意 FOIT/FOUT 权衡 |
| 下一页面资源 | prefetch 或空闲时加载 | 避免抢占关键资源带宽 |
这张表不是万能药,但它覆盖了我这些年做性能优化遇到的高频场景。每次接到新的优化任务,先对照场景找到对应方案,再深入剖析具体代码。
5. 关于性能优化的一点延伸思考
异步加载不是孤立的技术,它和性能优化的其他环节是咬合在一起的。比如你做了代码分割、做了资源预加载,但如果接口请求没有优化、数据缓存策略不合理,首屏依然可能慢。再比如,异步加载减少了主线程负担,但如果你的代码本身在运行时存在大量低效的 DOM 操作,INP 照样不会好看。
我的一个习惯是:每次优化完,重新跑一遍基线测量,对比数据和目标差距。如果达到了目标,不要急着庆祝,再看一眼有没有引入新的问题——比如布局偏移变大、请求数变多、内存占用升高。性能优化是个平衡游戏,每个指标都有取舍,关键是在具体的业务场景里找到那个"刚好合适"的点。
另外想提醒一句:性能优化很难一次到位,因为业务在变、依赖在变、用户设备也在变。异步加载这套方法论不需要经常换,但具体到每个版本的依赖升级、每个新功能的上线,都应该重新审视资源的加载策略是否还合理。把性能测量和监控做成持续流程,比一次性的大改造更有价值。