1. 异步加载到底在解决什么问题
1.1 从一次页面卡顿说起
我第一次真正意识到异步加载的价值,是在做一个后台管理系统的时候。那个页面要同时渲染一个包含三千多条数据的表格,还要加载三个图表组件、两个地图组件,外加一堆统计卡片。刚开始我没想太多,所有资源一股脑全塞在首屏加载里,结果页面白屏时间接近八秒,用户点进来以为系统挂了。
后来我把那些非首屏必需的图表、地图、统计模块全部改成异步加载,首屏时间直接降到一点二秒。这个对比让我彻底明白了一件事:异步加载不是锦上添花的技术,而是决定用户体验生死的基础能力。
所谓异步加载,说白了就是“不着急用的东西先别加载,等真正需要的时候再去拿”。这跟搬家是一个道理,你不会把冬天的大棉袄在夏天就全挂到衣柜最顺手的位置,而是先放常用的,厚衣服收起来,等天冷了再翻出来。浏览器加载资源也是同样的逻辑,首屏只需要展示用户第一眼能看到的东西,剩下的模块、图片、脚本,都可以往后排。
1.2 性能优化的核心指标到底看什么
很多人做性能优化,上来就说“我要优化加载速度”,但具体优化到什么程度、用什么衡量,心里没数。我一般会盯住几个核心指标:
| 指标 | 含义 | 合理目标 |
|---|---|---|
| 首屏渲染时间 | 用户第一次看到有意义内容的时间 | 小于1.5秒 |
| 可交互时间 | 页面能响应用户操作的时间 | 小于3秒 |
| 总阻塞时长 | 主线程被长任务占用的累计时间 | 小于300毫秒 |
| 资源总体积 | 首屏加载的所有资源大小之和 | 小于1MB |
这几个指标里,首屏渲染时间和可交互时间是最关键的。用户不会关心你后面加载了多少东西,他只关心“我点进来能不能马上看到东西、能不能马上点”。异步加载主要影响的就是这两个指标,因为它把非关键资源的加载时机往后推了,主线程不用在首屏就被一堆脚本堵死。
1.3 异步加载和懒加载的区别与联系
这两个概念经常被混着用,但其实有细微差别。懒加载更偏向“资源层面的延迟”,比如图片滚动到可视区域才加载;异步加载更偏向“执行层面的延迟”,比如脚本不阻塞主线程、模块按需引入。实际项目中两者往往是配合使用的。
我举个具体例子。一个电商详情页,首屏有商品主图、价格、购买按钮,下面有详情描述、用户评价、推荐商品。我的做法是:主图用懒加载配合占位图,详情描述和评价模块用异步加载,推荐商品用懒加载加异步加载组合。这样首屏只加载主图、价格和按钮相关的资源,其他全部延后。
注意:异步加载不是万能的,如果首屏关键资源本身就很重,那再怎么异步也救不了。关键路径上的资源该优化体积还是要优化体积,该压缩还是要压缩。
2. 异步加载的几种主流实现方式
2.1 脚本异步加载的三种姿势
脚本异步加载是最常见的场景,浏览器提供了几种不同的方式,效果差别很大。
第一种是给 script 标签加async属性。这种方式的特点是:脚本下载不阻塞页面解析,但下载完立刻执行,执行时会阻塞。适合那些独立的、不依赖其他脚本、也不被其他脚本依赖的工具类脚本,比如统计代码。
第二种是加defer属性。脚本下载不阻塞解析,但要等页面解析完成后再按顺序执行。适合那些需要操作 DOM、且有依赖关系的脚本。我一般把业务逻辑脚本都放在 defer 里。
第三种是动态创建 script 标签。这种方式最灵活,可以在任意时机插入脚本,但要注意执行顺序不可控的问题。
// 动态加载脚本的通用封装 function loadScript(src, options = {}) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = src; script.async = options.async !== false; script.onload = () => { resolve(script); script.remove(); }; script.onerror = () => { reject(new Error(`脚本加载失败: ${src}`)); script.remove(); }; document.head.appendChild(script); }); } // 使用示例 loadScript('/modules/chart.js') .then(() => initChart()) .catch(err => console.error(err));这段代码我用了很久,核心思路是把动态加载封装成 Promise,方便配合 async/await 使用。注意script.remove()这一步,加载完就把标签移除,避免 DOM 里堆积一堆没用的 script 标签。
2.2 模块级别的按需加载
现代前端项目基本都用模块化开发,模块级别的按需加载是异步加载的主战场。以动态 import 为例,它返回一个 Promise,只有真正调用的时候才会去加载对应的模块文件。
// 路由级别的按需加载 const routes = [ { path: '/dashboard', component: () => import('./views/Dashboard.vue') }, { path: '/settings', component: () => import('./views/Settings.vue') } ]; // 组件级别的按需加载 async function showReport() { const { generateReport } = await import('./utils/report.js'); generateReport(); }路由级别的按需加载是最容易见效的优化手段。一个后台系统可能有几十个页面,如果全部打包在一起,首屏要加载的脚本体积会非常夸张。按路由拆分之后,用户访问哪个页面就加载哪个页面的代码,首屏体积能减少百分之六十以上。
组件级别的按需加载适合那些体积大、使用频率低的组件,比如富文本编辑器、图表库、地图组件。这些组件往往动辄几百KB,如果首屏就加载,纯属浪费。
2.3 图片和媒体资源的异步加载
图片的异步加载主要靠loading="lazy"属性,浏览器原生支持,一行代码搞定。
<img src="photo.jpg" loading="lazy" alt="示例图片" width="800" height="600">注意一定要写 width 和 height,否则图片加载出来的时候会引起布局抖动,用户体验反而更差。如果图片尺寸不固定,可以用 aspect-ratio 或者占位容器来撑住空间。
对于背景图片,原生懒加载不生效,需要用 IntersectionObserver 自己实现。
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' // 提前200px开始加载 }); document.querySelectorAll('[data-bg]').forEach(el => observer.observe(el));rootMargin这个参数很关键,设置成 200px 意味着元素距离可视区域还有 200px 的时候就开始加载,这样用户滚动到的时候图片已经准备好了,不会看到空白。
2.4 数据请求的异步处理
数据请求的异步加载更多是并发控制和优先级调度的问题。我见过很多项目,页面一进来就同时发十几个请求,结果带宽被占满,关键请求反而被拖慢。
我的做法是把请求分成三档:
- 关键请求:首屏必须的数据,立即发起,优先级最高
- 次要请求:首屏下方的内容,首屏渲染完成后再发起
- 预取请求:用户可能接下来会访问的数据,空闲时发起
// 关键请求立即发起 const criticalData = fetch('/api/main'); // 次要请求等首屏渲染后 requestIdleCallback(() => { fetch('/api/secondary'); }); // 预取请求在用户hover时发起 link.addEventListener('mouseenter', () => { fetch('/api/detail'); }, { once: true });requestIdleCallback是浏览器提供的空闲调度 API,它会在主线程空闲的时候执行回调,不会影响关键渲染。不过兼容性一般,生产环境建议加个降级方案。
3. 性能优化的实战策略与参数调优
3.1 资源体积优化的具体手段
异步加载解决的是“什么时候加载”的问题,但“加载多大的东西”同样重要。一个五百KB的脚本,就算异步加载,下载和执行的时间也不短。
体积优化我一般从三个层面入手。第一层是代码压缩,用工具把空格、注释、换行全部去掉,变量名缩短,这一步通常能减少百分之三十到四十的体积。第二层是Tree Shaking,把没用到的代码摇掉,前提是用ES Module规范写代码。第三层是代码分割,把大文件拆成小文件,配合异步加载按需引入。
| 优化手段 | 典型收益 | 适用场景 |
|---|---|---|
| 代码压缩 | 减少30%-40% | 所有JS/CSS文件 |
| Tree Shaking | 减少10%-30% | 使用ES Module的项目 |
| 代码分割 | 首屏减少50%+ | 多页面/多模块项目 |
| 图片压缩 | 减少40%-70% | 所有图片资源 |
| Gzip/Brotli | 减少60%-80% | 文本类资源 |
图片压缩这块我要多说一句。很多人只知道压缩,但不知道格式选择同样重要。同样的图片,WebP格式比JPEG小百分之二十五到三十五,AVIF又比WebP小百分之二十左右。当然要考虑兼容性,我的做法是提供多格式回退。
<picture> <source srcset="image.avif" type="image/avif"> <source srcset="image.webp" type="image/webp"> <img src="image.jpg" loading="lazy" alt="示例"> </picture>3.2 缓存策略的合理配置
缓存是性能优化里性价比最高的手段,配好了能省掉大量重复请求。但缓存配置有个坑:配得太激进,用户看不到更新;配得太保守,缓存形同虚设。
我的经验是按资源类型分策略:
- HTML文件:不缓存或短缓存,保证用户能拿到最新版本
- 带哈希的JS/CSS:长期缓存,一年起步,因为文件名变了就是新文件
- 图片和字体:中期缓存,一个月左右
- API数据:根据业务特点,实时性要求高的不缓存,要求低的可以缓存几分钟
# 带哈希的静态资源,长期缓存 location ~* \.[a-f0-9]{8}\.(js|css)$ { expires 1y; add_header Cache-Control "public, immutable"; } # HTML不缓存 location ~* \.html$ { expires -1; add_header Cache-Control "no-cache, must-revalidate"; }immutable这个指令告诉浏览器“这个文件永远不会变,不用再发请求验证了”,能省掉一次条件请求。前提是文件名里带了内容哈希,内容变了文件名就变了。
3.3 预加载与预连接的时机把握
预加载是异步加载的“反向操作”,它不是延迟加载,而是提前加载。用得好能显著提升后续页面的打开速度,用不好就是浪费带宽。
preload用于当前页面即将用到的关键资源,比如首屏字体、关键CSS。prefetch用于下一个页面可能用到的资源,浏览器空闲时下载。preconnect用于提前建立连接,省掉DNS解析和TCP握手的时间。
<!-- 预加载首屏关键字体 --> <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin> <!-- 预连接API域名 --> <link rel="preconnect" href="https://api.example.com"> <!-- 预取下一个页面的脚本 --> <link rel="prefetch" href="/js/detail-page.js">注意:preload 用多了会适得其反,因为它会抢占带宽,把真正关键资源的加载挤掉。我的原则是 preload 不超过三个资源,prefetch 不超过五个。
3.4 渲染层面的性能优化
资源加载优化完了,渲染层面还有不少文章可做。最常见的问题是布局抖动和长任务阻塞。
布局抖动的根源是元素尺寸在加载过程中发生变化。解决办法是给所有可能变化的元素预留空间,图片写死宽高比,广告位用占位容器,字体加载用font-display: swap配合尺寸调整。
/* 字体加载期间先用系统字体,避免文字不可见 */ @font-face { font-family: 'CustomFont'; src: url('/fonts/custom.woff2') format('woff2'); font-display: swap; size-adjust: 105%; /* 调整系统字体和自定义字体的尺寸差异 */ }长任务阻塞的解决办法是把大任务拆成小任务,用requestAnimationFrame或setTimeout分片执行。
// 大列表分片渲染 function renderListInChunks(items, chunkSize = 50) { let index = 0; function renderChunk() { const chunk = items.slice(index, index + chunkSize); chunk.forEach(item => { // 渲染单个item container.appendChild(createItemElement(item)); }); index += chunkSize; if (index < items.length) { requestAnimationFrame(renderChunk); } } renderChunk(); }这样每帧只渲染五十个元素,主线程不会被长时间占用,用户滚动和点击都能及时响应。
4. 常见问题与排查技巧实录
4.1 异步加载后样式丢失或错乱
这个问题我遇到过好几次,典型表现是异步加载的组件渲染出来没有样式,或者样式和主页面冲突。
原因通常有两个。一是异步加载的组件样式没有被打包进去,因为构建工具默认只处理入口文件引用的样式。解决办法是在组件内部显式引入样式文件,或者配置构建工具把异步组件的样式也提取出来。
二是样式作用域问题,异步组件的样式污染了全局。解决办法是用 CSS Module 或者 scoped 样式,给每个组件的样式加上唯一标识。
// 组件内部显式引入样式 import './ChartComponent.css'; export default function ChartComponent() { // ... }4.2 异步加载导致的执行顺序问题
动态加载的脚本默认是异步执行的,谁先下载完谁先执行,这就会导致依赖问题。比如A脚本依赖B脚本,但A先下载完了,执行的时候就报错。
解决办法有三种。第一种是显式指定async = false,让脚本按插入顺序执行。第二种是用 Promise 链控制执行顺序。第三种是把依赖关系写进模块系统,用 import 自动处理。
// 用Promise链保证顺序 loadScript('/js/lib.js') .then(() => loadScript('/js/plugin.js')) .then(() => loadScript('/js/app.js')) .then(() => initApp());4.3 懒加载图片不显示或闪烁
图片懒加载最常见的问题是滚动到位置了图片还没加载出来,或者加载过程中出现闪烁。
不显示的原因通常是 IntersectionObserver 的阈值设置不对,或者 rootMargin 太小。我的经验是把 rootMargin 设置成200px 0px,提前两百像素开始加载,基本不会出现空白。
闪烁的原因是图片加载完成后突然撑开空间,导致布局跳动。解决办法是给图片容器设置固定的宽高比,或者用低质量占位图先撑住。
/* 用padding-top撑住16:9的宽高比 */ .image-wrapper { position: relative; padding-top: 56.25%; background: #f0f0f0; } .image-wrapper img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }4.4 性能优化效果不明显怎么排查
有时候做了一堆优化,测下来发现提升有限,这时候需要系统排查。
我一般按这个顺序查:先看网络面板,确认资源体积和加载时间,找出最大的几个文件;再看性能面板,确认主线程有没有长任务阻塞;最后看覆盖率工具,确认有多少代码是首屏没用到的。
| 排查方向 | 工具 | 关注指标 |
|---|---|---|
| 资源体积 | Network面板 | 单个文件大小、总大小 |
| 加载时序 | Network瀑布图 | 关键路径、阻塞时间 |
| 主线程 | Performance面板 | 长任务、帧率 |
| 代码利用率 | Coverage工具 | 未使用代码占比 |
| 渲染性能 | Lighthouse | LCP、FID、CLS |
覆盖率工具特别有用,它能告诉你首屏加载的代码里有多少是实际执行了的。我见过一个项目,首屏加载了2MB的JS,但实际用到的只有300KB,剩下全是没用的。这种情况做代码分割和Tree Shaking,效果立竿见影。
4.5 移动端性能优化的特殊考量
移动端的性能优化和桌面端有很大不同,主要受限于网络环境和硬件性能。
网络方面,移动端经常处于弱网环境,延迟高、带宽小。我的做法是进一步压缩资源体积,图片用更激进的压缩率,脚本做更细粒度的分割。同时要处理好加载失败的情况,加超时重试和降级方案。
硬件方面,移动端CPU性能有限,长任务的影响更明显。同样的脚本,桌面端执行50毫秒,移动端可能要200毫秒。所以移动端要更严格地控制单次执行的任务量,分片要更细。
// 根据设备性能动态调整分片大小 const isLowEndDevice = navigator.hardwareConcurrency <= 4; const chunkSize = isLowEndDevice ? 20 : 50;navigator.hardwareConcurrency返回CPU核心数,虽然不绝对准确,但能大致判断设备档次。低端设备用更小的分片,保证每帧都能及时让出主线程。
提示:移动端测试一定要用真机,模拟器的性能数据和真机差距很大。我一般用中低端安卓机做基准测试,能跑顺了,高端机肯定没问题。
5. 一套可复用的异步加载方案
5.1 整体架构设计
把前面说的东西串起来,我整理了一套可复用的异步加载方案,核心思路是“分级加载、按需触发、失败降级”。
分级加载是指把资源分成关键、次要、预取三级,关键资源立即加载,次要资源首屏后加载,预取资源空闲时加载。按需触发是指根据用户行为触发加载,比如滚动、点击、hover。失败降级是指加载失败时有兜底方案,不能白屏。
class AsyncLoader { constructor() { this.cache = new Map(); this.queue = []; this.maxConcurrent = 4; this.running = 0; } // 加载脚本 loadScript(src, priority = 'normal') { if (this.cache.has(src)) { return this.cache.get(src); } const promise = new Promise((resolve, reject) => { const task = () => this._doLoadScript(src, resolve, reject); if (priority === 'high') { task(); } else { this.queue.push({ task, priority }); this._schedule(); } }); this.cache.set(src, promise); return promise; } _doLoadScript(src, resolve, reject) { const script = document.createElement('script'); script.src = src; script.async = true; script.onload = () => { script.remove(); resolve(); }; script.onerror = () => { script.remove(); reject(new Error(src)); }; document.head.appendChild(script); } _schedule() { if (this.running >= this.maxConcurrent || this.queue.length === 0) { return; } // 高优先级先执行 this.queue.sort((a, b) => { const order = { high: 0, normal: 1, low: 2 }; return order[a.priority] - order[b.priority]; }); const { task } = this.queue.shift(); this.running++; task(); this.running--; this._schedule(); } }这个加载器做了几件事:缓存已加载的脚本避免重复请求,用队列控制并发数避免带宽被占满,按优先级排序保证关键资源先加载。
5.2 关键参数的计算与选择
并发数设成多少合适?这个没有标准答案,要根据实际情况调。我的经验值是四到六之间。设太小,加载速度慢;设太大,带宽竞争激烈,反而拖慢关键资源。
超时时间设多少?我一般设十秒。超过十秒还没加载完,大概率是网络有问题,这时候应该触发降级方案,而不是让用户一直等。
重试次数设几次?最多两次。第一次失败可能是网络抖动,重试一次大概率能成功。如果两次都失败,说明问题比较严重,重试再多次也没用,直接降级。
// 带超时和重试的加载 async function loadWithRetry(src, { timeout = 10000, retries = 2 } = {}) { for (let i = 0; i <= retries; i++) { try { await Promise.race([ loadScript(src), new Promise((_, reject) => setTimeout(() => reject(new Error('超时')), timeout) ) ]); return; } catch (err) { if (i === retries) { throw err; } // 重试前等一会儿,避免立即重试又失败 await new Promise(r => setTimeout(r, 1000 * (i + 1))); } } }5.3 监控与持续优化
优化不是一次性的工作,上线之后要持续监控,发现问题再优化。
我一般会监控几个关键指标:首屏时间、资源加载失败率、异步加载的平均耗时。这些数据能反映优化的实际效果,也能发现新的问题。
// 简单的性能监控 window.addEventListener('load', () => { const timing = performance.getEntriesByType('navigation')[0]; const metrics = { dns: timing.domainLookupEnd - timing.domainLookupStart, tcp: timing.connectEnd - timing.connectStart, ttfb: timing.responseStart - timing.requestStart, domReady: timing.domContentLoadedEventEnd - timing.startTime, loadComplete: timing.loadEventEnd - timing.startTime }; // 上报数据 navigator.sendBeacon('/api/metrics', JSON.stringify(metrics)); });sendBeacon是专门用于数据上报的API,它不会阻塞页面卸载,比传统的同步请求更合适。
监控数据积累一段时间后,就能看出哪些资源加载慢、哪些环节有问题。比如发现某个CDN的响应时间明显偏长,就可以考虑换节点或者做多域名分流。
6. 我踩过的坑和总结的经验
异步加载和性能优化这块,我踩过的坑不算少,挑几个有代表性的说说。
第一个坑是过度优化。有段时间我痴迷于把首屏时间压到极致,把所有能异步的都异步了,结果用户滚动到下面的时候,内容半天加载不出来,体验反而更差。后来我明白一个道理:优化要有全局视角,不能只盯着首屏指标,用户完整的使用流程才是关键。
第二个坑是忽略了错误处理。异步加载的东西多了,出错概率就大。有一次上线后发现部分用户页面空白,排查半天发现是某个异步脚本加载失败,但没有降级方案,整个页面就卡住了。从那以后我所有异步加载都加了超时和降级,宁可少个功能,不能白屏。
第三个坑是缓存配置不当。有次更新了脚本,但用户一直看到旧版本,排查发现是缓存时间设太长了,而且文件名没带哈希。后来我强制要求所有静态资源文件名必须带内容哈希,缓存时间设一年都没问题。
第四个坑是移动端测试不足。桌面端跑得好好的,到手机上就卡顿。后来我养成了习惯,每次优化完必须在中低端安卓机上实测,模拟器和真机的差距真的很大。
提示:性能优化没有终点,但要有优先级。先解决影响最大的问题,再抠细节。首屏从八秒优化到两秒,收益远大于从两秒优化到一点八秒。
最后分享一个我常用的判断方法:如果你不确定某个优化值不值得做,就问自己两个问题——这个优化影响多少用户?影响的程度有多深?影响面广且程度深的,优先做;影响面窄且程度浅的,往后排。资源永远有限,把力气花在刀刃上。