前端圈这两年都在聊性能优化,什么首屏加载、白屏时间、秒开率,归根结底绕不开一个核心问题:用户拿到页面到真正能操作,到底等了多久。而异步加载,恰恰是我在项目里体会到“性价比最高、收益最明显”的一招。
我最早接触这个概念是在做移动端 H5 的时候,那时候一个页面动不动几百 KB 打包体积,还没上 HTTP2,并发连接就 6 个,图片、脚本、样式全堆在首屏,加载体验只能用“随缘”形容。后来老老实实把异步加载吃透,从脚本的 defer/async 到路由懒加载,再到图片的懒加载和组件动态导入,整套组合拳打下来,首屏时间从 5 秒压到了 2 秒出头,体感差别非常大。
这篇文章想把我在实际项目里总结的异步加载与性能优化经验完整拆一遍,包括前端和小程序、桌面端等场景下能直接落地的方案、背后的原理、踩过的坑,以及排查工具怎么用。适合正在接触性能优化、想提升页面加载速度、或者准备在面试中聊清楚这套体系的同学参考。
1. 异步加载的底层逻辑与设计思路
1.1 为什么“异步”能带来性能收益
先说一个生活类比。你去餐厅吃饭,如果厨师先把所有食材准备齐了再开始做你的菜,那高峰期你大概率饿到前胸贴后背。如果厨师一边备料一边炒菜,哪里先熟哪里先上,你的等待时间就短得多。页面加载也是同样的道理。
浏览器加载页面时,HTML 解析过程是自上而下的。传统同步脚本有一个非常粗暴的特性:脚本被发现时,直到执行完,HTML 解析都会被阻塞。如果这个脚本很大、很慢,或者依赖了慢接口,整个页面就会卡在那个位置,用户看到的只有白屏。异步加载的核心就是把这个“阻塞”拆掉,让关键资源先到,非关键资源在后面慢慢跟上。
这里面最常见的就是<script>标签上的defer和async属性。它们让脚本在下载时不再阻塞 HTML 解析,区别在于执行时机:
defer:HTML 解析完成后按顺序执行,适合依赖 DOM 结构、有先后依赖的脚本。async:下载完成后立即执行,顺序不保证,适合独立、跟页面结构没有关系的小工具脚本。
我把这两者理解成“排队进考场”和“先到先做题”:defer是排队进考场,所有人到齐按顺序坐好再统一开考;async是先到先做题,谁先到达谁就先开始,不用等别人。
1.2 请求层面异步设计的取舍
除了脚本加载,请求层面的异步设计也特别值得提。早期页面经常为一个接口数据开启同步 XHR 请求,在主线程上干等,这几乎是把白屏时间当玩具踩。后来 Fetch API 和 axios 这类库默认全异步,配合 Promise、async/await 处理结果,请求一发出就继续做别的事情,数据到了再响应式渲染,这才是现代应用该有的状态。
我做过一个统计报表页面,原本 13 个接口是串行请求的,全部跑完大概 4.5 秒。后来把所有互不依赖的接口改为并列发起,用Promise.all聚合结果,整体耗时直接降到 1.8 秒。这里有一个很重要的取舍:多个接口并行发,并不是越多越好。移动端设备的网络连接数、服务端的压力都要考虑,如果一口气发 20 个请求,小水管带宽会被挤爆。实际项目的做法通常是按“首屏依赖”和“非首屏依赖”划分:
- 首屏必须用的接口:并行发起,尽快拿到结果。
- 非首屏接口:等页面渲染完,或者监听用户即将操作到某个区域时再触发。
这样既保证了关键内容的快速展示,又不会因为无脑并行带来体验上的反效果。
1.3 模块化与分包的异步策略
模块化开发是本世纪前端最重要的一次升级,它把代码拆成了很多小文件,但如果不做分包策略,打包工具会把所有模块打成一个巨大的 bundle,异步加载又从哪来呢?所以现代构建工具的异步策略通常结合动态导入(dynamic import)来实现。
Webpack、Vite 或 Rollup 这类工具遇到import()语法时,会自动把对应模块单独分到一个 chunk,需要时再去服务器下载和执行。这就是路由懒加载、组件按需加载的实现基础。一个一万行代码的大型后台系统,如果所有路由的页面全都打成一个包,首屏可能要把所有页面的业务代码全部下载完才渲染,这显然不合理。做成按路由分包后,用户访问哪个页面就下载哪个页面的代码,其他页面留到跳转时再加载,首屏资源体积可以缩小一半甚至更多。
异步加载的核心思路,总结起来就一句话:把“一次性全部交给用户”改成“先给关键的,再给次要的,最后给可不用的”。下面几个章节我会把每种方案在实操中的细节拆开讲。
2. 核心场景拆解:脚本、路由与组件的异步加载方案
2.1 经典脚本加载方案对比与使用边界
脚本加载是历史最久、也是最容易被忽略的异步优化点。很多老项目里,jQuery 时代就习惯把脚本放在</body>前面,以此来规避脚本阻塞 DOM 解析的问题。这个习惯到今天依然有效,但已经不够“优雅”。
我总结了一张对比表,方便小白直接选用:
| 方案 | 加载时机 | 执行时机 | 适用场景 |
|---|---|---|---|
| 放在 body 末尾 | HTML 解析到末尾时开始下载 | 下载完成后立即执行 | 无依赖的普通脚本,兼容性最好 |
defer | HTML 解析过程中后台下载 | 文档解析完毕后按顺序执行 | 需要 DOM 的脚本,需要执行顺序的脚本 |
async | HTML 解析过程中后台下载 | 下载完成后立即执行 | 独立统计脚本、埋点脚本、无依赖的小工具 |
| 动态创建 script | 任意时机动态插入 | 加载完成后执行 | 需要懒加载的第三方脚本、用户交互时才需要的脚本 |
实际操作中,我踩过一个典型坑:某个第三方地图 SDK 的加载顺序不能乱,它要求先加载核心 SDK 再加载插件 SDK。我当时用了async,插件先回来了,直接报错。后来把两个标签都改成defer,才稳定解决。这类有依赖关系的脚本,即使不阻塞 HTML 解析,也必须保证执行顺序,不能用async。
还有一个经验:很多团队统计脚本喜欢用async放在<head>里。这种做法是正确的,因为埋点脚本通常独立无依赖,并且下载执行得越早,越不容易漏掉用户的访问行为。但同时也有一个反直觉的点,就是async 脚本下载一旦完成会暂停 HTML 解析去执行,如果这个第三方脚本体积特别大,执行时间特别长,还是会卡一下主线程。所以如果只是简单埋点,我更倾向于把轻量统计脚本用async,而把重型第三方 SDK 放进动态异步加载的队列里。
2.2 路由懒加载与组件按需加载的落地姿势
现代单页应用(SPA)中,路由懒加载是异步加载最普遍的应用形式。我用 Vue 或 React 的时候,会在路由表定义里直接用动态导入:
// Vue Router 示例 const routes = [ { path: '/home', component: () => import('../views/Home.vue') }, { path: '/user', component: () => import('../views/User.vue') } ]// React Router 示例 import { lazy, Suspense } from 'react' const Home = lazy(() => import('../views/Home')) const User = lazy(() => import('../views/User')) // 在路由组件外包一层 Suspense打包之后,每个路由就是一个独立的 chunk,首屏只下载当前路由的代码。这种做法的收益在一个大体量项目中是最直观的:我不需要改任何业务代码,只需要把所有路由的静态引入变成动态导入,首屏资源体积就会立刻下降。
组件按需加载要稍微多考虑一些。不是所有组件都适合懒加载,我用的判断标准是:这个组件是不是首屏一定看得到?如果首屏就要渲染,懒加载反而会造成闪烁和延迟,因为组件代码还在下载中。适合懒加载的组件通常是:
- 弹窗类组件:打开前才需要真正渲染。
- 折叠面板里的复杂图表:用户没展开时根本不需要。
- 管理后台的编辑表单:不点击“编辑”按钮就不需要表单逻辑。
- 路由切换后才出现的次级功能区块。
实际开发中,组件懒加载还需要搭配 UI 界面上的占位处理。如果一个表格下面有一块图表区域需要动态加载,我会先在图表位置放一个轻量的骨架屏或者简单的 loading 占位,等异步组件加载完成后再替换。这里需要注意的是,骨架屏本身不要做得太重,否则你又把首屏压力增回了原来的水平。
2.3 资源预加载与按需加载的配合
异步加载的另一个重要配套是preload和prefetch。它们看起来像是异步的另一种形态,但其实思路不同:
preload:提前下载当前页面马上就会用到的关键资源,比如首屏背景大图、关键字体、核心 JS 文件。prefetch:提前下载用户“接下来很可能访问”的页面资源,利用浏览器空闲时间加载。
我做过这样一个优化:一个官网首页背景图有 2MB,用懒加载显然不合适,因为首屏就要展示,不做预加载会有一段白图时间。后来在首页<head>中加了一行<link rel="preload" as="image" href="/images/hero-bg.webp">,浏览器会更早开始下载这张图片,再配合图片压缩转 WebP,白图问题顺利解决。
prefetch的典型场景是后台系统。用户登录之后,访问的第一个页面大概率是“工作台/仪表盘”,而“工作台”往往会在侧边栏列出很多下级页面。我可以在工作台加载完后悄悄prefetch几个最常点击的下级路由的 chunk,等用户真的点过去时,页面几乎是秒开。这比完全懒加载的体验更好。
不过要注意,prefetch 不能滥用。如果页面有几十个路由,全部 prefetch 等于把懒加载省下来的流量全花回去。我一般只挑访问频率最高的 2 到 4 个页面做 prefetch,其他的保持懒加载。
2.4 图片与媒体资源的异步加载细节
图片懒加载可以说是异步加载里最常见、最直观的优化手段。现代浏览器的loading="lazy"属性已经足够好用:
<img src="product.jpg" loading="lazy" alt="商品图" />这个原生属性不需要额外引入 JavaScript 库,浏览器会自动判断图片是否进入可视区域,再决定是否发送网络请求。但这里有个细节很多新手不知道:原生loading="lazy"对“首屏图片”没有意义,而且它默认的阈值是 1250px 到 2500px 之间,也就是距离当前可视区域还有接近一两屏的高度时就可能开始加载。所以真正首屏的 banner 图、关键商品图,不应该懒加载,反而应该用 preload 去保证尽早加载。
对于更细粒度的图片优化,我可以选择 IntersectionObserver 手动控制:
const images = document.querySelectorAll('img[data-src]') const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target img.src = img.dataset.src observer.unobserve(img) } }) }) images.forEach((img) => observer.observe(img))IntersectionObserver 的问题在于兼容性和坑。移动端的低版本 WebView 不一定支持,需要做降级方案;另外图片加载失败时要有降级处理,不能让它静静碎掉。还有一点,懒加载必须给img元素设置正确的宽高比,否则图片进入可视区域后加载完成,布局会突然跳动,算是一种布局偏移(CLS)问题,影响性能指标。
视频资源的异步加载更需要注意。<video>默认不会预加载所有资源,但如果没有正确设置preload属性,浏览器依然可能加载一到几秒的媒体数据。我的做法是:默认为视频设置preload="metadata",只加载元信息,用户点击播放后,再动态设置preload="auto"并调用video.play()。这样首屏不会因为大视频消耗流量,同时保留了播放的流畅度。
3. 构造完整异步加载性能优化链路:从监控到落地
3.1 性能现状的诊断与关键指标
在动手优化之前,一定要先量化现状。不使用工具全靠猜性能问题,那就是碰运气。我常用的工具组合是:浏览器 DevTools 的 Performance 面板、Lighthouse、以及本地抓包或线上监控平台。
最关注的指标有三个:
- LCP(Largest Contentful Paint,最大内容绘制):页面视口内最大的可见元素被渲染出来的时间。它直接反映了用户感觉上“页面有没有出来”。
- FCP(First Contentful Paint,首次内容绘制):页面上第一次绘制出内容的时间。
- TTI(Time to Interactive,可交互时间):页面用户能真正完成点击等操作的时间。
这几个指标在 DevTools 里都能直接看到。实操建议是:打开隐身模式,把网络条件调成 Fast 3G,CPU 降速 4 倍,再跑一轮页面,模拟低端手机和弱网下的真实体验。这时候异步加载带来的差异会非常明显。
3.2 四步落地的异步加载改造流程
我通常用下面四个步骤完成一个页面的异步加载改造,可以直接照抄:
第一步:资源大盘点打开 DevTools 的 Network 面板,禁用浏览器缓存,刷新页面,把所有请求记录截图。按资源的类型、体积、加载优先级排序,标出前三大的脚本、样式、图片和接口。这个盘点结果决定了优化重点。
第二步:确定首屏关键资源关键资源指的是页面第一次渲染时,缺少它就无法正确显示的资源。视觉上又把它们分成两类:首屏视口内能看到的内容,和首屏视口外滚动才能看到的内容。首屏以外的内容,能异步就异步,能懒加载就懒加载。
第三步:技术改造具体包括:
- 把非关键脚本全部加上
defer或async。 - 把路由和组件改成动态导入。
- 对图片、视频、iframe 设置懒加载。
- 对第三方 SDK 做动态插入和延迟执行。
- 把体积过大的依赖放到独立分包,并在需要时才加载。
第四步:效果回归与监控改造完成后,重新在相同网络环境下跑一轮 Lighthouse,对比 LCP 和 TTI 数据。注意必须保持测试环境一致,否则数据没有对比意义。之后在线上接入性能监控,长期跟踪几个核心指标,防止后续迭代把性能回退。
3.3 性能优化中必须避开的异步陷阱
异步加载不是设置好就一劳永逸,有几个陷阱特别容易踩:
第一个陷阱是异步组件的水合(Hydration)问题。在 SSR 或服务端渲染项目中,如果客户端异步加载组件时机不对,可能出现“首屏内容已显示但交互不可用”的尴尬。这类项目的异步加载要和水合策略配合,否则用户看到一个能看的页面,点按钮却没反应,体验远比白屏还糟。
第二个陷阱是异步脚本报错遮挡。有些团队在入口处配置了window.onerror,把错误打印到全屏遮罩上。如果某个异步组件加载失败,就可能导致用户看到一张错误弹层。我的做法是,全局监听error事件时,对script error这种跨域错误做区分,不能一刀切把加载失败的错误直接暴露给用户。
第三个陷阱是依赖循环。异步加载会导致模块之间的加载顺序变得不固定,如果代码里存在循环依赖,在同步环境下往往不会暴露,但在异步加载时可能抛 “Cannot access before initialization” 这类错误。排查方法是在构建工具中开启循环依赖检测插件,从根上杜绝。
第四个陷阱是重复加载。动态导入的组件如果被多个地方引用,又没做好缓存,可能被加载出多份实例,内存白白翻倍。小项目问题不大,大型项目要在模块设计阶段避免多个入口重复导入同一个重型组件。
3.4 优化前后对比与效果数据参考
这里分享一个我实际做过的移动端 H5 项目数据,方便你有一个量级上的概念。那是一个商品详情页改造项目:
| 项目 | 优化前 | 优化后 |
|---|---|---|
| 首屏 JS 体积 | 486 KB | 86 KB |
| 首屏请求数 | 34 个 | 17 个 |
| LCP(Fast 3G 模拟) | 5.6 s | 2.3 s |
| TTI | 7.2 s | 3.1 s |
| 图片流量 | 3.1 MB | 1.2 MB |
这个结果的核心变化来自:路由懒加载把大部分详情页脚本拆出去了;主图、价格区文案这些首屏关键资源用 preload;评价列表、推荐商品等滚动区域全部改为懒加载;视频改成了元数据预加载模式。整个改造大概花了一个星期,业务代码改动量很小,没有动后端的任何逻辑。
你也可以在自己项目里复现这条链路:先跑一遍诊断,记录数据;然后按上面的方法改造异步加载;最后再跑一遍诊断,把数据体现在团队文档里。性能优化的说服力,永远建立在可量化数据上,而不是感觉上。
4. 常见问题排查与避坑指南
4.1 首屏一直白屏,异步加载到底生效没生效
出现“改造了异步加载,首屏还是白屏”的情况,优先检查一个点:框架入口文件是否还是同步的巨大 bundle。很多项目的懒加载只处理了业务页面,但把框架核心、UI 组件库、状态管理、路由配置全都塞进了入口 chunk,导致入口 chunk 比之前小不了多少,首屏自然降不下来。
实操排查方法:打开 Network 面板,看第一个 JS 资源体积多大。如果超过 200 KB 裸体积,就要考虑把组件库改成按需引入,把大的第三方库(比如图表库、日期库)从入口挪进异步组件。
另一个常见原因是CSS 全量打入入口样式。JS 懒加载了,但 CSS 还是全量一起打包,页面渲染依然被阻塞。现代构建工具已经内置了 CSS 提取和代码分割,但要检查配置有没有正确开启。我是吃过这个亏的,明明 JS 已经拆出去几百 KB,样式文件还是 300 KB 全量加载,首屏从来就没快过。
4.2 懒加载图片优化后反而出现“图片跳动”
这是最让新手抓狂的问题。图片懒加载的机制是在图片进入可视区域附近才拉取数据,加载完成之前,这张图片的高度可能是 0,于是页面布局在不断变化,下面的内容跟着顶下来,用户操作时页面跳来跳去。
解决方案非常简单粗暴:给每个具体尺寸的图片容器设置占位宽高比。如果你用的是 CSS 的aspect-ratio属性或padding-bottom百分比技巧,布局从一开始就是稳定的,懒加载加载完只需要替换内容,不会引起任何跳动。我在实际项目中,会为img标签设置width和height属性,浏览器会基于这两个属性自动算好占位比例,代码也很简洁:
<img src="placeholder.svg">const AsyncComponent = defineAsyncComponent({ loader: () => import('./HeavyComponent.vue'), loadingComponent: Loading, delay: 200, timeout: 8000 })这里有一个细节值得提及:delay: 200表示等待 200ms 后才显示 loading 组件。如果组件加载很快(比如 100ms),就不会出现闪一下 loading 又消失的情况,体验更自然。timeout则是超时保护,避免异步加载失败后页面一直卡在 loading 状态。
如果是无框架的原生项目,就需要自己在渲染前准备一个占位内容,用 Promise 来控制替换时机。这个做法虽然啰嗦,但可控性最强,适合对项目掌控要求高的情况。
4.4 异步加载和浏览器缓存的联动问题
异步加载依赖浏览器的缓存机制很重。一个 chunk 文件是否命中缓存,直接决定了下一次访问页面时还需要花多少流量。我见过一个团队为了“防止缓存”,给所有 JS 文件名加时间戳,结果每次发布所有用户都要重新下载全部文件,路由懒加载的优势大幅度削弱。
正确的做法是使用基于内容哈希的文件名,比如chat-8f8e1a5.js。只要文件内容变了,哈希就会变,文件名自然变;内容没变,文件名不变,浏览器命中缓存,访问速度飞快。因为异步加载的 chunk 本来就分散,缓存命中率对整体性能的影响会成倍放大。
另外要注意 Service Worker 缓存中与异步加载的配合。注册 Service Worker 时,尽量先让首屏请求优先,不要先把几十个 chunk 全部放进缓存队列,否则首屏会被缓存写入任务拖慢。我会把缓存策略拆成“首屏缓存优先”和“路由缓存 lazily”,对懒加载的页面 chunk 使用 navigate 到后才预缓存。
4.5 针对移动端与低端机的经验技巧
异步加载在移动端的表现和桌面端完全不同,我在调试时踩到几个坑,顺便分享给你:
- 弱网下异步请求的并发策略:4G 弱网环境下,多个小请求并发比一个大请求更致命,因为带宽被切碎。我在部分场景会把多个小接口合并请求,或者增加服务端的批量接口能力,减少请求次数。
- 内存占用:异步加载虽然减少了首屏流量,但懒加载进来的组件如果没做好销毁清理,会累积内存。移动端尤其明显,页面长时间使用后会越来越卡。组件卸载时务必清理定时器、事件监听器、IntersectionObserver 实例。
- 低端机的 CPU 瓶颈:低端机的解析执行 JS 也慢。异步加载把代码分段后,减少了一段长任务,但如果每个异步组件本身执行也重,交互仍然卡。可以在浏览器性能面板查看长任务(Long Task),针对耗时超过 50ms 的任务做拆分或 Web Worker 处理。
这些经验不一定适用于每个项目,建议你在自己的设备上进行性能测试,特别是用中低端 Android 手机实测,不要只看电脑上的 DevTools 模拟结果。性能优化的最终裁判,永远是真实用户手上的体验。
5. 工具链与工程化层面的异步加载配合
5.1 构建工具代码分割:手动分包与控制粒度
在做异步加载工程化时,一个很容易被忽略的问题就是“拆包粒度”。拆得太细,会产生大量小体积 chunk 文件,HTTP 请求增多会拖慢速度;拆得太粗,又失去了异步加载的意义。我用过一个中间策略:为每个路由拆一个 chunk,再把体积超过 50 KB 的第三方依赖(比如图表库、地图库)单独拆出来,做长期缓存。
在 Vite 中,手动分包通常靠build.rollupOptions.output.manualChunks;在 Webpack 则是optimization.splitChunks。我以 Vite 为例,简单展示一下配置:
// vite.config.js export default { build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { if (id.includes('echarts')) return 'vendor-echarts' if (id.includes('lodash')) return 'vendor-lodash' return 'vendor' } } } } } }这样打包后,echarts、lodash 这类大型第三方库会形成独立 chunk,配合 Content Hash 后可以长期缓存,用户再次访问时根本不用下载这些大型文件。
不过手动分包要谨慎,分得太碎会导致页面并行下载多个小文件,在高并发弱网环境反而更慢。我的建议是:先让构建工具自动按异步导入分包,观察实际加载数据,再针对体积前几名的依赖手动分包,不要一上来就把所有依赖全部拆开。
5.2 监控平台上报与性能回归护栏
异步加载的优化效果只有被持续监控数据验证,才算真正完成。我在团队里用一个简单上报模型:把 LCP、FCP、TTI、以及页面可交互前加载的总字节数上报到监控平台,按客户端、网络类型、页面路由维度聚合。每次发版后对比指标,如果 LCP 中位数或 p90 有明显回退,就要立刻排查。
具体上报伪代码:
// 简单示例:PerformanceObserver 捕获 LCP const observer = new PerformanceObserver((list) => { const entries = list.getEntries() const lastEntry = entries[entries.length - 1] sendBeacon('/api/perf', { lcp: lastEntry.startTime, path: location.pathname, ua: navigator.userAgent }) }) observer.observe({ type: 'largest-contentful-paint', buffered: true })这样采集到的数据可以直接在趋势图上看到优化效果。如果没有自建监控,也可以用开源工具采集上传,大家按团队基础设施来选择。
5.3 团队协作中异步加载的约束
异步加载方案落到团队协作,最容易出现的问题是“每个人都在局部优化,全局却退步了”。比如 A 同学把弹窗组件改成了动态导入,B 同学却在另一个模块里重新静态安装了这个弹窗,重复加载问题就出现了。
我的做法是在代码评审里加一条硬性规则:凡超过 50 KB 的非首屏组件,必须提供动态导入的实现方案,并标注它所属的模块归属。同时用构建产物的可视化分析工具(比如rollup-plugin-visualizer、webpack-bundle-analyzer)定期审查整体包组成,从源头控制体积膨胀。
另外,在 README 或者团队文档里写一页“异步加载约定”,把哪些资源必须懒加载、哪些资源必须预加载、哪类组件禁止放进入口写清楚。团队工程化的核心,是让大家有一个共同的参照系。
6. 最后再聊聊我的一些实操体会
异步加载与性能优化,说到底不是一套固定的招式,而是一种“权衡的艺术”。性能指标之间往往互相牵制:懒加载减小了首屏流量,但可能增加用户操作后的等待;preload 提前抢占了带宽,却可能让首屏最关键的接口晚返回;拆包更细能提升缓存命中,但可能增加请求次数。真正吃透这套知识,靠的是多在实际项目中做数据对比,把每一条策略的代价和收益都摸清楚。
我个人的习惯是,每做一个性能优化改动,都先记录改前数据,再记录改后数据,最后把结论写成一条团队可复用的 note。慢慢你会发现,大部分项目的性能问题不需要多高深的技术,异步加载 + 资源压缩 + 合理缓存,已经能解决七八成问题。剩下的二成,才轮到 Web Worker、离屏渲染、内存调优这些进阶方向。
如果你现在正好在优化一个访问慢、白屏久或者打包体积很大的项目,不妨从“首屏资源清单”开始,把所有非首屏的东西全部列出来,然后一项一项给它们安排上异步加载方案。这个开头很简单,但收益往往比想象的更可观。