1. 异步加载到底在解决什么问题
1.1 从一次页面卡顿说起
我第一次真正意识到异步加载的价值,是在做一个数据看板项目的时候。页面上要同时渲染十几个图表组件,每个组件都需要请求接口拿数据。最初的写法很直接:页面初始化时,一个循环把所有接口请求发出去,然后等所有数据回来再统一渲染。本地开发环境跑得好好的,一上测试环境就出问题了——首屏白屏时间接近四秒,用户点任何按钮都没反应,控制台里一堆请求排队等着返回。
这个场景其实很典型。同步加载的逻辑是“一件事做完再做下一件”,放在浏览器里就意味着主线程被占满,用户交互、渲染、脚本执行全部堵在队列里。异步加载的核心思路,是把那些不着急、不影响首屏关键路径的任务往后放,让主线程先处理用户能看到、能摸到的东西。
异步加载不是某个具体的技术,而是一类策略的统称。它的实现手段有很多:动态导入模块、懒加载图片、接口请求并发控制、任务分片、Web Worker 等等。不同手段解决的是不同层面的问题,但目标是一致的——让关键路径更短,让非关键任务不阻塞关键任务。
1.2 性能优化的本质是资源分配
很多人把性能优化理解成“让代码跑得更快”,这个理解只对了一半。更准确的说法是:性能优化是在有限的资源条件下,决定哪些事情优先做、哪些事情可以等、哪些事情可以不做。
浏览器的资源就那么几样:主线程的计算时间、网络带宽、内存、GPU 渲染能力。异步加载做的事情,本质上是对这些资源做时间维度上的重新分配。把一个大任务拆成多个小任务,分散到不同的时间片里执行,用户感知到的就是“页面一直有响应”,而不是“卡死几秒然后突然全部出现”。
这里有一个容易被忽略的点:异步不等于并行。JavaScript 是单线程的,所谓的异步只是把任务放到事件队列里,等主线程空闲时再执行。真正能并行的只有 Web Worker、Service Worker 这类独立线程环境。所以做异步加载设计时,要清楚哪些任务适合放到 Worker 里真正并行,哪些任务只是延后执行。
1.3 什么场景下必须用异步加载
不是所有项目都需要复杂的异步加载方案。我总结了几条判断标准,满足其中任意两条以上,就值得认真设计异步策略:
- 首屏需要渲染的组件超过十个,且每个组件都有独立的数据依赖
- 页面中存在大量图片、视频等媒体资源,且不是所有资源都在首屏可见
- 有计算密集型的任务,比如大数据量的排序、过滤、格式化
- 用户交互频繁,任何超过 100ms 的阻塞都会被明显感知
- 项目需要在中低端移动设备上运行,CPU 和内存都受限
反过来说,如果一个页面只有三五个静态组件,数据量也不大,强行上异步加载反而会增加代码复杂度,得不偿失。我见过不少项目为了“性能优化”而优化,把简单的同步逻辑拆得七零八落,最后维护成本飙升,性能提升却微乎其微。
2. 异步加载的核心实现手段拆解
2.1 动态导入与代码分割
现代前端构建工具都支持动态导入语法,这是实现按需加载最直接的方式。以 JavaScript 为例,静态导入会在打包时被合并到主包里,而动态导入会被拆分成独立的 chunk,只有在真正执行到那一行代码时才会去加载对应的文件。
// 静态导入:会被打包进主包 import { heavyFunction } from './heavyModule'; // 动态导入:会被拆分成独立 chunk const module = await import('./heavyModule'); module.heavyFunction();这两种写法的差异,在打包产物上体现得非常明显。静态导入的模块,无论页面是否用到,用户都要下载完整的代码。动态导入的模块,只有触发加载条件时才会发起网络请求。
我在一个后台管理系统里做过对比测试:把富文本编辑器、图表库、地图组件这三个体积较大的依赖改成动态导入后,首屏 JS 体积从 1.8MB 降到了 620KB,首屏可交互时间从 3.2 秒降到了 1.4 秒。这个提升幅度在中低端设备上会更明显,因为解析和执行大体积 JS 的时间会成倍增加。
不过动态导入也有代价。每次动态导入都会产生一次网络请求,如果拆分粒度过细,请求数量过多,反而会增加总加载时间。我的经验是:单个 chunk 的体积控制在 50KB 到 200KB 之间比较合适,太小了请求开销占比高,太大了加载时间又太长。
2.2 图片与媒体资源的懒加载
图片懒加载是异步加载里最成熟、收益最直观的一类。传统做法是监听 scroll 事件,计算图片是否进入视口,然后手动替换 src。现在浏览器原生支持了 loading 属性,一行代码就能搞定。
<img src="placeholder.jpg">const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const el = entry.target; el.style.backgroundImage = `url(${el.dataset.bg})`; observer.unobserve(el); } }); }, { rootMargin: '200px' }); document.querySelectorAll('[data-bg]').forEach(el => observer.observe(el));这里 rootMargin 设置为 200px,意思是提前 200px 就开始加载,给网络请求留出缓冲时间。这个值需要根据实际网络状况调整,网络差的环境可以设大一些,网络好的环境可以设小一些。
2.3 接口请求的并发控制与优先级调度
接口请求的异步化,重点不在于“异步”本身,而在于并发数量和优先级的控制。浏览器对同域名下的并发请求数量是有限制的,Chrome 大概是 6 个。如果一次性发出几十个请求,后面的请求会排队等待,反而拖慢关键请求的响应速度。
我的做法是把请求分成三个优先级:
| 优先级 | 请求类型 | 并发策略 |
|---|---|---|
| 高 | 首屏关键数据、用户主动触发的操作 | 立即发送,不限制 |
| 中 | 首屏非关键数据、预加载数据 | 最多 3 个并发 |
| 低 | 埋点上报、日志、非可见区域的预取 | 最多 1 个并发,且延迟发送 |
实现上可以用一个简单的请求队列来管理。高优先级请求直接走 fetch,中低优先级请求进入队列,由调度器控制发送时机。
class RequestScheduler { constructor(maxConcurrent = 3) { this.maxConcurrent = maxConcurrent; this.running = 0; this.queue = []; } add(requestFn, priority = 'medium') { return new Promise((resolve, reject) => { this.queue.push({ requestFn, priority, resolve, reject }); this.queue.sort((a, b) => { const order = { high: 0, medium: 1, low: 2 }; return order[a.priority] - order[b.priority]; }); this.next(); }); } next() { if (this.running >= this.maxConcurrent || this.queue.length === 0) return; const { requestFn, resolve, reject } = this.queue.shift(); this.running++; requestFn() .then(resolve) .catch(reject) .finally(() => { this.running--; this.next(); }); } }这个调度器虽然简单,但已经能解决大部分场景下的请求拥堵问题。实际项目中还可以加入超时重试、请求取消等能力,但核心逻辑就是上面这些。
2.4 任务分片与时间切片
有些任务本身无法拆分到不同线程,比如大量的 DOM 操作、复杂的数据计算。这类任务如果一次性执行,很容易造成主线程长时间阻塞。解决办法是把大任务拆成多个小任务,利用 requestIdleCallback 或 setTimeout 分散到不同的时间片里执行。
function processInChunks(items, processFn, chunkSize = 50) { let index = 0; function processChunk(deadline) { while (index < items.length && (deadline.timeRemaining() > 0 || deadline.didTimeout)) { processFn(items[index]); index++; } if (index < items.length) { requestIdleCallback(processChunk); } } requestIdleCallback(processChunk); }requestIdleCallback 的回调会收到一个 deadline 对象,timeRemaining() 返回当前空闲时间片还剩多少毫秒。我们在这个时间片内尽可能多地处理任务,时间片用完后让出主线程,等下一个空闲时间片继续。
需要注意的是,requestIdleCallback 的兼容性虽然已经不错,但在一些老版本浏览器上还是需要降级到 setTimeout。另外,空闲时间片的长度是不确定的,如果页面一直很忙,空闲回调可能迟迟不执行。所以对于有时效性要求的任务,还是要用 setTimeout 兜底。
3. 性能优化的完整实操流程
3.1 先测量再优化:性能数据的采集方法
我见过太多人一上来就开始改代码,改完之后凭感觉说“好像快了一点”。这种做法非常不可取,因为没有数据支撑的优化就是盲人摸象。
性能数据的采集分两个层面:实验室数据和真实用户数据。实验室数据用 Lighthouse、Performance 面板就能拿到,优点是环境可控、指标全面,缺点是只能反映特定条件下的表现。真实用户数据需要自己在代码里埋点,采集关键时间指标,优点是能反映真实分布,缺点是数据噪声大、需要一定的样本量。
我通常采集这几个核心指标:
- FP(First Paint):首次绘制时间,反映页面开始有内容的时间
- FCP(First Contentful Paint):首次内容绘制时间,反映用户看到第一个有意义内容的时间
- TTI(Time to Interactive):可交互时间,反映页面真正可用的时间
- TBT(Total Blocking Time):总阻塞时间,反映主线程被阻塞的累计时长
- LCP(Largest Contentful Paint):最大内容绘制时间,反映主要内容的加载速度
采集方式用 PerformanceObserver 监听对应的 entry 类型,然后在合适的时机上报。
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.entryType === 'largest-contentful-paint') { reportMetric('LCP', entry.startTime); } } }); observer.observe({ entryTypes: ['largest-contentful-paint'] });采集到数据之后,不要只看平均值。平均值会被极端值拉偏,真正有价值的是 P50、P75、P95 这些分位数。P75 意味着 75% 的用户体验好于这个值,通常作为优化目标比较合理。
3.2 异步加载方案的落地步骤
假设我们现在要优化一个首屏加载较慢的页面,完整的落地流程大概是这样的:
第一步:分析当前加载瀑布图
打开 Performance 面板,录制一次完整的页面加载过程。重点看几个东西:主线程有没有长时间的任务、网络请求有没有串行等待、资源加载的顺序是否合理。
第二步:识别关键路径和非关键路径
关键路径上的资源是首屏渲染必须的,比如首屏的 HTML、CSS、核心 JS、首屏接口数据。非关键路径的资源包括:非首屏组件的代码、非首屏图片、埋点脚本、第三方统计代码等。
第三步:对非关键路径资源做异步化改造
- 非首屏组件用动态导入
- 非首屏图片用懒加载
- 非关键接口用低优先级调度
- 第三方脚本用 async 或 defer 加载
第四步:对关键路径资源做体积压缩
- 代码压缩和 Tree Shaking
- 图片格式转换(WebP、AVIF)
- 接口数据精简,只返回首屏需要的字段
第五步:验证优化效果
重新采集性能数据,对比优化前后的核心指标。如果提升不明显,回到第一步重新分析。
这个流程看起来简单,但每一步都有很多细节。比如第三步里,哪些组件算“非首屏”,这个判断就需要结合具体业务来定。我的经验是:首屏渲染完成后,用户不滚动页面就看不到的内容,都可以算非首屏。
3.3 一个真实项目的优化记录
去年我接手了一个移动端商城项目的性能优化。项目背景是:首屏加载时间在 4G 网络下平均 5.8 秒,用户跳出率很高。
我先用 Lighthouse 跑了一遍,发现几个明显问题:主包体积 2.3MB、首屏接口串行请求了 6 个、图片全部是未压缩的 PNG。
优化措施和效果如下:
| 优化项 | 具体做法 | 优化前 | 优化后 |
|---|---|---|---|
| 代码分割 | 路由级动态导入 + 组件级懒加载 | 主包 2.3MB | 主包 480KB |
| 接口合并 | 6 个串行接口合并为 1 个聚合接口 | 首屏数据等待 2.1s | 首屏数据等待 0.6s |
| 图片优化 | PNG 转 WebP + 懒加载 | 图片总大小 3.2MB | 首屏图片 180KB |
| 第三方脚本 | 统计脚本延迟到 TTI 后加载 | 阻塞 400ms | 无阻塞 |
最终首屏加载时间从 5.8 秒降到了 1.9 秒,跳出率下降了 23%。这个案例里最关键的优化其实是接口合并,因为串行请求的等待时间占了首屏时间的一大半。代码分割和图片优化也很重要,但它们的收益更多体现在后续页面的加载速度上。
3.4 移动端性能优化的特殊考量
移动端和桌面端的性能优化策略有很大差异,不能照搬。移动设备的 CPU 性能通常是桌面端的几分之一,内存也更紧张,网络环境波动更大。
在移动端做异步加载,有几个点需要特别注意:
第一,内存压力更大。动态导入的模块加载后会占用内存,如果加载了太多模块又不释放,很容易触发内存警告甚至页面崩溃。我的做法是:对于使用频率低的模块,加载完成后在使用完毕时手动清理引用,让垃圾回收器能回收内存。
第二,网络切换频繁。移动设备可能在 WiFi 和蜂窝网络之间切换,网络质量变化很大。异步加载的策略应该能感知网络状况,在弱网环境下减少并发请求数量,优先保证关键请求的成功率。
第三,电量消耗。频繁的网络请求和计算任务会加速电量消耗。对于非关键任务,可以延迟到设备充电时或者 WiFi 环境下再执行。navigator.connection API 可以获取网络类型和是否省电模式。
const connection = navigator.connection; if (connection) { const isSlowNetwork = ['slow-2g', '2g', '3g'].includes(connection.effectiveType); const isSaveData = connection.saveData; if (isSlowNetwork || isSaveData) { // 降低并发数,关闭预加载 scheduler.maxConcurrent = 1; disablePreload(); } }这段代码在页面初始化时执行一次,根据网络状况动态调整加载策略。实测下来,在弱网环境下能明显减少请求超时和失败的情况。
4. 常见问题与排查技巧实录
4.1 异步加载后页面闪烁怎么办
页面闪烁是异步加载最常见的副作用。典型表现是:首屏先渲染了一个占位内容,异步数据回来后替换成真实内容,如果两者尺寸差异大,就会产生明显的跳动。
解决这个问题的核心思路是:让占位内容和真实内容的尺寸尽可能一致。具体做法有几种:
- 骨架屏的尺寸按照真实内容的布局来设计,不要随便画几个灰块了事
- 图片懒加载时,给 img 标签设置固定的 width 和 height,或者用 aspect-ratio 属性锁定宽高比
- 异步组件加载时,用和真实组件同尺寸的 loading 占位
.image-container { aspect-ratio: 16 / 9; background: #f0f0f0; } .image-container img { width: 100%; height: 100%; object-fit: cover; }aspect-ratio 这个属性现在兼容性已经很好了,用它来锁定图片容器的宽高比,图片加载前后容器尺寸不变,就不会有跳动。
4.2 动态导入的模块加载失败怎么处理
动态导入本质上是网络请求,就一定会遇到加载失败的情况。失败的原因可能是网络波动、CDN 节点异常、文件被误删等等。如果不做处理,用户看到的就是一个报错或者白屏。
我的处理策略是:重试 + 降级 + 提示。
async function loadModuleWithRetry(importFn, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await importFn(); } catch (error) { if (i === maxRetries - 1) { // 最后一次失败,走降级逻辑 showFallbackUI(); throw error; } // 等待一段时间后重试,间隔递增 await new Promise(r => setTimeout(r, 1000 * (i + 1))); } } }重试间隔用递增的方式,避免在服务端有问题时频繁重试加重负担。降级逻辑根据具体场景来定:如果是非关键组件,可以显示一个简单的提示;如果是关键组件,可能需要引导用户刷新页面。
4.3 异步任务的执行顺序不符合预期
异步任务的执行顺序问题,根源在于对事件循环的理解不够深入。我整理了一个常见问题速查表:
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 异步任务在同步代码之前执行 | 把微任务当成了宏任务 | 区分 Promise 和 setTimeout 的执行时机 |
| 多个异步任务的结果顺序错乱 | 没有做顺序控制 | 用 Promise.all 或 async/await 串行 |
| 异步任务在页面卸载后还在执行 | 没有做取消处理 | 用 AbortController 取消请求 |
| 异步任务重复执行 | 没有做防抖或状态锁 | 加执行状态标记或防抖处理 |
其中最容易踩坑的是微任务和宏任务的执行顺序。简单来说:同步代码执行完后,先清空微任务队列(Promise.then、queueMicrotask),然后执行一个宏任务(setTimeout、setInterval),然后再清空微任务队列,如此循环。
console.log('1'); setTimeout(() => console.log('2'), 0); Promise.resolve().then(() => console.log('3')); console.log('4'); // 输出顺序:1 4 3 2这个例子很基础,但实际项目中很多执行顺序的 bug 都源于对这个机制理解不透彻。
4.4 性能优化做了但指标没提升
这种情况我遇到过好几次,排查下来通常是这几个原因:
第一,优化了非瓶颈环节。比如花了很大力气压缩图片,但真正的瓶颈是接口响应慢。这时候需要重新做性能分析,找到真正的瓶颈再动手。
第二,优化被其他因素抵消了。比如减少了 JS 体积,但新增了一个同步的第三方脚本,整体反而更慢了。每次优化后都要重新测量,确认净收益是正的。
第三,测量环境不一致。优化前后用的网络环境、设备、数据量不同,对比结果没有意义。测量时要控制变量,尽量在相同条件下对比。
第四,指标本身有局限性。比如 TTI 这个指标,在某些场景下并不能准确反映用户感知。如果优化后 TTI 没变但用户反馈变好了,可能是其他指标改善了。
我的建议是:不要只盯着一个指标看,要结合多个指标和用户反馈综合判断。性能优化的最终目标是用户体验,不是指标数字。
4.5 几个容易被忽略的实操细节
最后分享几个我在实际项目中踩过的坑和总结的技巧:
关于预加载。预加载能提升后续页面的加载速度,但不要滥用。我见过一个项目在首屏就预加载了所有路由的代码,结果首屏加载了 5MB 的 JS,得不偿失。预加载应该只针对用户大概率会访问的下一页,而且要在首屏关键资源加载完成之后再触发。
关于缓存策略。异步加载的资源一定要配置合理的缓存策略。带 hash 的静态资源可以设置长期缓存,接口数据要根据业务特点设置合适的缓存时间。没有缓存策略的异步加载,每次都要重新请求,性能提升有限。
关于错误监控。异步加载的错误比同步代码更难排查,因为错误可能发生在任何时间、任何模块。建议在动态导入和异步请求的地方都加上错误捕获和上报,方便定位问题。
window.addEventListener('unhandledrejection', (event) => { reportError({ type: 'unhandledRejection', reason: event.reason, time: Date.now() }); });这个全局监听能捕获到没有被 catch 的 Promise 错误,对于排查异步加载问题很有帮助。
关于代码可维护性。异步加载会让代码的执行流程变得不那么直观,所以在写异步代码时要特别注意可读性。我的习惯是:每个异步操作都加上清晰的注释,说明为什么要异步、什么时候触发、失败了怎么处理。这样后续维护的人能快速理解意图,而不是对着一堆 await 猜来猜去。
异步加载和性能优化是一个需要持续投入的事情,没有一劳永逸的方案。业务在变、用户在变、设备在变,优化策略也要跟着调整。我一般会每个季度做一次性能回顾,看看核心指标有没有退化,有没有新的优化点可以尝试。这个过程本身也是对自己技术理解的一次梳理。