5.1 秒到 1.5 秒,中间隔着 5 次改动,只有 1 次碰了业务代码。
上周我做了个受控实验:搭一个埋满典型性能坑的活动落地页,用 Lighthouse 移动端模拟(Slow 4G + 4 倍 CPU 降速),每改一处测一次。所有数字都是实测,不是估算。
先看总账:
| 步骤 | 改动 | LCP | TBT | 性能分 |
|---|---|---|---|---|
| 原始页 | — | 5.1s | 0ms | 77 |
| 第 1 次 | JPG 换 WebP | 3.7s | 0ms | 86 |
| 第 2 次 | 加 preload | 2.6s | 0ms | 93 |
| 第 3 次 | script 加 defer | 2.6s | 990ms | 75 ↓ |
| 第 4 次 | 长任务切片(唯一改代码) | 2.2s | 0ms | 99 |
| 第 5 次 | 按视口出图 | 1.5s | 0ms | 100 |
注意第 3 次那一行:改完分数反而从 93 跌到 75。不是测错了,是这次实验里最有意思的发现,后面细说。
实验设定
页面是一个大会落地页:一张 1920 宽的 JPG 头图(408KB,background-image 挂首屏),一段 30KB 的同步 JS(轮播初始化里藏着 6000 万次循环的长任务),加常规的标题、议程卡片、CTA。没有框架和第三方脚本,变量干净。
真机上跑,绝对值不会这么整齐,但每步改动的因果方向是可靠的——生产页上你永远说不清那 800 毫秒是谁贡献的,受控实验能。
Lighthouse 的分数看着抽象,LCP 分解倒是很诚实。原始页的 5.1 秒拆出来:
| LCP 组成 | 耗时 | 意思 |
|---|---|---|
| 发现延迟 | 2.34s | 浏览器很晚才知道要下这张图 |
| 加载时长 | 2.74s | 图太大,下载要 2.7 秒 |
| 渲染延迟 | 0.01s | 到位即渲染 |
后面 5 次优化,本质上就是把这四段挨个打掉。
第 1 次:JPG 换 WebP,5.1s → 3.7s
最没技术含量的一步,性价比却最高:
cwebp -q 90 hero.jpg -o hero.webp
408KB 变 141KB,画质肉眼无差,LCP 加载时长从 2.74s 掉到 1.33s。
1.6Mbps 带宽下,408KB 意味着光下载就 2 秒多。首屏最大的元素通常是头图,LCP 的主角就是它——头图胖,LCP 必胖。动手调代码之前,先把资源过一遍秤,图片超重是营销页最常见也最便宜的病。
第 2 次:一行 preload,3.7s → 2.6s
换完 WebP,加载时长降了,发现延迟还是 2.34s:浏览器要等 CSS 下载、同步 JS 执行完、body 开始解析,才从 style 里读到头图 URL,然后才开始下载这张最重要的图。
所以让头图插队:
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
放在 head 里,浏览器解析到这行就发请求,不等 CSS 也不等 JS。发现延迟从 2.34s 砍到 0.58s。fetchpriority="high" 是让浏览器把带宽优先分给头图——没有它,preload 只是提前排队;CSS 里引用的背景图是全页面藏得最深的关键资源,这招对它尤其有效。
第 3 次:加 defer,LCP 没动,分数跌 18
前两次干掉的是图片的问题,第 3 次轮到那个 30KB 的同步 JS:
<script src="app.js" defer></script>
直觉上这步稳赚:脚本不再阻塞解析。实测结果却是 LCP 纹丝不动(2.6s),TBT 从 0 涨到 990ms,性能分 93 → 75。
原因不玄:那个 6000 万次循环的长任务本来就在,只是位置变了。同步脚本时代,它执行时页面还没画出第一帧,TBT 根本看不见它(TBT 只统计首帧之后的阻塞);defer 之后首帧提前画出来了,长任务落到首帧后面执行,TBT 当场抓个正着。
阻塞用户的事件从头到尾就那么多,defer 没有消灭它,只是把它从「看不见的窗口」挪进了「看得见的窗口」。这步的教训:优化前先想清楚指标在度量哪一段,别只盯一个指标——LCP 好看了 TBT 爆了,体验是继续烂着的。
第 4 次:切片,唯一一次改代码,TBT 990ms → 0
长任务的处理思路一句话:任何超过 50ms 的任务都该拆开。
原始版本是同步跑完 6000 万次循环(模拟真实项目里「预计算 + 建索引」的重活):
function initCarousel() {
var acc = 0;
// 大循环跑到底,主线程卡死 ~1.5 秒
for (var i = 0; i < 6e7; i++) { acc = (acc * 31 + i) % 1000003; }
window.__initSum = acc;
}
切片版拆成 60 片,每片 100 万次,片间用 setTimeout 让出主线程:
function initCarousel() {
var acc = 0, i = 0, N = 6e7, STEP = 1e6;
(function chunk() {
var end = Math.min(i + STEP, N);
for (; i < end; i++) { acc = (acc * 31 + i) % 1000003; }
if (i < N) { setTimeout(chunk, 0); }
else { window.__initSum = acc; }
})();
}
每片不到 50ms,输入响应和渲染帧都能在片间插进来。实测:TBT 990ms → 0,LCP 顺带 2.6s → 2.2s(首帧渲染不再被挡),性能分 75 → 99。
要诚实地说:这 0.4 秒是顺带的,这步真正救的是交互。总工作量一点没少,6000 万次还是 6000 万次,只是不再一次全塞给主线程。setTimeout 是最朴素的做法,任务更重还有 scheduler.yield 和 Web Worker,但「先拆开」比选哪个 API 重要。
第 5 次:别给手机喂 1920 的图,2.2s → 1.5s
最后回头看图片本身:手机视口 412px 宽,2 倍屏也只需要 824px 的图,我却给所有设备发 1920 宽原图。
cwebp -resize 840 560 -q 90 hero.jpg -o hero-sm.webp
141KB 变 25KB,加载时长 1.5s → 0.83s,LCP 收在 1.5s,性能分 100。
工程里这步通常是响应式图片(srcset/sizes)或图片服务按宽度出图,原理相同:渲染尺寸决定需要的像素,别多给。第 1 次省的是「格式的浪费」,这次省的是「尺寸的浪费」,两者经常被混为一谈——压了格式没压尺寸,头图照样虚胖。
复盘:3.6 秒里,代码只值 0.4 秒
把 5 次改动按杠杆归类:
| 杠杆 | 对应改动 | 贡献 |
|---|---|---|
| 资源体积 | WebP、按视口出图 | -1.4s、-0.7s |
| 资源发现 | preload + fetchpriority | -1.1s |
| 主线程 | defer(暴露问题)、切片(解决问题) | TBT 990ms → 0 |
4 次配置级的改动拿走 3.2 秒,唯一那次改代码贡献 0.4 秒 LCP 加一个干净的 TBT。不是说代码不重要——没有切片,页面就是个「图片秒开但点不动」的页面,TBT 990ms 是真实存在的卡顿。它想说的是:动手改代码之前,先把配置层和资源层的便宜捡完。
操作顺序三步:跑一次 Lighthouse 移动端测试,看 LCP 分解四段里哪段最长;发现延迟长 → preload,加载时长长 → 压图出小图,渲染延迟长 → 查长任务;配置层治完还剩长任务,再动代码——切片,而不是删功能。
判断「该动哪一刀」永远比「能动多少刀」值钱。你的页面最近一次跑 Lighthouse 是什么时候?评论区报个数,我猜不少人从没看过自己页面的 LCP 分解——那里面的每一项,都对应一行能落地的改动。