LCP 从 4.2 秒到 1.1 秒

·约 1900 字 · Web 性能前端

产品落地页的跳出率一直偏高,运营怀疑是加载太慢。花了三周把首屏最大内容绘制(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。整条链路是串行的:

优化前(串行 4.05s) HTML 0.42s app.js 0.61s main.css 0.81s hero.png 2.21s 优化后(并行 1.08s) HTML hero.avif 0.51s main.css app.js(defer)
关键路径从四段串行压缩为两段并行,图片提前到 HTML 解析阶段就开始下载

改动有三处。

把背景图换成 <img> 这样预加载扫描器在解析 HTML 时就能发现它,不必等 CSS。同时加上 fetchpriority="high",让它插到请求队列前面:

<img src="/img/hero.avif" alt="产品概览"
     width="1200" height="630"
     fetchpriority="high" decoding="async">

widthheight 必须写,浏览器据此预留空间,能顺带把 CLS 降下来。

JS 全部改成 defer 首屏内容是服务端渲染的,JS 只负责交互,没有理由阻塞渲染:

<script src="/assets/app.4f2a9c1e.js" defer></script>

deferasync 的区别:前者保证按顺序在 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" 让浏览器以低优先级下载且不阻塞渲染,加载完再改回 allnoscript 分支保证禁用 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 SCMicrosoft 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 + fetchpriority2.48s−0.67s
AVIF + srcset1.62s−0.86s
TTFB 缓存1.24s−0.38s
字体子集 + font-display1.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 趋势,实验室数据用来卡门禁——两者分工不同,不要混用。

← 返回首页