产品落地页的跳出率一直偏高,运营怀疑是加载太慢。花了三周把首屏最大内容绘制(LCP)从 P75 的 4.2 秒降到 1.1 秒,跳出率随之下降了 9 个百分点。这篇按实际顺序记录每一步,包括没生效的那些。
先建立可信的基线
Lighthouse 分数不能作为唯一依据。它跑在固定的模拟网络下,和真实用户的设备与网络分布关系不大。我们的做法是两条腿走路:实验室数据用来定位问题,真实用户数据用来判断是否改善。
真实用户指标用 PerformanceObserver 直接采集,不引入任何第三方库:
<script>
(function () {
var lcp = 0;
var po = new PerformanceObserver(function (list) {
var entries = list.getEntries();
var last = entries[entries.length - 1];
lcp = last.renderTime || last.loadTime;
window.__lcpElement = last.element; // 调试用
});
po.observe({ type: 'largest-contentful-paint', buffered: true });
addEventListener('visibilitychange', function report() {
if (document.visibilityState !== 'hidden' || !lcp) return;
removeEventListener('visibilitychange', report);
var nav = performance.getEntriesByType('navigation')[0] || {};
navigator.sendBeacon('/rum', JSON.stringify({
lcp: Math.round(lcp),
ttfb: Math.round(nav.responseStart || 0),
conn: (navigator.connection || {}).effectiveType,
path: location.pathname
}));
});
})();
</script>
两个细节值得说:LCP 会在页面生命周期中多次更新,取最后一次才是最终值;上报时机用 visibilitychange 而不是 beforeunload,后者在移动端经常不触发。
数据回来之后,第一个发现是分布严重右偏:P50 是 1.9 秒,P75 到了 4.2 秒,P95 有 9 秒以上。只看平均值会完全错过问题。
定位 LCP 元素
在控制台里直接看:
window.__lcpElement
// <img class="hero-banner" src="/img/hero.png">
是首屏那张主视觉图。于是问题被拆成两个:这张图什么时候开始下载,以及下载要多久。
用 Resource Timing 看时间线:
performance.getEntriesByType('resource')
.filter(function (e) { return /hero/.test(e.name); })
.map(function (e) {
return {
start: Math.round(e.startTime),
duration: Math.round(e.duration),
size: e.encodedBodySize
};
});
// [{ start: 1840, duration: 2210, size: 1482330 }]
1.84 秒才开始请求,本身又下了 2.2 秒,1.4 MB。两头都有问题。
为什么 1.84 秒才开始下载
因为这张图是被 CSS 背景引用的,浏览器必须先下载并解析完 CSS 才知道它的存在。而那份 CSS 是渲染阻塞的,前面还排着一个同步的 JS。整条链路是串行的:
改动有三处。
把背景图换成 <img>。 这样预加载扫描器在解析 HTML 时就能发现它,不必等 CSS。同时加上 fetchpriority="high",让它插到请求队列前面:
<img src="/img/hero.avif" alt="产品概览"
width="1200" height="630"
fetchpriority="high" decoding="async">
width 和 height 必须写,浏览器据此预留空间,能顺带把 CLS 降下来。
JS 全部改成 defer。 首屏内容是服务端渲染的,JS 只负责交互,没有理由阻塞渲染:
<script src="/assets/app.4f2a9c1e.js" defer></script>
defer 与 async 的区别:前者保证按顺序在 DOM 解析完后执行,后者下载完就执行、顺序不定。有依赖关系的脚本必须用 defer。
关键 CSS 内联,其余异步加载。 首屏用到的样式大约 6 KB,直接内联进 HTML;剩下的用这个模式异步加载:
<style>/* 首屏关键样式,约 6KB */</style>
<link rel="stylesheet" href="/assets/main.b7d03e88.css"
media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/assets/main.b7d03e88.css"></noscript>
media="print" 让浏览器以低优先级下载且不阻塞渲染,加载完再改回 all。noscript 分支保证禁用 JS 时样式仍然生效。
为什么下载要 2.2 秒
1.4 MB 的 PNG,而且是按 2400px 宽导出的,实际展示区域最宽 1200px。三件事:
换格式。 同等观感下,AVIF 比 PNG 小 80% 以上,比 JPEG 小 50% 左右:
avifenc --min 24 --max 32 --speed 4 hero.png hero.avif
cwebp -q 82 hero.png -o hero.webp
用 <picture> 做降级,老浏览器仍然拿到 JPEG:
<picture>
<source srcset="/img/hero.avif" type="image/avif">
<source srcset="/img/hero.webp" type="image/webp">
<img src="/img/hero.jpg" alt="产品概览"
width="1200" height="630" fetchpriority="high">
</picture>
按屏幕尺寸给不同分辨率。 手机没必要下 1200px 的图:
<img src="/img/hero-800.avif"
srcset="/img/hero-480.avif 480w,
/img/hero-800.avif 800w,
/img/hero-1200.avif 1200w"
sizes="(max-width: 640px) 100vw, 1200px"
width="1200" height="630" alt="产品概览" fetchpriority="high">
首屏之外的图片才用懒加载。 这里要特别小心:给 LCP 图片加 loading="lazy" 会让它排到最后下载,是很常见的自伤操作。规则很简单——首屏内的图片绝不懒加载。
三步做完,图片从 1.4 MB 降到 96 KB,下载时间 2.21 秒变 0.51 秒。
字体:被低估的阻塞源
页面用了一款中文字体做标题,完整字重 3.8 MB。即使 LCP 元素是图片,字体加载也会影响 FCP 和文字的可见时间。
做了两件事。一是子集化,只保留实际用到的字符范围,体积降到 210 KB;二是显式声明 font-display:
@font-face {
font-family: "Display CN";
src: url("/fonts/display-cn-subset.woff2") format("woff2");
font-display: swap;
unicode-range: U+4E00-9FFF, U+3000-303F, U+FF00-FFEF;
font-weight: 700;
}
swap 的含义是先用后备字体渲染,字体到了再替换。代价是会有一次字体闪烁;如果对此敏感,用 optional——超时就干脆不用这个字体,永远不闪。我们选了 optional,因为标题字体只是锦上添花。
更进一步的做法是干脆不加载 Web 字体,正文直接用系统字体栈。这个站现在就是这么做的,中文下 PingFang SC 和 Microsoft YaHei 的观感已经足够好,成本是零。
服务端那一段
TTFB 的 P75 是 620 毫秒,其中约 400 毫秒花在渲染时查询推荐位。加了 60 秒的服务端缓存,并配上 stale-while-revalidate,让过期后先返回旧内容再后台刷新:
Cache-Control: public, max-age=60, stale-while-revalidate=600
TTFB 降到 180 毫秒。另外把静态资源域名的 preconnect 加上,省掉一次 DNS + TLS 握手:
<link rel="preconnect" href="https://static.example.com" crossorigin>
preconnect 不要滥用,超过三四个反而会争抢连接。只对确定会用到的关键域名加。
每一步的收益
| 改动 | LCP P75 | 增量 |
|---|---|---|
| 基线 | 4.20s | — |
| JS 改 defer + 关键 CSS 内联 | 3.15s | −1.05s |
| 背景图改 img + fetchpriority | 2.48s | −0.67s |
| AVIF + srcset | 1.62s | −0.86s |
| TTFB 缓存 | 1.24s | −0.38s |
| 字体子集 + font-display | 1.11s | −0.13s |
没生效的尝试
给所有资源加 preload。 一开始给六七个资源都加了,结果 LCP 反而涨了 200 毫秒。原因是 preload 会提升优先级,同时提升所有资源等于没有提升,还挤占了 LCP 图片的带宽。最后只保留了 LCP 图片和关键字体两条。
HTTP/2 Server Push。 试了,收益接近零,还经常推送浏览器已经缓存的资源、白白浪费带宽。这个特性现在主流浏览器已经移除,别再花时间了。
把第三方统计脚本换成异步。 有收益但很小(约 40 毫秒),因为它本来就在 defer 之后。真正的问题是它引入的第三个域名连接,用 preconnect 解决了。
保持住
性能优化最难的不是做到,而是不退化。我们在 CI 里加了预算检查,超标直接失败:
lighthouse https://staging.example.com/landing \
--budget-path=./budget.json \
--only-categories=performance \
--chrome-flags="--headless=new" \
--output=json --output-path=./lh.json
[{
"path": "/*",
"timings": [
{ "metric": "largest-contentful-paint", "budget": 1500 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 }
],
"resourceSizes": [
{ "resourceType": "script", "budget": 180 },
{ "resourceType": "image", "budget": 250 },
{ "resourceType": "total", "budget": 600 }
]
}]
阈值故意比现状留了余量(1.5 秒对 1.1 秒),避免抖动造成的误报。真实用户数据每周看一次 P75 趋势,实验室数据用来卡门禁——两者分工不同,不要混用。