- Published on
真正把 Core Web Vitals 修好 — 用数字压下 LCP、INP、CLS 的顺序
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — Lighthouse 98 分,Search Console 却是红灯
- LCP、INP、CLS 各自测量什么,以及阈值
- 实验室数据与真实用户数据 — 哪一边才是真相
- 压下 LCP 的顺序 — 从服务端响应到字体
- INP 取代 FID 的原因,以及把长任务切开
- CLS 的三大元凶与各自准确的解法
- 把性能预算放进 CI
- 结语 — 要修的是 75 百分位,不是分数
引言 — Lighthouse 98 分,Search Console 却是红灯
在本地跑 Lighthouse,性能 98 分。发布一周后打开 Search Console,那个页面还老老实实待在「需要改进的网址」里。团队说「大概是谷歌的数据更新慢吧」,一个月后情况依旧。
这两个数字测的是不同的东西。Lighthouse 是在受控环境里加载一次的结果,而 Search Console 展示的是真实访客 28 天的分布。改善了前者却带不动后者的情况非常常见,而搞清楚这个落差从哪儿来,就是这项工作的一半。
本文先点出这个落差,然后按效果大小的顺序,讲怎么真正把三个指标压下去。
LCP、INP、CLS 各自测量什么,以及阈值
三个指标负责用户体验的不同侧面。LCP 是加载,INP 是响应性,CLS 是视觉稳定性。
| 指标 | 测量的内容 | 「良好」标准 | 能否在实验室测 | 最常见的原因 |
|---|---|---|---|---|
| LCP | 最大内容元素被绘制的时点 | 2.5 秒以内 | 可以 | TTFB 慢、主视觉图被发现得太晚 |
| INP | 从交互到下一次绘制 | 200ms 以内 | 不行 (用 TBT 代理) | 长任务、沉重的 DOM 更新 |
| CLS | 非预期布局偏移的累计分数 | 0.1 以内 | 部分可以 | 没有尺寸的图片、后插入的横幅 |
这里要点出大多数文章都会漏掉的两件事。
第一,这些阈值适用的不是平均值,而是 75 百分位。页面加载中有 75% 的 LCP 在 2.5 秒以内,才算「良好」。就算平均 LCP 是 2.1 秒,只要有 25% 超过 4 秒就算不合格。前面关于百分位的那套话在这里原样重演。
第二,移动端和桌面端是分开评估的。经常有人只看桌面端就判定通过了,可如果流量绝大部分来自移动端,该改善的就是移动端的数字。
CLS 的计算方式也顺带说一遍。CLS 不是整个页面生命周期的简单求和。它把发生偏移的区间按最长 5 秒、偏移间隔不超过 1 秒的规则归并成会话窗口,然后取其中总和最大的那个窗口的值。无限滚动的页面开着很久分数也不会无限增大,原因就在这里。
实验室数据与真实用户数据 — 哪一边才是真相
Lighthouse 会模拟设备与网络,加载一次。缓存是空的,第三方脚本受当天响应速度摆布,用户什么也不点。所以 Lighthouse 测不了 INP。因为根本没有交互。它转而拿总阻塞时间(TBT)作为代理指标,两者相关,但不是同一个值。
真实用户数据正相反。老旧的安卓设备、3G 路段、已经有缓存的回访、广告拦截器,以及真实的点击,全都混在一起。用于搜索评估的是这一边,用户实际经历的也是这一边。
结论很简单。实验室数据用于发现回归,而判断的依据是真实用户数据。把 Lighthouse 分数当成目标,你做出来的就会是分数上去了、用户体验纹丝不动的优化。
自建 RUM 采集,一小段脚本就能起步。
// web-vitals v4 — 用 attribution 构建版还能一并知道「是什么」慢
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution'
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating, // good | needs-improvement | poor
nav_type: metric.navigationType,
path: location.pathname,
// 不区分设备就合在一起,什么也诊断不出来
device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
conn: navigator.connection?.effectiveType,
attribution: metric.attribution,
})
navigator.sendBeacon('/rum/vitals', body)
}
onLCP(send)
onINP(send)
onCLS(send)
onTTFB(send)
采集之后一定要按路径、按设备去看 75 百分位。只有一个整体平均值,是看不出该修哪个页面的。
压下 LCP 的顺序 — 从服务端响应到字体
把 LCP 整个看,会觉得无处下手。拆成四段,答案就出来了。
- TTFB — 服务端发出第一个字节之前
- 资源加载延迟 — TTFB 之后到 LCP 资源请求发起之前
- 资源加载时长 — 把那个资源收完之前
- 渲染延迟 — 收完之后到真正绘制到屏幕上之前
import { onLCP } from 'web-vitals/attribution'
onLCP(({ value, attribution: a }) => {
console.table({
TTFB: Math.round(a.timeToFirstByte),
'资源加载延迟': Math.round(a.resourceLoadDelay),
'资源加载时长': Math.round(a.resourceLoadDuration),
'渲染延迟': Math.round(a.elementRenderDelay),
合计: Math.round(value),
元素: a.target,
})
})
// ┌──────────────────┬───────┐
// │ TTFB │ 412 │
// │ 资源加载延迟 │ 1180 │ <-- 元凶
// │ 资源加载时长 │ 340 │
// │ 渲染延迟 │ 96 │
// │ 合计 │ 2028 │
// └──────────────────┴───────┘
目标分配大致是 TTFB 与资源加载时长各占 40 个百分点左右,两项延迟应该接近于 0。上面这个例子里加载延迟是 1,180ms,意思是浏览器有一秒多的时间根本不知道主视觉图的存在。图片文件再怎么优化,这个数字也不会变小。
先确认 TTFB
curl -s -o /dev/null \
-w 'dns:%{time_namelookup}s tcp:%{time_connect}s tls:%{time_appconnect}s ttfb:%{time_starttransfer}s total:%{time_total}s\n' \
https://shop.example.com/products/12345
# dns:0.021s tcp:0.043s tls:0.118s ttfb:0.412s total:0.588s
# 顺便看缓存是否命中 — CDN 未命中是 TTFB 变慢的常见原因
curl -sI https://shop.example.com/products/12345 | grep -iE 'cache|age|server-timing'
# cache-control: public, max-age=0, s-maxage=300
# age: 0
# x-cache: MISS
# server-timing: db;dur=284, render;dur=96
只要服务端下发 Server-Timing 头,就能在 RUM 数据里看到 TTFB 的内部构成。没有它,TTFB 一慢就没办法分清是网络还是后端。
资源加载延迟是最大的杠杆
如果 LCP 元素是图片,就必须让预加载扫描器能找到它。下面三种做法会让扫描器失效。
- 用 CSS 的 background-image 放的主视觉图 — 要等 CSSOM 建好才会被发现。
- 用 JavaScript 插入的图片 — 要等打包产物执行才会被发现。
- 给主视觉图加了懒加载 — 最常见的自残。给视口内的图片挂 lazy,请求本身就被往后推了。
<!-- 主视觉图: 放在标记里,提高优先级,绝对不要挂 lazy -->
<img
src="/hero-1200.avif"
srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
sizes="(max-width: 768px) 100vw, 1200px"
width="1200"
height="675"
fetchpriority="high"
decoding="async"
alt="夏季新品"
/>
<!-- 兜底: 如果实在没法放进标记里,就用 preload 代替扫描器 -->
<link
rel="preload"
as="image"
href="/hero-1200.avif"
imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
imagesizes="(max-width: 768px) 100vw, 1200px"
fetchpriority="high"
/>
如果用的是 Next.js 的 next/image,给主视觉加上 priority 属性,就同时处理了上面的 fetchpriority="high" 和取消 lazy 两件事。而列表下方的图片则相反,保持默认的懒加载。
渲染阻塞资源要减少,但不要整个干掉
有两条常见的错误建议。
「把所有 CSS 都内联。」内联关键 CSS 是对的,但把整份 CSS 都内联会让 HTML 变大,TTFB 之后的解析时间增加,而且缓存完全用不上。目标要限定在首屏所需的规则上。
「JavaScript 加个 defer 就行了。」defer 只是推迟执行,下载和解析的成本一分不少。如果 LCP 元素是由 JavaScript 渲染出来的,defer 反而会让 LCP 更慢。这种情况需要的是服务端渲染。
第三方脚本要单独处理。标签管理器、聊天挂件、A/B 测试工具通常都会比 LCP 元素更早加载,把带宽和主线程抢走。
# 看看是哪些资源在 LCP 之前抢走了带宽
npx lighthouse https://shop.example.com/ \
--only-audits=largest-contentful-paint-element,render-blocking-resources,third-party-summary \
--output=json --output-path=./lh.json --quiet
node -e "
const r = require('./lh.json');
for (const it of r.audits['third-party-summary'].details.items.slice(0, 5))
console.log(it.entity.padEnd(28), Math.round(it.blockingTime) + 'ms blocking', Math.round(it.transferSize / 1024) + 'KB');
"
# Google Tag Manager 412ms blocking 148KB
# Intercom 288ms blocking 231KB
# Hotjar 134ms blocking 92KB
字体
字体横跨 LCP 与 CLS 两边。font-display: swap 能消掉文字不可见的那一段,但兜底字体与真实字体之间 metrics 的差异会让布局跳动。解法是把兜底字体的度量对齐到真实字体。
@font-face {
font-family: 'Pretendard';
src: url('/fonts/pretendard-subset.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
/* 只把韩文预组合字 + ASCII 做成子集 — 完整字体会有好几 MB */
unicode-range: U+0020-007E, U+AC00-D7A3;
}
/* 把兜底字体的度量对齐到真实字体,消除交换时刻的偏移 */
@font-face {
font-family: 'Pretendard Fallback';
src:
local('Apple SD Gothic Neo'),
local('Malgun Gothic');
size-adjust: 103%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: 'Pretendard', 'Pretendard Fallback', sans-serif;
}
只 preload 首屏用到的字体文件。把各种字重全都 preload,就会和主视觉图抢带宽。
INP 取代 FID 的原因,以及把长任务切开
FID 只测量了首次交互的输入延迟。也就是浏览器开始执行事件处理器之前的那段时间。哪怕处理器攥着主线程不放 400ms,FID 也不会把它算进去。所以大多数站点都能轻松通过 FID,而通过了的站点照样卡顿。
INP 把三段全都包含进来,而且看的不是第一次,而是页面生命周期内的所有交互。
- 输入延迟:主线程正忙于别的工作,导致处理器启动被推后的时间
- 处理时长:事件处理器执行的时间
- 呈现延迟:处理器结束之后到下一帧被绘制出来之前
先确认问题出在哪一段。
import { onINP } from 'web-vitals/attribution'
onINP(({ value, attribution: a }) => {
console.table({
输入延迟: Math.round(a.inputDelay),
处理时长: Math.round(a.processingDuration),
呈现延迟: Math.round(a.presentationDelay),
合计: Math.round(value),
目标: a.interactionTarget,
事件: a.interactionType,
})
})
// 输入延迟 34 / 处理时长 268 / 呈现延迟 92 / 合计 394
// 目标: button.filter-apply
处理时长大就把处理器切开。这里有一件事必须准确理解。await 不是让出。await 造出来的是微任务,而微任务会在当前任务结束之前执行,所以根本不给浏览器绘制的空隙。要真正让出,就必须制造任务边界。
// 差: 点一下就把主线程攥住 380ms
applyButton.addEventListener('click', () => {
applyFilters(state) // 12ms
rerenderTable(rows) // 260ms
syncToServer(state) // 108ms
})
// 好: 先把看得见的画出来,其余切成任务并让出
applyButton.addEventListener('click', async () => {
applyFilters(state)
showPendingState() // 只有用户立刻会看到的反馈才同步执行
await yieldToMain() // 浏览器在这里绘制
rerenderTable(rows)
await yieldToMain()
syncToServer(state) // 与画面无关的工作放到最后
})
function yieldToMain() {
// scheduler.yield 在让出后仍保留优先级,比 setTimeout 更有利
if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield()
return new Promise((resolve) => setTimeout(resolve, 0))
}
另一个经常搞错的点是 requestIdleCallback。它在空闲时间执行,所以把必须显示到屏幕上的更新放进去,呈现延迟就会原样拉长。空闲回调只用于用户并不在等的工作,比如发送分析事件或者预取。
呈现延迟大的情况,通常是 DOM 太大。要么让浏览器把屏幕外区域的渲染成本往后推,要么把长列表虚拟化。
/* 跳过屏幕外卡片的布局与绘制 */
.product-card {
content-visibility: auto;
contain-intrinsic-size: auto 320px; /* 防止滚动条跳动的预估尺寸 */
}
长任务可以常态化监控。
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < 120) continue
console.warn('long task', Math.round(entry.duration) + 'ms', entry.attribution?.[0]?.name)
}
}).observe({ type: 'longtask', buffered: true })
CLS 的三大元凶与各自准确的解法
CLS 的成因基本上是固定的那几种,处理掉三样,大多数情况就能降到 0.1 以下。
第一,没有声明尺寸的图片和 iframe。在资源到达之前,浏览器不知道该留出多大的位置。解法是明确写上 width 和 height 属性。现代浏览器会从这两个值自动算出 aspect-ratio,即使在响应式布局里也能把位置占住。就算用 CSS 控制尺寸,属性也要照样保留。
<!-- 属性是真实像素尺寸,CSS 是显示尺寸 — 两者都需要 -->
<img src="/thumb.avif" width="640" height="360" style="width: 100%; height: auto" alt="商品" />
第二,后来才插入的横幅、广告与 Cookie 提示。在已经绘制好的内容上方塞进一个元素,下面的一整块都会被推走。解法有两个。要么提前把位置占好,要么把它从文档流里拿出去。
/* 提前把位置占住 — 就算广告没出来也不会有偏移 */
.ad-slot {
min-height: 250px;
contain: layout;
}
/* Cookie 横幅不要放进文档流,用浮层的方式浮出来 */
.cookie-banner {
position: fixed;
inset-block-end: 0;
inset-inline: 0;
}
这里有个常见的误解。「用户点击展开的折叠面板也会被算进 CLS。」不会。用户输入之后 500ms 以内发生的偏移会带上 hadRecentInput 标志,从分数里排除。不过如果超过这个时间、异步到达的内容把东西推开了,那就会被算进去。
第三,字体交换。用上一节的 size-adjust 方式解决。在文字多的页面上,仅这一项就占掉 CLS 一半的情况相当常见。
再补一条,动画要用 transform 来做。去动 top、left、width、height,每一帧都会被算成布局偏移,原样堆进分数里。
到底是什么在动,可以直接观察。
new PerformanceObserver((list) => {
for (const shift of list.getEntries()) {
if (shift.hadRecentInput) continue // 用户输入之后紧接着的偏移会被排除
for (const src of shift.sources) {
console.log(shift.value.toFixed(4), src.node, src.previousRect, '→', src.currentRect)
}
}
}).observe({ type: 'layout-shift', buffered: true })
// 0.0821 <img class="hero"> DOMRect(0,180,...) → DOMRect(0,412,...)
把性能预算放进 CI
靠手工测量改出来的优化,两次发布之内就会退回去。能挡住回归的只有闸门。
这里同样有个常见的错误。不要对合成分数设闸门。分数是多个指标的加权平均,而权重会随版本变化。仅仅是工具升级就可能让 CI 挂掉,或者反过来,某个指标变差了但另一个变好了,分数维持不动。闸门要挂在单项指标的绝对值上。
{
"ci": {
"collect": {
"url": ["https://staging.shop.example.com/", "https://staging.shop.example.com/products/12345"],
"numberOfRuns": 5,
"settings": { "preset": "desktop" }
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"server-response-time": ["error", { "maxNumericValue": 600 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
"resource-summary:image:size": ["warn", { "maxNumericValue": 400000 }],
"unsized-images": "error",
"uses-responsive-images": "warn"
}
},
"upload": { "target": "temporary-public-storage" }
}
}
npx lhci autorun
# ✅ .lighthouseci/ directory writable
# ✅ Configuration file found
# Running Lighthouse 5 time(s) on https://staging.shop.example.com/
# ...
# Checking assertions against 2 URL(s), 5 run(s)
# ✘ largest-contentful-paint failure expected <= 2500, found 2884
# https://staging.shop.example.com/products/12345
# Assertion failed. Exiting with status code 1.
把 numberOfRuns 设成 5 很重要。单次运行的波动太大,闸门会随机崩掉。Lighthouse CI 是按多次运行的中位数来判定的。
打包体积要作为单独的预算来管理。它是对 LCP 与 INP 影响最直接的变量。
npx size-limit
# Path: .next/static/chunks/pages/index-*.js
# Size: 164.2 kB with all dependencies, minified and brotlied
# Size limit: 180 kB
#
# Path: .next/static/chunks/pages/products/[id]-*.js
# Size: 221.4 kB with all dependencies, minified and brotlied
# Size limit: 180 kB
# ✘ Size limit has been exceeded by 41.4 kB
最后,CI 闸门过了并不等于结束。CI 只能抓住回归。发布之后要连着几天确认 RUM 的 75 百分位是不是真的降下来了,改善才算完成。实验室里砍掉 400ms 而真实用户数据纹丝不动的情况确实很常见,那种时候瓶颈通常是只存在于真实用户环境里的某个东西。
结语 — 要修的是 75 百分位,不是分数
要记住的只有一句话。Lighthouse 是抓回归的工具,而要修的对象是真实用户数据的 75 百分位。
工作顺序也由此而来。先接上 RUM,按路径、按设备看 75 百分位。挑出最差的那条路径,如果是 LCP 就拆成四段确定问题出在哪一段,如果是 INP 就在输入延迟、处理时长、呈现延迟里确定是哪一个。只应用对应那一段的解法。然后用单项指标的绝对值挂上 CI 闸门,让它不会再退回来。
最常见的一记重拳依然是主视觉图。仅仅是摘掉 lazy 属性、加上 fetchpriority="high"、写明 width 和 height,就能让 LCP 与 CLS 同时下降的页面多得是。就从那里开始。
值得继续深入的资料。