接手一个后台管理系统的首页,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 来自运行时插入的元素
两个典型来源:
- 广告位 / 推荐位没有预留高度
.ad-slot { min-height: 250px; } /* 先占位,内容来了再填 */
- 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 | — |
可以复用的排查顺序
- 打开 Performance 面板录一次首屏,找到 LCP 元素到底是哪个
- 查网络瀑布图,找首屏关键链路上最慢的那个资源
- 图片:格式 → 尺寸 → preload/lazy
- 字体:
font-display: swap+ preload +unicode-range - JS:
defer/ 代码分割 / 第三方脚本延后 - CLS:所有会异步变化的元素都要预留空间
- 复测:Chart 里对比真实用户分布,别只看本地 Lighthouse
一个容易被忽略的点
性能优化最容易犯的错是只优化本地 Lighthouse 分数。本地是千兆宽带 + 空缓存,真实用户可能在地铁上用 4G。
优先看 CrUX / RUM 的真实分布(p75 而不是平均值),把 p75 拉进「好」的区间,才算真的优化完成。