Core Web Vitals 深度剖析
理解并修复 LCP、INP 与 CLS —— 附一套面向开发者的调试工作流。
Core Web Vitals 是 Google 衡量页面体验的标准化方式 —— 页面渲染有多快、对输入的响应有多迅速、加载过程中保持得有多稳定。它们汇总成更宽泛的页面体验信号,在排名中充当一个决胜因素:它很少能盖过优质内容,但一个又慢又卡顿的页面,会输给一个同样相关却更快的页面。
如今正好有三项 Core Web Vitals,其中一项是新的。自 2024 年 3 月起,**INP(Interaction to Next Paint)取代了 FID(First Input Delay)**成为响应性指标。FID 只衡量首次交互被处理前的延迟;INP 衡量整个页面生命周期内所有交互的完整延迟。如果你的心智模型里还是「FID」,请更新它 —— FID 已被弃用,不再上报。
本指南假设你能读代码、能上线改动。我们会逐个指标讲解,理清真正重要的数据来源,然后走一遍可以在任意页面上复用的具体调试循环。
三项指标
每个指标都有三档阈值区间。Google 评估的是真实用户的第 75 百分位 —— 所以你需要四次页面加载里有三次跨过「良好」线,而不是看你开发机上的中位数体验。
| 指标 | 衡量什么 | 良好 | 需要改进 | 较差 |
|---|---|---|---|---|
| LCP(Largest Contentful Paint) | 最大可见元素(首屏大图、标题、视频封面)绘制完成所需的时间 | ≤ 2.5 s | 2.5 – 4.0 s | > 4.0 s |
| INP(Interaction to Next Paint) | 从一次用户交互到下一帧绘制完成的最坏情况延迟 | ≤ 200 ms | 200 – 500 ms | > 500 ms |
| CLS(Cumulative Layout Shift) | 页面生命周期内所有意外布局偏移得分之和 | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
一个便于记忆的方式:LCP 是「东西出来了吗?」,INP 是「我戳它它有反应吗?」,CLS 是「我读着它有没有自己动?」 加载、响应性、视觉稳定性。
🧑💻 开发者视角:CLS 是无量纲的 —— 它是各偏移窗口内
影响占比 × 距离占比的累加,而不是毫秒数。视口顶部的一次大偏移就可能耗光你的整个预算,而首屏以下的许多微小偏移可能几乎不计入。
字段数据 vs 实验室数据
这个区分坑过的团队,比任何单项优化都多。测量有两种,它们回答的是不同的问题。
**实验室数据(Lab data)**来自受控环境下的合成运行 —— Lighthouse、WebPageTest 或 Performance 面板。一台设备、一份网络配置、一次冷加载。它可复现、非常适合调试,但它是一个样本,而且无法正确测量 INP,因为 INP 需要真实的人在一次真实会话里点击真实的东西。Lighthouse 用 **Total Blocking Time(TBT)**作为响应性的实验室代理指标。
**字段数据(Field data)**才是 Google 真正用来排名的依据。它来自 Chrome 用户体验报告(CrUX) —— 来自已选择上报的真实 Chrome 用户的匿名、聚合指标,按滚动的 28 天窗口分桶。这就是行星级规模的「RUM」(真实用户监测)。
| 实验室(Lighthouse) | 字段(CrUX / RUM) | |
|---|---|---|
| 来源 | 合成单次运行 | 真实用户,28 天窗口 |
| 支持 INP | 否(用 TBT 代理) | 是 |
| 用于排名 | 否 | 是 |
| 适合做 | 调试、CI 门禁 | 真相基准、确定优先级 |
| 波动性 | 低(受控) | 高(真实设备/网络) |
⚠️ 注意:Lighthouse 跑出绿色分数,并不意味着你的 Core Web Vitals 就是「良好」。Lighthouse 跑在一台经过限速但很干净的模拟设备上;而你的真实用户里包含网络不稳的中端安卓手机。在宣布胜利前,永远要用字段数据确认。
实操原则:**在实验室里调试,按字段数据判定。**用 Lighthouse 快速找到并修复问题(它是确定性的),但把 Search Console 或 PageSpeed Insights 里的 CrUX 当作修复是否真正生效的唯一真相来源。
修复 LCP
LCP 是最可拆解的指标。把「到 LCP 的时间」拆成四个子阶段,你就能清楚地知道该把力气花在哪里:
| 子阶段 | 此刻在发生什么 | 在一次慢 LCP 中的典型占比 |
|---|---|---|
| TTFB | 服务器返回首字节 | 常常 40%+ |
| 资源加载延迟 | 从 TTFB 到浏览器开始加载 LCP 资源之间的时间 | 最常见的隐性元凶 |
| 资源加载时间 | 下载 LCP 图片/字体/视频 | 受网络制约 |
| 元素渲染延迟 | 从资源到达到它被绘制之间的时间 | 被 CSS/JS 阻塞 |
理想的 LCP 会让加载延迟接近于零 —— 浏览器应当几乎立刻就发现并开始抓取 LCP 资源。
**1. 削减 TTFB。**这是服务端的事:缓存、CDN 边缘投递,以及避免阻塞渲染的源站工作。对于由 CDN 投递的静态站点,TTFB 应当是两位数毫秒。如果是几百毫秒,那你在碰任何一张图片之前,先有一个缓存或源站问题。
**2. 预加载 LCP 资源。**经典的加载延迟 bug:LCP 图片在 CSS 中被引用、或由 JS 注入,于是浏览器很晚才发现它。让它在初始 HTML 中即可被发现,并提示其优先级:
<!-- Preload + high priority so the browser fetches it immediately -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<!-- Or directly on the img — fetchpriority avoids a separate preload -->
<img src="/hero.avif" fetchpriority="high" alt="Product hero" width="1280" height="720" />
**3. 优化图片本身。**提供现代格式(AVIF/WebP),按实际渲染尺寸调整大小,并使用响应式 srcset。永远不要懒加载你的 LCP 图片 —— 在首屏大图上写 loading="lazy" 是自找的 LCP 伤口,因为它把最重要的那次绘制推迟了。
<img
src="/hero-800.avif"
srcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1600.avif 1600w"
sizes="(max-width: 600px) 100vw, 800px"
fetchpriority="high"
alt="Product hero"
width="800" height="450" />
**4. 处理好字体。**如果你的 LCP 元素是文本(一个大标题),一个阻塞渲染的网络字体会延迟渲染。预加载该字体,使用 font-display: swap(或 optional),并自托管以省去一次第三方连接。
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap; /* render text immediately, swap when font loads */
}
💡 提示:在优化之前,先确认哪个元素是 LCP。在 DevTools 里,Performance 面板会明确标出它,PageSpeed Insights 也会把它点名。优化错了元素,是性能工作中最常见的、白白浪费掉的一个下午。
修复 INP
INP 是一个主线程问题。当用户点击时,浏览器需要运行你的事件处理器、重新计算样式和布局,然后绘制下一帧。如果主线程正忙 —— 在跑一个长任务 —— 这些都无法发生,交互就会感觉卡死。
单点杠杆最大的动作是拆分长任务(任何超过 50 ms 的都会阻塞主线程)。三种技巧,从粗放到精细:
**1. 让出主线程。**在各块工作之间,让浏览器处理待处理的输入。现代原语是 scheduler.yield();setTimeout(0) 是兜底方案。
async function processLargeList(items) {
for (const item of items) {
doExpensiveWork(item);
// Yield so a pending click can be processed mid-loop
if (navigator.scheduling?.isInputPending?.()) {
await scheduler.yield(); // falls back to setTimeout in older browsers
}
}
}
**2. 把视觉响应与繁重工作解耦。**先绘制用户期待的反馈,再把昂贵的计算延后,让它不阻塞下一次绘制:
button.addEventListener("click", () => {
// 1. Immediate visual feedback — runs before the next paint
button.classList.add("is-loading");
// 2. Defer the heavy work past the paint
requestAnimationFrame(() => {
setTimeout(() => runExpensiveUpdate(), 0);
});
});
**3. 避免不必要的重渲染。**在组件框架里,一次点击触发一连串重渲染,是经典的 INP 杀手。做记忆化(memoize)、对高频处理器做防抖(debounce),并让状态更新保持局部:
// React: debounce a search-as-you-type handler so each keystroke
// doesn't trigger a full filter + re-render on the critical path
const onChange = useMemo(
() => debounce((q) => setQuery(q), 150),
[]
);
其他靠谱的收益:把 CPU 密集型工作(解析、图像处理)挪进 Web Worker;削减霸占主线程的第三方脚本;并避免在处理器内同步读取布局(先读 offsetHeight 再写样式会强制「布局抖动」,即 layout thrashing)。
🧑💻 开发者视角:INP 衡量的是最差的那次交互,而不是平均值。在一个原本很跟手的页面上,一次缓慢的弹窗打开就可能拖垮你的字段分数。去剖析用户实际最常做的交互 —— 菜单切换、搜索、加入购物车 —— 而不是只盯着页面加载。
修复 CLS
CLS 几乎总是由「布局之后才加载、并把已有内容推开」的内容引起的。修复的核心是提前预留空间。
**1. 永远给图片和视频设定尺寸。**width/height 属性(或一个 CSS aspect-ratio)能让浏览器在图片到达之前先预留好这个盒子:
<img src="/photo.avif" width="800" height="450" alt="..." />
.media { aspect-ratio: 16 / 9; width: 100%; }
**2. 为广告、嵌入内容和 iframe 预留空间。**动态注入的广告位是内容类网站上第一大 CLS 来源。给容器一个与最常见广告位尺寸相匹配的固定 min-height,这样广告填入时页面就不会跳动。
.ad-slot { min-height: 280px; } /* reserve before the ad loads */
**3. 永远不要在已有内容上方插入内容。**横幅、Cookie 提示,以及「你有一条新消息」这类把页面往下推的提示条,是最严重的违规者。用浮层(fixed/absolute 定位)覆盖它们,而不是把它们插进文档流,或者从一开始就为它们预留空间。
**4. 驯服字体切换。**一次字体切换会改变文本度量,可能让它下方的一切发生偏移。把 font-display: swap 与兜底 @font-face 上的 size-adjust、ascent-override、descent-override 描述符配合使用,让兜底字体与网络字体占据相同的空间:
@font-face {
font-family: "Inter-fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
}
⚠️ 注意:CLS 只计入意外的偏移。发生在一次用户交互后 500 ms 以内的偏移(例如展开一个折叠面板)会被排除。所以你不必畏惧每一个动画 —— 只需提防用户没有要求的移动。
一套调试工作流
这是一个可重复的循环。从「便宜且宽泛」走向「深入且具体」。
**第 1 步 —— 用字段数据做分诊。**打开 PageSpeed Insights(或 Search Console 的 Core Web Vitals 报告),先读字段部分。它会告诉你在第 75 百分位、在哪一类设备上(通常移动端先垮)真正失败的是哪个指标。不要去优化没坏的东西。
**第 2 步 —— 在实验室里复现。**运行 Lighthouse(DevTools → Lighthouse,开启移动端 + 限速),得到一次确定性的、可调试的运行,附带具体诊断(「Largest Contentful Paint element」「Avoid large layout shifts」「Reduce unused JavaScript」)。
第 3 步 —— 在 DevTools 里剖析具体指标。
- 对于 LCP/CLS:Performance 面板会录制一段 trace。它会标出 LCP 元素,并用一条红色的「Layout Shift」轨道标记每一次布局偏移 —— 点击其中一个,即可看到究竟是哪个节点移动了。
- 对于 INP:启用 interaction 轨道,在页面上四处点击,然后检查最长的那次交互。DevTools 会把它拆成 input delay(输入延迟)、processing time(处理时间)和 presentation delay(呈现延迟),让你知道问题出在主线程繁忙(input delay)还是处理器缓慢(processing)。
**第 4 步 —— 用 web-vitals 库做埋点。**实验室数据无法捕捉真实的 INP。引入官方库,从你自己的用户那里收集字段指标,并用信标(beacon)发往你的分析系统:
import { onLCP, onINP, onCLS } from "web-vitals";
function send(metric) {
navigator.sendBeacon(
"/analytics",
JSON.stringify({ name: metric.name, value: metric.value, id: metric.id })
);
}
onLCP(send);
onINP(send);
onCLS(send);
每个指标对象都带有一份归因(attribution)构建(web-vitals/attribution),它会告诉你违规的元素或最大的偏移来源 —— 这样你的 RUM 数据就能直指修复点,而不只是一个数字。
**第 5 步 —— 在字段数据中验证。**上线之后,等。CrUX 使用滚动的 28 天窗口,所以字段层面的改善需要数周才能完全反映出来。你自己的 web-vitals 信标会在几天内显示出变化 —— 用它们做快速反馈,等窗口追上后再对照 PageSpeed Insights 确认。
关联之处
Core Web Vitals 是你在构建站点时所做决策的下游产物 —— 你的渲染策略、资源管线、字体加载和托管,共同决定了这些数字能好到什么程度的天花板。事后再去优化它们,比一开始就把它们内建进去要难得多。
关于这些基础 —— 如何从一开始就构建一个快速、可被抓取的站点 —— 请继续阅读站点构建层。那一层涵盖了让命中这些阈值成为默认结果、而非一场苦战的架构选择(静态渲染、图片管线、CDN 投递)。
关键要点
- ✅ 三项指标,三个问题:LCP(东西出来了吗?)、INP(它有反应吗?)、CLS(它动了吗?)。目标:在第 75 百分位达到 ≤ 2.5 s、≤ 200 ms、≤ 0.1。
- ✅ INP 已在 2024 年取代 FID —— 衡量整个会话中完整的交互延迟,而不只是首次输入。
- ✅ **在实验室里调试,按字段数据判定。**绿色的 Lighthouse 分数不等于及格的 CrUX 分数。
- ✅ 通过拆解来修复 LCP:削减 TTFB,用
preload+fetchpriority干掉加载延迟,并且永远不要懒加载首屏大图。 - ✅ 通过拆分长任务来修复 INP —— 用
scheduler.yield()让出主线程、先绘制反馈,并削减无谓的重渲染。 - ✅ 通过提前预留空间来修复 CLS:图片尺寸、广告位
min-height、用浮层覆盖(而非插入)横幅,以及一个度量相匹配的兜底字体。