接手一个后台管理系统的首页,Lighthouse 性能分 42,LCP 4.2s。三周后 LCP 1.1s、CLS 0.02、INP 90ms。过程没什么黑魔法,就是按顺序解决瓶颈。

先搞清楚三个指标在测什么

指标 含义 好 / 差
LCP 最大内容元素绘制时间 < 2.5s / > 4s
INP 交互到下次绘制的延迟 < 200ms / > 500ms
CLS 布局偏移累积 < 0.1 / > 0.25

LCP 通常就是「首屏那张大图或者那段大标题什么时候渲染出来」。所以优化 LCP = 让关键资源更快到位。

第一步:用真实分布定位,而不是靠猜

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('LCP', entry.startTime, entry.element, entry.url);
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

输出告诉我们 LCP 元素是首屏的用户头像图(一个 800KB 的 PNG),而不是我们以为的标题文字。先测量,再动手。

第二步:图片是一切问题的元凶

2.1 换成现代格式

<picture>
  <source srcset="/avatar.avif" type="image/avif">
  <source srcset="/avatar.webp" type="image/webp">
  <img src="/avatar.png" width="96" height="96" alt="用户头像">
</picture>

800KB → 42KB。这一步单独就把 LCP 砍掉了 1.8s。

2.2 关键图片加 preload,非关键图加 lazy

<link rel="preload" as="image" href="/avatar.avif" fetchpriority="high">
<img src="/below-fold.png" loading="lazy" decoding="async" alt="">

注意:首屏图片千万不要 loading="lazy",那会让 LCP 更慢。

2.3 必须写死宽高

<img src="..." width="640" height="360" alt="">

或者用 CSS 长宽比:

.thumb { aspect-ratio: 16 / 9; width: 100%; }

不写宽高 = 图片加载后撑开布局 = CLS 飙升。

第三步:字体阻塞

原来的 @font-face 是这样的:

@font-face {
  font-family: 'Brand';
  src: url('/brand.woff2') format('woff2');
  /* 没有 font-display */
}

浏览器会阻塞文本渲染最长 3 秒等待字体。改成:

@font-face {
  font-family: 'Brand';
  src: url('/brand.woff2') format('woff2');
  font-display: swap;        /* 先用系统字体渲染,字体到了再替换 */
  unicode-range: U+0000-00FF; /* 只要拉丁字符,中文走系统字体 */
}

配合 preload:

<link rel="preload" href="/brand.woff2" as="font" type="font/woff2" crossorigin>

第四步:把渲染阻塞的 JS 干掉

原来 <head> 里有 5 个同步 <script>。改法:

<!-- 关键:不需要在渲染前执行的都加 defer -->
<script src="/analytics.js" defer></script>
<script src="/app.js" type="module"></script>  <!-- module 天然 defer -->

对于第三方脚本(统计、客服),用空闲时间加载:

requestIdleCallback(() => {
  const s = document.createElement('script');
  s.src = 'https://third-party.com/widget.js';
  s.async = true;
  document.body.appendChild(s);
});

第五步:CLS 来自运行时插入的元素

两个典型来源:

  1. 广告位 / 推荐位没有预留高度
.ad-slot { min-height: 250px; }   /* 先占位,内容来了再填 */
  1. Toast / 顶部横幅把内容顶下去
/* 改成覆盖式,不参与文档流 */
.banner { position: fixed; top: 0; left: 0; right: 0; }

优化结果

指标 优化前 优化后 主要贡献
LCP 4.2s 1.1s 图片格式 + preload
CLS 0.31 0.02 写死宽高 + 预留广告位
INP 480ms 90ms 拆分长任务
首屏 JS 1.2MB 310KB 代码分割 + defer
Lighthouse 42 96 —

可以复用的排查顺序

  1. 打开 Performance 面板录一次首屏,找到 LCP 元素到底是哪个
  2. 查网络瀑布图,找首屏关键链路上最慢的那个资源
  3. 图片:格式 → 尺寸 → preload/lazy
  4. 字体:font-display: swap + preload + unicode-range
  5. JS:defer / 代码分割 / 第三方脚本延后
  6. CLS:所有会异步变化的元素都要预留空间
  7. 复测:Chart 里对比真实用户分布,别只看本地 Lighthouse

一个容易被忽略的点

性能优化最容易犯的错是只优化本地 Lighthouse 分数。本地是千兆宽带 + 空缓存,真实用户可能在地铁上用 4G。

优先看 CrUX / RUM 的真实分布(p75 而不是平均值),把 p75 拉进「好」的区间,才算真的优化完成。