Core Web Vitals 实战:LCP、INP、CLS 的定位与优化路径
本文目录
性能优化最大的浪费,是在错误的数据上优化错误的问题:本地 Lighthouse 跑了 98 分,线上真实用户的 LCP 却是 4 秒,因为真实用户用的是四年前的安卓机加 4G 网络。这篇文章把 Core Web Vitals 当成一条完整的工作流来讲:先分清实验室数据和真实用户数据,再按指标逐个定位到具体资源和具体代码,最后用性能预算和持续监控把优化成果锁住。
三大指标与阈值
Core Web Vitals 目前包含三个指标,衡量维度完全不同,优化手段也几乎不重叠:
| 指标 | 衡量什么 | 良好 | 需改进 | 较差 |
|---|---|---|---|---|
| LCP(最大内容绘制) | 最大内容元素渲染完成的时刻,代表"主要内容多快可见" | ≤ 2.5s | ≤ 4s | > 4s |
| INP(交互到下次绘制) | 页面生命周期内交互响应的最差情况,代表"点下去多快有反应" | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS(累积布局偏移) | 意外布局偏移的累积分数,代表"页面会不会跳" | ≤ 0.1 | ≤ 0.25 | > 0.25 |
三个细节直接决定你的判断是否成立:
- 评估口径是第 75 百分位。Core Web Vitals 评估的不是平均值,而是所有页面访问的第 75 百分位——也就是说,有四分之一的用户比这个值更差。平均值好看没有意义。
- 粒度是页面访问(pageview),不是会话。一个用户连续浏览五个页面,就是五次样本。
- INP 在 2024 年 3 月正式取代 FID。FID 只测第一次交互的输入延迟,而 INP 覆盖整个页面生命周期内所有交互的完整耗时(输入延迟 + 事件处理 + 绘制延迟),所以老文章里的 FID 优化思路只能部分迁移。
Lab 数据与 Field 数据:先分清再动手
生产环境最常见的误区,就是拿实验室数据当真实结论。两者的定位完全不同:
| 维度 | Lab 数据(实验室) | Field 数据(真实用户) |
|---|---|---|
| 来源 | Lighthouse、DevTools Performance 面板、CI 环境 | CrUX 报告、自有 RUM 上报 |
| 环境 | 固定设备、固定网络、无真实用户 | 千差万别的设备、网络、缓存状态 |
| 强项 | 可复现、可定位、适合 CI 卡口 | 代表真实体验、能分设备/地区/版本下钻 |
| 弱点 | 与真实分布脱节 | 单条数据噪音大,只能看聚合 |
实际工作流应该是:Field 数据告诉你"有没有问题、问题在哪类用户身上",Lab 数据告诉你"问题出在哪行代码"。PageSpeed Insights 的价值在于一页同时给出两者:上半部分是 CrUX 真实用户数据,下半部分是 Lighthouse 实验室分析。
只靠 Lab 优化的典型翻车:本地模拟 4G 压缩后脚本加载很快,但真实用户里中低端机占七成,解析执行时间翻了几倍——这种差异只有 Field 数据能看到。
RUM 上报:web-vitals 库
CrUX 数据按域名和热门 URL 聚合,有滞后(按周期聚合),而且覆盖不了你关心的业务维度(版本、渠道、AB 实验)。所以生产环境通常自建 RUM,用 Google 官方的 web-vitals 库采集:
import { onCLS, onINP, onLCP } from 'web-vitals/attribution';
function send(metric: { name: string; value: number; id: string; rating: string }) {
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
id: metric.id, // 本次上报的唯一 ID,用于服务端聚合去重
page: location.pathname,
referrer: document.referrer || 'direct',
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
}
}
onCLS(send);
onINP(send);
onLCP(send);
三个实践要点:
- 用
web-vitals/attribution子路径而不是主入口。attribution 版本会把 LCP 的四个子阶段、INP 交互的目标元素等归因信息一起带出来,定位问题靠的就是这些字段。 - 在
visibilitychange为 hidden 时上报,不要等pagehide(移动端不可靠),更不要用unload。LCP/CLS 都可能在页面隐藏后停止变化,隐藏时上报的值就是终值。 - 上报量要控制。每个指标每次变化只报一次终值即可,一条几百字节,别在滚动过程里反复报。
LCP:从"哪个元素"到"哪一段耗时"
优化 LCP 前必须先回答两个问题:最大内容元素是哪个?时间花在了哪一段?LCP 的总时间可以拆成四段,attributon 版 web-vitals 会直接给出各段数值:
| 子阶段 | 含义 | 常见元凶 |
|---|---|---|
| TTFB | 请求 HTML 到收到首字节 | 服务端渲染慢、重定向链、无 CDN |
| 资源加载延迟 | HTML 里能开始下载 LCP 资源,到实际开始下载 | preload 缺失、图片优先级被排到后面、懒加载误用 |
| 资源加载耗时 | LCP 图片开始下载到下载完成 | 图片体积过大、图片服务器慢、未走 CDN |
| 元素渲染延迟 | 资源就绪到实际渲染 | 主线程被脚本长任务占住、LCP 元素由 JS 客户端渲染 |
上图是一个典型的"图片型 LCP"问题画像(数据为示意):资源加载耗时占了大头,说明优化方向是图片本身;如果资源加载延迟异常大,说明是优先级问题;如果元素渲染延迟大,说明是主线程被 JS 阻塞。先看分布再动手,不要上来就压图片。
优先级与预加载
图片默认优先级是 low——浏览器认为视口内的图片才可能马上需要,而首屏 banner 图经常因为不确定是否在视口内而拿不到高优先级。两个工具:
<!-- 方式一:直接给 LCP 图片提权 -->
<img src="/hero.avif" alt="首页首图" fetchpriority="high" width="1600" height="900">
<!-- 方式二:用 preload 抢跑(响应式场景用 imagesrcset) -->
<link rel="preload" as="image" href="/hero.avif"
imagesrcset="/hero-640.avif 640w, /hero-1280.avif 1280w"
imagesizes="100vw" fetchpriority="high">
规则:
- LCP 图片永远不要加
loading="lazy"。懒加载会把下载推迟到布局确认之后,凭空多出一段资源加载延迟,这是生产环境反复出现的"改一行毁首屏"事故。 - preload 和
fetchpriority可以一起用:preload 负责"提前发现",fetchpriority 负责"优先下载"。 - preload 的 URL 必须和实际渲染用的 URL 逐字符一致(含参数),否则会预载两次,带宽翻倍还拖慢首屏。
图片本身与网络路径
- 尺寸匹配:1600px 宽的屏幕不要下发 3000px 的图。用响应式
srcset/sizes让浏览器按视口和 DPR 选档。 - 格式:WebP/AVIF 相比 JPEG 通常能显著减小体积,AVIF 在新浏览器可用、老浏览器回退(详见站内图片管线一文)。
- CDN 与缓存:静态资源走 CDN 边缘节点,把 TTFB 和下载耗时从"跨运营商链路"压到"就近节点"。HTML 与带 hash 的资源要分开设置缓存策略。
渲染路径
如果 LCP 元素是 JS 客户端渲染出来的(SPA 典型),前面所有图片优化都救不了它——时间必然包含"下载 + 解析 + 执行框架 + 请求接口 + 渲染"全链路。这类站点的 LCP 优化本质是首屏优化:SSR/SSG 输出首屏 HTML,或者至少把 LCP 内容用骨架占位提前可见。可参考另一篇首屏优化的完整清单。
INP:长任务的拆分与让出主线程
INP 差的页面几乎都有一个共同特征:点击发生后,主线程被一段长任务占住,用户看到的是"点了没反应"。单个任务超过 50ms 就算长任务,而一次交互的期望响应是 200ms 内,留给你的余量很少。
定位用 DevTools Performance 面板:录制一段点击操作,主线程轨道上红色斜纹块就是长任务,点开能看到是哪个函数栈。新版 Chrome 还有 Performance insights 面板,会直接标出"长任务导致交互延迟"。
让出主线程:yield 的写法
任务太长就拆,拆的标准是"每处理一小批就检查有没有输入等着进来":
// 首选:scheduler.yield(较新版本 Chrome 支持),让出后继续排在队首,
// 不会被其他已排队任务插队,保证拆分后仍按原顺序完成
async function processItems(items: Item[]): Promise<void> {
const yieldToUI = () =>
'yield' in navigator.scheduling
? navigator.scheduling.yield()
: new Promise<void>((r) => setTimeout(r, 0)); // 老浏览器回退
for (let i = 0; i < items.length; i += 100) {
renderBatch(items.slice(i, i + 100));
if (i + 100 < items.length) await yieldToUI();
}
}
回退到 setTimeout 的代价是:让出后你的后续任务排在任务队列尾部,可能被其他任务插队,拆分后的总时长变长但交互能插进来——这正是 INP 需要的取舍。
更彻底的方案:搬离主线程
- 纯计算(排序、过滤大数组、解析大 JSON、图片处理)搬到 Web Worker,主线程只收结果,Worker 方案见站内文章。
- 大列表渲染用虚拟列表控制 DOM 数量,减少每次交互触发的样式计算与布局。
- 第三方脚本(统计、客服、A/B)用
scheduler.postTask或队列控制执行时机,不要让它们和你的关键交互抢主线程。
别忽略的输入延迟
INP 的前半段是"输入延迟"——点击发生到事件处理器开始执行的等待。如果点击时主线程正忙(比如正好在跑一段和这次点击无关的长任务),这段时间也计入 INP。所以优化 INP 不只是优化事件处理器本身,还要消除页面上所有常驻长任务:启动阶段的重活延后、埋点批量上报别在交互高峰期同步执行。
CLS:把布局位置提前留出来
CLS 的计算是"影响分数 × 距离分数"的累加,只统计非用户触发的偏移(用户点了按钮展开面板不算)。生产环境四大来源按出现频率排:图片/广告/嵌入块尺寸未知、网络字体切换、接口返回后插入内容、动画用错了属性。
图片与嵌入块:尺寸永远提前声明
/* 固定宽高比占位:容器先占住空间,图片加载完成只是填充 */
.media {
aspect-ratio: 16 / 9;
width: 100%;
background: #eee; /* 占位底色,避免白块闪烁 */
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
<!-- 原生属性同样有效,老浏览器靠 CSS 兜底 -->
<img src="/cover.avif" alt="封面" width="800" height="450" loading="lazy">
声明宽高有两个作用:一是现代浏览器据此计算宽高比预留空间(即使 CSS 把图片改成流式宽度);二是配合懒加载不会在图片进入视口后把下方内容推走。广告位、第三方 iframe 嵌入同理——宁可预留一块固定高度的空区域,也不要让内容塌下去再弹回来。
字体:fallback 与最终字体指标对齐
font-display: swap 让文字先以回退字体显示、不挡 LCP,但字体切换时字形宽度不同会把后续内容顶开。缓解办法是调整回退字体的度量,让两种字体占位尽量接近:
@font-face {
font-family: 'BodyFallback';
/* 把系统字体拉伸到与正文网络字体相近的行高与宽度 */
src: local('Arial');
ascent-override: 0.92;
descent-override: 0.24;
size-adjust: 98%;
}
:root {
--font-body: 'BodyWeb', BodyFallback, sans-serif;
}
另外两点:字体文件用 preload 提前下载(注意字体必须带 crossorigin 属性,否则预载两次);能裁剪字重和字符集就裁剪,字体越小切换窗口越短。
插入内容与动画
- 接口数据回来后往列表头部插内容,会把已有内容整体推下移。要么预留高度,要么插到底部,要么把新内容浮层化。
- 动画永远动画
transform/opacity,不要动画top/left/width/height——后者每帧触发布局,如果布局影响兄弟元素就产生偏移,还顺带拖垮 INP。
性能预算与持续监控
优化完不锁住,三个迭代就会回退。锁的手段是性能预算:把指标写进 CI,把趋势接进看板。
| 预算项 | 示例阈值(需按自身基线定) | 卡点位置 |
|---|---|---|
| LCP p75 | ≤ 2.5s | RUM 看板告警 |
| INP p75 | ≤ 200ms | RUM 看板告警 |
| 首屏 JS(gzip) | ≤ 200KB | CI 构建检查 |
| LCP 图片体积 | ≤ 100KB | CI 构建检查 |
| 单资源缓存命中率 | ≥ 90% | CDN 看板 |
CI 侧用 Lighthouse CI 或自写脚本,对预发环境跑分并对比预算;构建侧把 bundle 体积预算写进构建工具配置:
{
"budgets": [
{ "type": "initial", "maximumWarning": "170kb", "maximumError": "200kb" },
{ "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" }
]
}
RUM 侧看两个东西:按版本分组的 p75 曲线(新版本上线后指标有没有跳变)和按版本分组的 poor 占比(用户体验有没有恶化)。告警阈值基于自身历史基线定,不要照抄别家的数字。
注意事项与边界
- web-vitals 库测的是自己域名下的页面,第三方 iframe 里的指标不在你的采集范围内,但 CrUX 评估包含整页体验。
- LCP 会忽略用户交互之后的绘制变化,且横幅轮播图的第一张才算候选,后续切换不计入。
- INP 在低交互页面可能一直取不到值(没有交互就没有样本),看板要区分"无数据"和"为 0"。
- CrUX 只覆盖 Chrome 用户且需要足够流量才有数据,小站拿不到 CrUX,只能靠自建 RUM。
- A/B 实验两组指标对比时,先确认样本量和分组哈希一致,再下结论。
- 性能优化的结论必须可复现:改一处、跑一次 Lab、看一次趋势,改三处再看一次,归因就是猜。
总结
Core Web Vitals 的优化路径可以压缩成三句话:用 Field 数据定位问题面,用 attribution 子阶段和 DevTools 定位问题点,用预算和 CI 锁住成果。LCP 优化的核心是让最大资源尽早开始下载且尽快可见;INP 优化的核心是消灭主线程长任务并让出控制权;CLS 优化的核心是把一切未来的尺寸提前声明。三个指标对应三套完全不同的代码改动,先测量再动手,是唯一的捷径。
参考
- web.dev: Core Web Vitals
- web.dev: 优化 LCP
- web.dev: 优化 INP
- MDN: performance — 长任务与性能时间线
- MDN: fetchpriority 属性
- 站内参考:前端埋点与数据监控体系(RUM 上报通道的队列与 sendBeacon 细节)
- 站内参考:Web Worker 实战(长任务卸载到 Worker 的完整做法)
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。