LCP、CLS、INP 各自在量什么:把 Core Web Vitals 拆到能下手的部件

为什么 Core Web Vitals 不能只看一个总分

很多站长第一次打开 PageSpeed Insights,看到那个红色的数字就开始焦虑,然后照着报告里列出的十几条建议一条条改:压缩图片、消除阻塞资源、减少未使用的 CSS……改了两天再测,分数从 42 涨到 58,又过一周掉回 51。整个过程里其实没人回答一个更基础的问题:这三个指标分别量的是什么,它们为什么会互相牵制,以及我改的那一条到底动了哪一个指标的哪一部分。

Core Web Vitals 从 2021 年起成为 Google 页面体验的正式组成部分,2024 年 3 月 INP 正式替换了 FID。它由三个指标组成:LCP 衡量加载、CLS 衡量视觉稳定、INP 衡量交互响应。这三个数不在同一个时间轴上,成因也几乎完全不相交。把它们当作一个可以整体优化的分数来处理,是绝大多数「越优化越乱」的起点。

这篇文章不谈玄学,只做一件事:把三个指标各自拆到「可以下手的部件」这一层,然后给出一套能在一台普通 VPS 上落地的测量与验证方法。全文的经验值都来自中小型个人站的常见形态:单机 Nginx + PHP-FPM + MySQL,前后端不做前后端分离,页面 HTML 由服务端渲染。

LCP:四个候选部件,一个时间点

LCP 的全称是 Largest Contentful Paint,它测量的是「视口内最大的那块内容元素完成渲染的时间」。浏览器在页面加载过程中会不断更新候选元素,直到用户第一次交互或页面被隐藏。最终报出来的那一个时间点,是最后一次候选元素绘制完成的时刻。

关键在「候选元素」这四个字。LCP 的候选类型只有四类:<img> 元素、<image>(SVG 内部用的那个)、视频的封面帧(poster)、以及带文字的块级元素(包括 <p>、<h1>、<div> 等)。CSS 背景图不算,<canvas> 不算,伪元素里的图不算。这一点非常重要:如果你的首屏大图是用 background-image 铺上去的,它根本不参与 LCP 计算,你花时间优化它,指标上一分不动。

所以定位 LCP 的第一步不是压缩图片,而是确认「到底哪个元素是 LCP 元素」。Chrome DevTools 的 Performance 面板在录制后,会把 LCP 标记在时间轴上,点开能直接看到元素选择器;也可以在控制台挂一个 PerformanceObserver:

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1];
  console.log(last.element, last.startTime, last.size);
}).observe({ type: 'largest-contentful-paint', buffered: true });

拿到元素之后,才好判断这个时间点被什么撑大了。LCP 的耗时可以粗分成四段,虽然浏览器不会把这个拆分直接报给你,但每一段都有各自的排查手段:

第一段:服务器响应(TTFB)

用户的浏览器发出 HTML 请求,到第一个字节到达的时间。这一段完全由后端决定:PHP 执行时间、数据库查询、缓存命中率。如果你发现 LCP 时间是 3.2 秒而 TTFB 就占了 1.5 秒,那么无论前端怎么改都不可能把 LCP 压到 2.5 秒以内。这一段是硬门槛。

实测方法很直接。用 curl 只取 time_starttransfer 这一个时间点,输出重定向到 /dev/null 丢弃响应体,同一条命令连续跑十次取中位数,就是这台机器到用户的 TTFB 基线。注意不要在自己电脑上跑完就下结论,本地网络到机房的往返延迟和真实用户差异很大,有条件应该从和用户相近的地区测,或者干脆读真实用户数据(CrUX)。

for i in $(seq 1 10); do
  curl -o /dev/null -s -w '%{time_starttransfer} %{http_code}\n' https://example.com/
done

第二段:资源加载延迟

HTML 已经解析到 LCP 元素那个标签,但资源(图片本体)还没下载完。这一段受三件事影响:资源发现时机、资源大小、资源优先级。

资源发现时机经常是最被忽视的一项。如果 LCP 图片写在首屏 HTML 里 <img src="...">,浏览器在解析到那行时就能发出请求,很快。但如果这张图是通过 JS 动态插入的,或者伪装成 CSS 背景图由某个样式表间接引用,那么浏览器必须先把 JS 下载、解析、执行完,才知道有这么一张图。我见过一个站点首屏大图是轮播组件的第一帧,写在 data-src 里等 JS 启动后填入,结果 LCP 时间里有 1.6 秒花在「等 JS 到货」上。改法不是优化图片,而是把第一帧改成真 <img> 直接写在 HTML 里,JS 只在之后接管轮播。

优先级治理有两把趁手的工具。fetchpriority="high" 告诉浏览器这张图很关键,值得早点给带宽:

<img src="/images/hero.avif" width="1200" height="600"
     fetchpriority="high" alt="首屏大图">

另一个是 preload,放在 <head> 里提前发起请求。但 preload 是一把双刃剑:它只是把请求往前挪,并不会提高带宽优先级的上限,还会挤占其他关键资源的带宽。判断要不要用,就看这张图是不是真的在 HTML 解析早期就被发现——如果本来就在 HTML 前 20KB 内,preload 收益很小;如果是 CSS 里引用的背景图、或者字体文件,preload 才有明显价值。

第三段:渲染阻塞

资源下好了,但浏览器还不能把它画出来,因为主线程在忙别的事:解析同步脚本、执行大段内联 JS、计算复杂样式。这一段的表现是「Network 面板早就看到 200,但 Paint 迟迟不出现」。

最典型的元凶是 <head> 里没有 defer / async 的脚本。第三方统计、广告、评论组件这类代码通常有几百 KB,只要它们在 <head> 里同步加载,整个首屏都得等它们。改法是把非关键脚本挪到 </body> 前并加 defer,把确实需要先跑的(比如主题设置、防闪烁脚本)压到 2KB 以内内联。

样式表这边,关键 CSS 可以内联进 <head> 让首屏不必等外部样式表,其余部分异步加载。但内联也有代价:HTML 变大、无法缓存、每次请求都要重传。对个人站来说,如果全站样式表总共不到 30KB 且经 gzip 后不到 10KB,内联关键部分往往得不偿失,直接让浏览器缓存整份更快。

第四段:绘制本身

图像解码和绘制。这一段通常只占几十到一两百毫秒,但如果图片尺寸远超展示尺寸(比如 4000px 宽的图在一个 800px 宽的容器里显示),解码开销会明显变大。给 <img> 写对 width / height 属性并配合 srcset 提供合适尺寸,是这一段唯一的常规优化。

CLS:位移从哪里来,为什么它不是「抖动」问题

CLS 衡量的是「元素在没有被用户交互触发的情况下,位置发生不可预期的变化」的总量。注意这里有两个关键词:无预期、用户未触发。用户点开折叠面板导致内容下移不算 CLS,那是用户自己触发的;广告晚到把正文挤下去,那就是典型的 CLS。

CLS 的计算公式是「影响区域比例 × 位移距离比例」,两者相乘累加。这意味着一个占满整个视口宽度的元素移动 10%,和一个占视口 10% 宽度的元素移动满屏,贡献是一样的。

来源一:没有尺寸的图片和 iframe

这是最常见、也最容易修的一类。图片没有声明 width / height,浏览器下载前不知道它多大,先按 0 高度排版,图片到了之后撑开,下面的内容全部下移。

/* 现代浏览器支持 aspect-ratio,比 padding-top 百分比法更直观 */
.hero img { width: 100%; height: auto; aspect-ratio: 16 / 9; }

写 width / height 属性加上 max-width:100%; height:auto 的样式,浏览器就能依据属性比例提前预留空间。iframe 同理,嵌入视频、地图、第三方组件一律给固定高度。

来源二:字体切换

自定义字体加载前用后备字体渲染,加载完成后整页重排,文字块宽度全变。表现是「打开页面文字先闪一下,然后整体挪位」。修法有两种方向:一是用 font-display: optional,如果在极短时间内没加载到就本次不用,下次访问再从缓存走,用户完全看不到切换;二是用 size-adjust 和 ascent-override 调整后备字体的度量,让两者行高和字宽尽量接近,切换时位移小到看不出来。

对中文站点尤其要注意:一个中文字体少说 3MB,无论怎样优化延迟都不可能为零。所以中文站首屏文字用系统字体(font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif)几乎是唯一正确的选择,只在极少数装饰性标题上考虑自建字体,并且必须做子集化。

来源三:动态插入的内容

广告位、公告条、推荐位、需要服务端二次请求才能渲染的模块,都属于这一类。修法是在容器上预留最小高度,哪怕内容是空的也占住位置:

.ad-slot { min-height: 250px; background: #f5f5f5; }

广告联盟通常允许你手动指定容器尺寸,把尺寸写死是最稳的做法。如果广告最终没填充,就留一块空白,比内容乱推要好得多——从指标上看,空白完全不产生 CLS,而推挤会产生很大的分数。

CLS 的一条硬性口径

CLS 只统计「持续 500 毫秒以上窗口」内的位移,并且对用户输入后 500 毫秒内的位移不计入。所以有些页面在本地快网下测出来 CLS 为 0,在慢网下却是 0.28——广告在慢网下晚到,正好落在无输入窗口里。测 CLS 一定要用 Network 限速(Slow 4G)而不是光纤直连。

INP:从「首字节到交互」到「真正画出下一帧」

INP 替代了 FID,这个替换的实质是:FID 只测了「输入事件开始处理」的延迟,而 INP 测的是「从用户产生交互到浏览器画出下一次更新」的完整时间,取页面生命周期内(大致)最差的那一次。

换句话说,FID 时代那个 100ms 的绿色指标,在 INP 口径下大部分真实页面都是几百毫秒。这不是网站变慢了,是度量变严了。很多站长在 2024 年发现自己的指标从绿色跳到黄色,第一反应是「是不是被惩罚了」,其实只是口径换了。

INP 的三段拆解

一次交互的 INP 由三部分组成:输入延迟(input delay)、处理时间(processing time)、呈现延迟(presentation delay)。

输入延迟是「用户点了按钮到事件回调真正开始执行」之间的时间。如果主线程正在执行一个 800ms 的长任务,用户的点击就得排队等到任务结束。这一段的名字叫「主线程繁忙度」,是 INP 最常见的瓶颈。

处理时间是你的事件处理代码本身的执行时长。常见问题包括:在点击回调里同步做大量 DOM 操作、同步发起多个计算、遍历上千条数据做筛选、把整个列表重渲染一遍而不是只改一行。

呈现延迟是「回调结束到下一帧画出」的时间。这一段看起来短,但如果你在回调里触发了一次大规模布局(比如读取了 offsetHeight 又写入了样式,形成强制同步布局),浏览器要重排整页,这一段就会显著变长。

定位工具:先看长任务,再看具体回调

Performance 面板录制一次真实交互,然后在时间轴上找红色三角标记(Long Task,超过 50ms 的任务)。每个长任务的调用栈都能展开,直接看到是哪个函数。这比读报告里「减少 JavaScript 执行时间」这种笼统建议有用一百倍。

如果觉得录制麻烦,可以用 PerformanceObserver 抓长任务,只在真正超过阈值时上报,避免污染日志:

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.duration > 200) {
      navigator.sendBeacon('/log/longtask', JSON.stringify({
        d: Math.round(e.duration), n: e.name, t: Date.now()
      }));
    }
  }
}).observe({ type: 'longtask', buffered: true });

这条上报的意义在于:你不需要猜测哪台设备上发生了长任务,用户设备会自己告诉你。收集一周数据,按出现次数排序,前三个函数通常就是全部问题的 80%。

最有效的一类修法:把长任务切碎

不管长任务具体在做什么,「把它切成多个小于 50ms 的片段」几乎是通用的改善手段。浏览器有 scheduler.yield()(新版本 Chrome 支持),老一点的环境用 setTimeout(fn, 0) 或 requestIdleCallback。核心思想是一样的:让出主线程,给输入事件一个插队的机会。

async function processInChunks(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const slice = items.slice(i, i + chunkSize);
    slice.forEach(renderRow);
    await new Promise(r => setTimeout(r, 0)); // 主动让出主线程
  }
}

另一个高频问题是第三方脚本。统计、客服、A/B 测试这些代码不在你的控制之下,但它们跑在你的主线程上。能做的是:延迟加载(等首屏稳定后再注入)、限制数量(只留真正在用的)、以及给它们套上 <iframe> 隔离(部分组件支持)。我见过一个页面同时挂了五个统计 SDK,光是它们的主线程占用就让 INP 从 120ms 变成 460ms 以上。

三个指标会互相牵制,别一起动

这是最容易踩的坑:一次改十处,指标掉了,不知道是哪处改坏的。

常见的牵制关系有这几对。第一,为了改善 LCP 而内联关键 CSS,HTML 变大,TTFB 变差,而 TTFB 又是 LCP 的一部分——收益可能被抵消。第二,为了改善 INP 而把脚本全部 defer,脚本延后执行,某些依赖脚本渲染的内容推迟出现,反而拖后了 LCP 的候选元素。第三,为了改善 CLS 给所有图片写死高度,在响应式布局里图片被裁切或留白,用户观感变差,但指标变好——这种「赢了指标输了体验」的改法要警惕。

正确的做法是严格串行:一次只改一类,改完在自己的测试环境验证,等至少一周的真实用户数据(CrUX 是 28 天滚动窗口,所以至少要看两周趋势才稳),确认稳定后再动下一类。

从哪拿数据:实验室数据与现场数据的分工

PageSpeed Insights 同时给两部分结果。上半部分叫「发现的问题」但实际是实验室数据,来自 Lighthouse 在受控环境里跑的一次测试;下半部分「真实用户数据」来自 CrUX,是过去 28 天真实访问者的第 75 百分位。

两者的判读口径完全不同。实验室数据可复现,适合定位具体技术问题,但它只跑一次、用固定的网络和设备模拟,可能完全不反映你的用户实际情况。现场数据反映真实分布,但不能告诉你「为什么慢」。

所以正确的用法是:先看现场数据判断哪个指标真的不达标,再用实验室数据(或自己录 Profile)定位原因,改完以现场数据验证效果。反过来用——拿实验室分数当 KPI——是很多人陷入「优化分数」泥潭的根本原因。

一个必须知道的口径细节:CrUX 的 p75 是有统计意义的。假如你的 LCP p75 是 2.6 秒,说明有 25% 的访问 LCP 超过 2.6 秒,这 25% 往往是移动端、弱网、老设备用户。如果你的站点流量里移动端占 70%,那么优化移动端的 LCP 几乎是唯一有意义的动作。

另外要注意流量门槛:CrUX 只收录有足够访问量的页面,个人站的大量内页可能根本不进数据集,PSI 里看到的「无数据」不代表没问题,只是样本不够。这时候只能靠自己的 RUM 埋点(上一节那段 PerformanceObserver 就是最小可用的 RUM)。

给个人站的三步落地顺序

如果只能做三件事,按这个顺序做,投入产出比最高。

第一步,量 TTFB。如果 TTFB 超过 800ms,先把后端解决掉——加整页缓存、开 OPcache、查慢查询,这一步的收益通常比其他所有前端优化加起来都大。很多站长跳过这一步直接去压缩图片,结果在 1.5 秒的 TTFB 上面反复挣扎。

第二步,把首屏的 LCP 图片和字体处理干净。确认 LCP 元素是哪个、是不是原生 <img>、有没有尺寸、是不是在 HTML 早期就被发现、格式是否现代。这一步做完 LCP 通常能降 30% 到 50%。

第三步,给所有「会晚到」的容器预留尺寸。广告位、图片、iframe、嵌入卡片,一律写死高度或用 aspect-ratio。这一步做完全站 CLS 基本能落到 0.05 以下。

INP 放到最后,因为它依赖真实用户交互数据,而且对内容型站点来说通常不是瓶颈——除非你挂了大量第三方脚本或做了很重的前端渲染。

最后:指标是手段,不是目标

Core Web Vitals 进入排名信号已经好几年,业界的共识是它是一个「小权重因素」:在所有其他条件相同时,体验更好的页面有微弱优势。它绝不可能把一个内容空洞的页面救上来,也不可能把一篇好文章压下去。

所以合理的态度是:把三个指标都做到绿色(LCP ≤ 2.5s、CLS ≤ 0.1、INP ≤ 200ms),然后停下来,把时间还给内容。追着 95 分往 100 分抠,边际收益趋近于零,而每篇文章多花的两小时写作时间,长期回报远高于那 5 分。

真正值得长期盯的是趋势而不是分数:如果某个指标连续两个月缓慢恶化,说明站点在长出新的问题(新插件、新图、新第三方脚本),这时候才需要动手。平时,让它绿着就好。

Last modification:November 13th, 2026 at 01:25 pm

Leave a Comment