做前端这些年,我见过太多团队把性能优化做成玄学——改改这个、试试那个,最后指标没上去,代码倒乱成一锅粥。其实性能优化的起点非常朴素:搞清楚浏览器在加载页面时,到底哪一步在等什么。只要把这个问题想明白,异步加载就是水到渠成的事。
这篇文章是某个系列教程里原理篇第八节的整理版,主题就两个词:异步加载、性能优化。内容围绕浏览器渲染链路展开,但也顺带覆盖了移动端性能优化的几个关键点。适合刚入门前端、想系统理解渲染原理的初级工程师,也适合那些写了不少业务、但还没系统梳理过性能链路的资深开发者。读完之后,你至少能解释清楚三个问题:脚本为什么阻塞渲染、图片懒加载到底在优化什么、页面快了之后怎么证明它真的快了。
1. 异步加载的本质:浏览器到底在等什么
1.1 先想清楚一个基本问题:页面是什么时候变慢的
先别急着聊标签和 API。异步加载这个词听起来很高端,但解决的本质问题只有一个:主线程空闲不下来。
浏览器的渲染主线程要做的事情非常多——解析 HTML、构建 DOM、解析 CSS、构建样式树、计算布局、绘制像素,还要执行你引入的所有 JavaScript。你可以把主线程想象成一条单车道公路,所有任务都必须排队通过,任何一个任务长时间卡住,后面的车就全部堵死。页面加载的耗时,本质上就是这条路被占用的总时长。
页面的加载过程大致可以拆成:DNS 解析、TCP 连接、TLS 握手、发送请求、接收响应、解析 HTML、构建渲染树、执行脚本、首次绘制。大多数开发者只关心“请求发出去到响应回来”这一小段,但实际开销往往在后半段。尤其是 JavaScript:浏览器下载完一段脚本,还要先做语法解析、字节码编译,然后才能真正执行。这段 CPU 密集型的工作,全都发生在主线程上。现代浏览器虽然有预加载扫描器,能在解析 HTML 时提前发现并下载后续资源,但预加载只解决“下载”的并行问题,解决不了“执行”的排队问题。
明白了这个模型,再去看各家优化方案就会豁然开朗:所谓异步加载,就是想办法把非必要的任务从这条单车道上挪走,要么换到并行车道上去下载,要么推迟到主线程不忙的时候再执行。
1.2 同步脚本为什么是性能杀手
假设你的页面 HTML 只有 50KB,但 head 里引了一个 300KB 的脚本。浏览器解析 HTML 时遇到 script 标签,会立刻暂停 HTML 解析,先把脚本下载并执行完,再继续往后解析。这就是所谓的解析阻塞(render-blocking)。300KB 的脚本在 4G 网络下可能要下载 1 到 2 秒,加上解析执行的时间,白屏时间直接翻倍。
更麻烦的是,即便你把这个脚本挪到 body 尾部,它仍然会阻塞该位置之后的 HTML 解析。也就是说,页面中任何一个同步脚本,都是在主线程上设置了一道不可跨越的闸门。极端情况下,一个写得很重的第三方库,可能会让首屏渲染推迟好几秒。很多性能事故的根因,就是某个同事悄悄在页面里加了一个同步加载的 SDK,没人发现。
看清楚这个规律之后,两个问题就自然浮现了:哪些脚本必须在主线程空闲前执行?哪些脚本可以晚点再执行?异步加载的全部方案,本质上都是对这两个问题的回答。这也就是为什么我在评估一个团队的性能水平时,第一件事就是打开页面源码,数一数 head 里有多少个同步 script 标签。数量越多,优化空间越大。
2. 异步加载的四个常用方案:从标签到构建工具
2.1 script 标签三件套:默认、defer、async 怎么选
<script src="normal.js"></script> <script defer src="defer.js"></script> <script async src="async.js"></script>三个写法的行为差异,很多人背过面试题但没真正理解。默认写法:HTML 解析到标签位置时,立刻下载并执行,期间阻塞后续 HTML 解析。defer 写法:网络下载阶段是并行进行的,不阻塞 HTML 解析,脚本会等到文档解析完成之后、DOMContentLoaded 事件触发之前,按文档顺序执行。async 写法:同样并行下载,但下载完成后会立刻暂停 HTML 解析,抢在主线程上执行,执行完再继续解析,而且多个 async 脚本之间不保证执行顺序。
| 属性 | 下载时机 | 执行时机 | 顺序保证 | 典型场景 |
|---|---|---|---|---|
| 默认 | 遇到标签时 | 遇到标签时(阻塞解析) | 有 | 基本不建议 |
| defer | HTML 解析期间并行 | 文档解析完成后 | 有 | 业务脚本、依赖 DOM 的脚本 |
| async | HTML 解析期间并行 | 下载完成后立即执行 | 无 | 埋点、监控、独立 SDK |
选型逻辑其实很清楚:如果是依赖 DOM 结构、需要保证顺序执行的脚本,用 defer;如果是相互独立、不依赖 DOM、越早执行越好的监控类脚本,用 async。默认写法在现代项目里应该基本消失。
这里有一个非常实用的细节:加了 defer 或 async 的脚本,在现代浏览器里都会触发预加载扫描,网络下载阶段不会浪费 HTML 解析时间。但如果你有非常关键的脚本,还可以在 head 里用 preload 显式提前声明,让浏览器以最高优先级去下载。我自己的习惯是:首屏关键脚本用 preload + defer 组合,非关键第三方脚本全部 async,业务模块全部走动态 import。
2.2 图片和 CSS:异步加载的常见盲区
脚本之外,图片通常是体量最大的资源。同步加载图片时,浏览器为每张图片发起请求,图片解码也占用一定的主线程时间。这里有两个非常实用的原生属性,小白开发者常常忽略:
loading="lazy":告诉浏览器,图片接近视口时再加载。浏览器原生懒加载,不依赖任何 JS。decoding="async":图片解码异步化。特别是大图,解码时不会卡住主线程。
这两个属性可以同时使用。实测下来,一个图片很多的博客页,给所有非首屏图片加上 loading="lazy",首屏传输量能下降 30% 到 60%,视具体页面而定。但要注意:loading="lazy"千万别加在 LCP 元素上,否则最大内容绘制会被无谓地推迟。
CSS 的异步加载更隐蔽。CSS 在默认情况下是渲染阻塞资源,因为浏览器必须拿到完整样式才能避免样式闪烁。但如果你有多套不太重要的样式(比如弹窗组件的 CSS、图表库的 CSS),完全可以用 preload 方式先下载缓存,等主样式解析完再应用:
<!-- 让浏览器提前下载但不立即应用 --> <link rel="preload" href="dialog.css" as="style" onload="this.onload=null;this.rel='stylesheet'">另外,CSS 的 media 属性也能实现条件加载——打印样式、横屏样式这种,本质上也是一种异步化,浏览器只在条件满足时才下载并应用。这个小技巧在移动端性能优化中经常被用来减少无谓的带宽消耗。
2.3 动态 import:运行时按需加载的正确姿势
如果说过 script 标签是“网页层面的异步”,动态 import 就是“应用层面的异步”。现代打包器(Webpack、Vite、Rollup)都会把import()语法拆成独立的 chunk,在浏览器端需要时才发起请求。
// 点击按钮时才加载编辑器组件 button.addEventListener('click', async () => { const { Editor } = await import('./editor.js'); new Editor(container); });动态 import 背后是一个 Promise,浏览器在主脚本执行的同时并行下载 chunk,执行时机完全由业务逻辑控制。这是目前最受推荐的按需加载方式,因为它把“异步”从资源级别提升到了功能级别——可以精确到某个路由、某个弹窗、某个开关。
框架层面已经封装好了:React 有 React.lazy 配合 Suspense,Vue 有 defineAsyncComponent,配合路由懒加载几乎是标配。用这些现成能力,能少写很多底层的 loadScript 工具函数,也减少了出错的可能。我在改造老项目时,通常第一步就是把路由表里的静态 import 全部改成动态 import,这一步通常能让首屏脚本体积直接砍半,收益极其明显。
3. 实操落地:一个页面从 3.2s 到 1.1s 的改造记录
3.1 动手前的基线测量:不要凭感觉优化
任何优化动作,第一步都应该是测量。不测量就开始改代码,你根本不知道是网络慢、脚本重,还是图片拖后腿。我习惯先用 Chrome DevTools 的 Lighthouse 做一次快速体检,同时用 Performance 面板记录完整的加载瀑布。重点看三份数据:
- 脚本总体积与请求数量:看 Network 面板按 JS 排序,找出最大的几个文件。
- FCP 和 LCP 的具体时间:确定首屏被谁拖住了。
- 主线程长任务分布:Performance 面板里红色标记的任务就是主线程卡顿的源头。
对比两份报告,就能确定优先级。记住一个原则:优化最靠前的那个瓶颈,往往收益最大。别去动那些已经很健康的资源,那不是优化,是添乱。我改造过一个活动页,Lighthouse 报告显示 LCP 高达 3.2 秒,打开瀑布图发现罪魁祸首是页面加载时同步加载了 1.8MB 的图表库,而图表只会在用户点击“查看报表”后才显示。这个场景,优化思路一目了然。
3.2 构建层配置:让打包器帮你拆
光靠运行时手动写异步还不够,大多数业务代码早就超过了单脚本能承载的体积。这一步要做的是在构建层配置代码分割,让打包器替你把拆包这件事固化下来。
以 Webpack 为例,几个关键配置:
// webpack.config.js module.exports = { optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', priority: 10 } } } } };optimization.splitChunks把 node_modules 里的第三方库拆成独立的 vendors chunk,避免每个业务页面都打包一份 React。- 所有动态路由都改成
import()形式,让每个路由页面成为独立 chunk。 - 大体积组件(富文本编辑器、图表库、PDF 预览)全部动态 import,配合 webpack 的魔法注释设置 chunk 名称。
Vite 用户更省心,默认就按动态 import 自动拆 chunk。需要留意的是rollupOptions.output.manualChunks,把更新频率相近的库合在一起,避免每次发版都让用户重新下载一遍依赖。这个细节直接影响缓存命中率,很多团队忽视了。
3.3 运行时异步加载的实施清单
构建层处理完,收尾是在运行时把剩下的资源分类处理。我的做法是先把页面资源按“关键”和“非关键”分两类,然后按下面的顺序推进:
- head 里只保留首屏样式和关键脚本(通常用 defer)。
- 非关键第三方 SDK 全部改成异步动态注入,比如统计埋点、客服组件、聊天插件。这些脚本完全可以等页面加载完再补上。
- 首屏以下的图片全部加
loading="lazy",首屏大图用decoding="async"。 - 字体文件用 preload 提前建立连接,
font-display设为 swap,避免文字闪烁。 - 用 PerformanceObserver 监控实时指标,把改造后的数据记录下来,和基线对比。
第三步有一个很容易漏的细节:所有懒加载图片必须提前设置宽高比。否则图片加载完成后会突然撑开布局,引发布局偏移,CLS 指标直接变红。具体做法可以看 5.2 节,这里先记住结论:懒加载不是加个属性就完事,它要求你把布局的稳定性一并考虑进去。
改造完再跑一次 Lighthouse,页面 LCP 从 3.2s 降到 1.1s,传输体积从 2.4MB 降到 780KB。这个量级的提升,靠的不是什么黑科技,就是老老实实把非关键资源往后挪。
4. 性能指标与验证:优化不是玄学
4.1 该盯哪些指标:FCP、LCP、TTI、TBT、CLS
异步加载的目标不是“感觉上快了”,而是数字上变好了。目前行业通用的核心指标有几个,我整理成一张表:
| 指标 | 含义 | 理想范围 | 说明 |
|---|---|---|---|
| FCP | 首次内容绘制 | < 1.8s | 用户看到第一个内容的时间 |
| LCP | 最大内容绘制 | < 2.5s | 最大元素(通常是大图或首屏标题)绘制时间 |
| TTI | 可交互时间 | < 3.5s | 主线程能响应用户操作的时间 |
| TBT | 总阻塞时间 | < 200ms | FCP 到 TTI 之间所有长任务阻塞时长之和 |
| CLS | 累计布局偏移 | < 0.1 | 页面元素突然移动的程度 |
异步加载直接影响的是 FCP 和 LCP:把关键脚本改异步后,首屏内容能更早绘制;图片懒加载则直接优化 LCP 和带宽占用。但这里有一个很容易被忽略的反向操作:如果首页最大的那张图是标题背景图,你给它加了loading="lazy",LCP 就会被推迟到用户滚动到它附近时才计算,评分会非常难看。懒加载只适合首屏以下的图片,这一点怎么强调都不为过。
关于移动端性能优化,这几个指标同样适用。但手机上的 TBT 往往会比桌面环境高一截,因为移动端 CPU 弱,同样的解析执行工作耗时是桌面的两三倍。这也是为什么移动端优化不能只看网络传输,脚本体积和主线程负担同样关键。
4.2 用 Performance API 采集真实用户数据
Lighthouse 只能在本地模拟一次加载,真实用户的环境千差万别——有人用着两年前的千元机,有人连着信号不稳的电梯 WiFi。上线后要接真实用户监控(RUM),用 Performance API 在用户浏览器里采集指标:
// 收集 LCP 指标并上报 const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.entryType === 'largest-contentful-paint') { console.log('LCP:', entry.startTime, 'ms'); // 上报到监控平台 } } }); observer.observe({ type: 'largest-contentful-paint', buffered: true }); // 手动标记异步加载的耗时 performance.mark('editor-start'); await import('./editor.js'); performance.mark('editor-end'); performance.measure('editor-load', 'editor-start', 'editor-end');这些数据上报后,按百分位分布来评估。我一般只盯 p75 和 p95:p75 代表大部分用户的实际体验,p95 用来发现极端环境下的问题。单次本地测试的数字波动很大,看分布曲线才靠谱。注意 PerformanceObserver 的buffered: true,加上它才能拿到注册监听之前已经产生的标记,这个细节我踩过坑,不加的话首屏那次 LCP 数据直接丢掉了。
4.3 回归测试与可持续优化机制
优化最怕回退:今天改好了,下个月有人在主页上 import 一个大图表库,性能又回去了。所以必须加自动化防线,让机器盯着。
- 在 CI 里配置 Lighthouse CI,跑分低于阈值就报警。
- 给打包产物设体积预算,Webpack 的
performance.maxAssetSize超过预算会警告。 - 发布前用 WebPageTest 跑一次多地域测试,检查瀑布图是否出现异常的长尾请求。
这一套机制跑起来之后,性能优化就从一次性的专项整治,变成了日常开发中的隐形守卫。我见过太多项目优化完三个月就打回原形,原因不是方案不行,而是没有把规范固化到流程里。性能优化这件事,工具永远比自觉可靠。
5. 常见问题与排查技巧实录
5.1 异步加载后脚本执行顺序不对
这是最常踩的坑。async 脚本不保证执行顺序,如果你用 async 加载了两个有依赖关系的脚本,很可能第二个先执行,然后报错。我见过有人因此把 async 全部改回同步,性能又回退了。正确做法是:有依赖关系的脚本用 defer 保序,独立脚本用 async;更现代的做法是干脆用 ES Module 的 import,让打包器处理依赖关系,从源头消灭这个坑。
另一个容易被忽视的是 DOMContentLoaded 事件。defer 脚本执行完成后才会触发 DOMContentLoaded,而 async 脚本不等。如果你在 async 脚本里监听 DOMContentLoaded,可能事件已经过去,监听永远不触发,初始化代码就静默失效。这个 bug 很阴险,因为它只在某些网络环境下复现。解决办法是初始化逻辑要做双重判断:如果 document.readyState 已经是 interactive 或 complete,就直接执行,否则再监听事件。
5.2 懒加载导致页面抖动
图片懒加载最常见的副作用是布局偏移。我在 3.3 节提醒过,这里必须说细一点。解决思路有两种:一种是在 img 上写width和height,现代浏览器会自动用这两个属性推断宽高比,预留空间;另一种是给外层容器一个padding-top等于宽高比的占位方案,兼容旧浏览器,但需要额外包裹元素。
第二个坑是懒加载触发不及时。默认的loading="lazy"是浏览器自己判断加载时机,不同浏览器阈值不同。如果你对加载时机有更精确的要求,比如希望在前一个区块滚动到一半时就预加载下一张图,建议用 IntersectionObserver 自己控制:
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '200px 0px' }); document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));rootMargin: '200px'的意思是图片距视口还有 200px 时就开始加载,相当于提前预加载,滚动体验会顺滑很多。但小心别设得太大,否则所有图片都提前加载,懒加载就没有意义了。
5.3 移动端弱网下的特殊处理
移动端性能优化是异步加载的重灾区。弱网环境下,一个 2MB 的脚本可能要下载十几秒,用户早就走了。我强烈建议在移动端页面做三件事:
- 用 Network Information API 判断网络类型,在 2G 或慢速 3G 环境下直接跳过非关键资源:大图降级成小图、隐藏视频自动播放、广告位直接不加载。
- 给关键脚本设置超时,超时后降级成简化页面,至少保证首屏可读可用。
- 对图片做多档位响应式加载,用
srcset和sizes匹配屏幕尺寸,避免手机下载桌面级的大图。600px 宽的屏幕下载 2000px 的图,是移动端最常见的带宽浪费。
还有一个容易忽略的点:移动端的 CPU 性能差异很大。同样的脚本在低端安卓机上解析执行,耗时可能是桌面环境的四倍。做移动端优化时,最好在 DevTools 里打开 CPU 降频模拟(4x 甚至 6x slowdown),看 TBT 在低端机上的表现。很多在桌面端毫无压力的页面,降频之后主线程直接卡成幻灯片。异步加载在这种场景下的价值被进一步放大——你每把一个任务移出主线程关键路径,就是在帮那些还在用两年前千元机的用户夺回时间。
最后聊点这个标题之外的体会。写这节课程的时候,我重新翻了一遍自己两年前的优化记录,发现当时纠结的那些技巧——defer 要不要加、图片要不要懒加载、chunk 拆多碎——现在看都是次要的。真正让性能长期保持健康的,是团队里形成了这么一种条件反射:新加一个功能时,先问一句它能不能异步,再问一句它占不占主线程。这种条件反射当然不是靠看文章养成的,你得自己踩几次坑。比如线上出过一次 LCP 从 1.8s 掉到 6s 的事故,起因就是有人顺手在页面里 import 了一个图表库,你才会真正理解体积预算和监控的意义。所以我越来越觉得,性能优化的功夫其实在页面之外,在每次写 import 和 script 标签之前的那个念头里。这个习惯,才是异步加载留给我的最大收益。