先看实测数据,而不是 Lighthouse 分数。在 Search Console 或 PageSpeed Insights 中找出哪个指标在第 75 百分位未达标、发生在哪类设备上,然后接入 web-vitals 的归因构建,让真实会话告诉你是哪个元素、哪次交互或哪次偏移造成的。LCP 按占比最大的子阶段来修,INP 按慢交互背后的长任务来修,CLS 则为所有会移动的内容预留空间。实验室工具用于复现问题和验证修复,但不能决定你是否通过评估。
三个指标及其阈值
web.dev 上的 Web Vitals 概述列出了三个稳定的 Core Web Vitals:
- Largest Contentful Paint(LCP),衡量加载:2.5 秒及以内为良好,超过 4 秒为差。
- Interaction to Next Paint(INP),衡量响应性:200 毫秒及以内为良好,超过 500 毫秒为差。
- Cumulative Layout Shift(CLS),衡量视觉稳定性:0.1 及以内为良好,超过 0.25 为差。
介于两者之间的值被评为“需要改进”。阈值针对页面加载的第 75 百分位,并分别按移动设备和桌面设备统计;只有三个指标在该百分位都达到良好,页面才算通过。看起来不错的中位数可能掩盖了来自慢速手机的那四分之一访问,而评估机制正是为了发现它们。
INP 在 2024 年取代了 First Input Delay。它在页面整个生命周期内观察点击、触摸和按键,并把每次交互测量到下一帧绘制为止。悬停、缩放和滚动不计入;每 50 次交互会忽略其中最慢的一次,因此单个异常值不会决定最终分数。
web-vitals README 说明 onLCP() 和 onINP() 可在 Chromium、Firefox 和 Safari 中运行,而 onCLS() 仅支持 Chromium。MDN 兼容性数据显示,Safari 在 26.2 版本中加入了 LCP 和 Event Timing API。Google 的实测数据集仍然只来自 Chrome 用户。
实测数据与实验室数据
实测数据来自真实访问。Chrome UX Report(CrUX)以 28 天滚动窗口汇总符合条件的 Chrome 用户数据,粒度包括来源(origin)和 URL,仅覆盖公开流量足够的页面。PageSpeed Insights 在报告顶部展示这些数据,当某个 URL 数据不足时会回退到来源级数据。Search Console 的 Core Web Vitals 报告使用同一数据源,把相似 URL 分组,以组内最差指标作为该组状态,并分别跟踪移动设备和桌面设备。今天上线的修复,会在接下来四周内逐步反映到这些数字上。
实验室数据来自一次受控加载。Lighthouse 在模拟的设备和网络上加载页面。DevTools 的 Performance 面板会实时显示本地的 LCP、CLS 和 INP,记录每次交互及其阶段、每次布局偏移及其分数,还能拉取 CrUX 数据,并给出与用户环境相近的 CPU 和网络节流建议。
两者出现差异的原因是可以预期的,web.dev 在实验室数据与实测数据为何不同一文中做了说明:
- LCP:实验室测试通常使用冷缓存、单一视口尺寸且没有个性化内容。真实用户可能已缓存资源、看到不同的 LCP 元素或 A/B 测试变体,而且从 bfcache 恢复的页面也计入实测数据。
- INP:Lighthouse 的导航模式运行没有任何交互,因此报告的是 Total Blocking Time。TBT 有助于诊断加载期间的主线程阻塞,但看不到会话后期的一次慢点击。
- CLS:实验室只看到加载期间的偏移。实测 CLS 覆盖页面的整个生命周期,包括没有尺寸的延迟加载内容和较晚出现的广告。
用实测数据判断哪里出了问题,再在实验室里用接近真实的节流条件复现。CrUX API 直接返回第 75 百分位,数据每日更新:
curl -s --request POST \
"https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"origin": "https://example.com",
"formFactor": "PHONE",
"metrics": [
"largest_contentful_paint",
"interaction_to_next_paint",
"cumulative_layout_shift",
"largest_contentful_paint_image_time_to_first_byte",
"largest_contentful_paint_image_resource_load_delay",
"largest_contentful_paint_image_resource_load_duration",
"largest_contentful_paint_image_element_render_delay"
]
}'
最后四个指标是 LCP 元素为图片时的 LCP 子阶段。
用归因构建收集真实用户数据
CrUX 能告诉你某个指标没有达标,却很少告诉你原因。web-vitals 库按照与 Chrome 相同的方式测量指标,它的归因构建还会附带元素、时间分解和文档加载状态。当前的主版本是 6。它可以在 Chromium 151 及更高版本中为单页应用报告 soft navigations,但 CrUX 将如何统计它们尚未确定。
npm install web-vitals
下面的模块为每个指标实例保留最新一条记录,并按 README 的建议,在页面变为隐藏时通过 navigator.sendBeacon() 批量发送:
import {onCLS, onINP, onLCP} from 'web-vitals/attribution';
const queue = new Map();
function toRecord(metric) {
const {name, value, rating, id, attribution} = metric;
const record = {name, value, rating, id, page: metric.navigationURL ?? location.href};
if (name === 'LCP') {
record.target = attribution.target;
record.ttfb = attribution.timeToFirstByte;
record.loadDelay = attribution.resourceLoadDelay;
record.loadDuration = attribution.resourceLoadDuration;
record.renderDelay = attribution.elementRenderDelay;
} else if (name === 'INP') {
record.target = attribution.interactionTarget;
record.inputDelay = attribution.inputDelay;
record.processing = attribution.processingDuration;
record.presentation = attribution.presentationDelay;
record.loadState = attribution.loadState;
record.script = attribution.longestScript?.entry.sourceURL;
} else if (name === 'CLS') {
record.target = attribution.largestShiftTarget;
record.loadState = attribution.loadState;
}
return record;
}
function flush() {
if (queue.size === 0) return;
navigator.sendBeacon('/rum', JSON.stringify([...queue.values()]));
queue.clear();
}
onLCP((metric) => queue.set(metric.id, toRecord(metric)));
onINP((metric) => queue.set(metric.id, toRecord(metric)));
onCLS((metric) => queue.set(metric.id, toRecord(metric)));
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
CLS 和 INP 可能会上报多次,因此收集端应为每个 id 保留最后一个值。如果用户从未交互,就不会有 INP。先按页面模板和设备类型聚合,再按 target 聚合,找出决定 p75 的少数几个元素。你的数字不会与 CrUX 完全一致:其中包含其他浏览器,而且该库看不到 iframe 内部。高流量站点应采样,标签保持低基数,并执行常规的数据保留规则。AI 代理可观测性指南介绍了同样的遥测数据管理原则。
LCP:找出慢的子阶段
LCP 优化指南把 LCP 拆成四个依次发生的子阶段:
- Time to first byte(TTFB):从导航开始到收到 HTML 第一个字节。
- Resource load delay:从 TTFB 到开始请求 LCP 资源。
- Resource load duration:资源本身的下载时间。
- Element render delay:从资源就绪到元素完成绘制。
如果 LCP 元素是文本,中间两个阶段为零。指南给出的参考比例是:TTFB 和资源下载各占约 40%,两个延迟各占不到 10%。按 2.5 秒计算,大约是 1 秒、250 毫秒、1 秒和 250 毫秒。加载延迟大,说明资源发现得太晚;渲染延迟大,说明有东西阻塞了绘制。压缩图片对这两种情况都没有帮助。
真正能改善数字的 LCP 修复
Time to first byte
web.dev 将 0.8 秒及以内的 TTFB 视为良好,超过 1.8 秒视为差。它包括重定向、service worker 启动、DNS、连接与 TLS 握手以及请求本身。去掉重定向链,在 CDN 边缘为匿名流量缓存 HTML,并为文件名带指纹的静态资源设置长期不可变缓存。Nginx 与 Cloudflare 静态缓存指南详细介绍了相关响应头和规则。如果源站是一台小型 VPS,请按照 Linux VPS 加固清单保持其精简,并让边缘节点承接流量。
curl -s -o /dev/null \
-w 'dns %{time_namelookup}\nconnect %{time_connect}\ntls %{time_appconnect}\nttfb %{time_starttransfer}\n' \
https://example.com/
curl -sI https://example.com/ | grep -iE '^(cache-control|age|cf-cache-status):'
Resource load delay
LCP 图片应当与页面最早的一批资源同时开始加载。把它以 <img> 的形式放进初始 HTML,永远不要给它加 loading="lazy",并加上 fetchpriority="high";MDN 显示 Chromium、Firefox 132+ 和 Safari 17.2+ 均支持该属性。最常见的问题是在客户端渲染的首屏大图区域:在打包文件下载、执行并常常还要请求数据之前,浏览器无法请求这张图片。请在服务端或构建时预渲染首屏区域。如果 LCP 图片来自 CSS,就对它进行预加载。
<link rel="preload" as="image" href="/img/hero-bg.avif"
type="image/avif" fetchpriority="high">
<picture>
<source type="image/avif"
srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
sizes="(max-width: 800px) 100vw, 800px">
<img src="/img/hero-800.jpg"
srcset="/img/hero-800.jpg 800w, /img/hero-1600.jpg 1600w"
sizes="(max-width: 800px) 100vw, 800px"
width="800" height="450" alt="Product dashboard"
fetchpriority="high">
</picture>
<link> 只用于 CSS 中的图片,<picture> 用于普通内容。
Resource load duration
少传字节。使用 AVIF 或 WebP 并提供后备格式,让 srcset 和 sizes 按实际显示区域选择文件,并通过 CDN 分发图片,设置足够长的缓存时间,让重复访问无需再次下载。高优先级请求要少,因为每一个都会与 LCP 图片争抢带宽。
Element render delay
如果图片很早到达却很晚才绘制,请检查体积较大的阻塞渲染样式表、<head> 中的同步脚本,以及使用 font-display: block 或 auto 的字体。有些实验工具会在脚本执行完毕前隐藏页面,这类脚本的运行时间会直接加到 LCP 上。把关键 CSS 内联,其余延后加载,并在不依赖客户端 JavaScript 的情况下渲染首屏区域。
INP:找到交互及其背后的长任务
每次交互包含三个阶段。input delay 是事件处理函数开始运行前的等待,通常是因为主线程正在执行其他任务。processing duration 是处理函数本身的执行时间。presentation delay 从处理函数结束持续到下一帧呈现,包括样式计算、布局和绘制。
要把三者放在一起看。如果 input delay 很高,且 loadState 为 dom-interactive 或 dom-content-loaded,说明用户是在页面启动过程中点击的,通常正赶上水合或第三方脚本执行。processing 高,问题在处理函数;presentation 高,通常是 DOM 过大或强制同步布局。在 Chromium 中,来自 Long Animation Frames API 的 longestScript 字段会指出运行时间最长的脚本。
复现时,打开 Performance 面板,应用建议的 CPU 节流,然后在 RUM 中 target 指向的元素上重复该交互。任何超过 50 毫秒的任务都是长任务,排在它后面的交互会继承它剩余的时间。
INP 修复:减少每次交互的工作量
先绘制响应
只做能给出可见反馈的最少工作,让浏览器先绘制,然后再继续。分析上报和自动保存很少需要在下一帧之前完成。长任务优化指南给出了下面这个辅助函数:
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
filterButton.addEventListener('click', async () => {
filterButton.setAttribute('aria-busy', 'true');
await yieldToMain();
const rows = filterRows(allRows, currentQuery());
renderRows(rows);
filterButton.removeAttribute('aria-busy');
await yieldToMain();
sendAnalytics('filter', rows.length);
});
async function processInChunks(items, handleItem, budgetMs = 40) {
let lastYield = performance.now();
for (const item of items) {
handleItem(item);
if (performance.now() - lastYield > budgetMs) {
await yieldToMain();
lastYield = performance.now();
}
}
}
在支持的环境中用 scheduler.yield 让出主线程
scheduler.yield() 会以带优先级的延续形式恢复你的代码,先于队列中其他同类优先级的任务执行。MDN 将其标记为有限可用:Chrome 和 Edge 129+、Firefox 142+ 支持,Safari 不支持。因此必须做特性检测,并以 setTimeout() 作为后备。后备方案同样能拆分任务,只是失去了优先级。web.dev 已不再推荐 isInputPending(),建议无论是否有待处理输入都要让出主线程。分块辅助函数把每个任务控制在 40 毫秒以内,这样循环中途的一次触摸只需等待一个短任务。
水合与孤岛架构
整页水合会在加载期间执行整棵组件树,包括静态内容。此时发生的交互会遇到很长的输入延迟。把静态内容渲染为 HTML,只对交互组件进行水合,并把它们推迟到浏览器空闲或组件进入视口时再执行。在 Astro 中,指令决定每个孤岛何时水合:
---
import SearchBox from '../components/SearchBox.jsx';
import Comments from '../components/Comments.jsx';
import PriceChart from '../components/PriceChart.jsx';
---
<SearchBox client:idle={{ timeout: 2000 }} />
<Comments client:visible={{ rootMargin: "200px" }} />
<PriceChart client:media="(min-width: 960px)" />
没有指令的组件不会发送任何 JavaScript。React Server Components 以及其他框架中的部分水合都追求同样的效果,但要靠测量确认,不能想当然。第三方脚本也要审查:删除没人用的标签,其余在空闲时加载,并在 longestScript 中留意它们的 URL。
Presentation delay
渲染成本随 DOM 规模增长。保持 DOM 精简,对屏幕外的长区块使用 content-visibility: auto,并避免在写入样式后立即读取 offsetHeight,因为这会触发强制同步布局。大数据量的解析或排序可以移到 Web Worker 中。
CLS:预留空间并稳定字体
单次布局偏移的分数等于影响比例乘以距离比例。间隔小于 1 秒的偏移会合并为一个会话窗口,窗口最长 5 秒,CLS 取最大的那个窗口。在触摸或按键后 500 毫秒内发生的偏移不计入。CLS 优化指南列出了常见原因:
- 没有尺寸的媒体:设置
width和height或aspect-ratio,让浏览器提前预留位置。 - 广告、嵌入内容和横幅:用
min-height预留位置,绝不要把晚到的内容插入到用户正在阅读的内容上方。 - 网页字体:使用
font-display: optional,或使用swap并配合通过size-adjust以及 ascent、descent、line-gap 覆盖值调校过的后备字体,同时预加载关键字体。 - 动画:对
transform和opacity做动画,而不是top、left、width或height。合成层上的变换不计入 CLS。 - 前进与后退导航:从 bfcache 恢复时页面已完成加载,不会出现偏移,因此要让页面保持可被 bfcache 缓存。
.ad-slot {
min-height: 250px;
}
@font-face {
font-family: "Brand Sans Fallback";
src: local("Arial");
size-adjust: 104%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}
.toast {
transform: translateY(100%);
transition: transform 200ms ease-out;
}
.toast.is-visible {
transform: translateY(0);
}
这里的覆盖百分比只是示例值。请根据实际字体的度量数据计算,并在浏览器中对比两种字体。
先修什么
从流量最高的 URL 组中未达标的指标入手;除非你的主要受众使用桌面设备,否则先看移动端。在该指标内部,优先处理在 p75 中占比最大的子阶段或交互阶段。先上线成本低的修复:添加 fetchpriority、去掉首屏图片的延迟加载、为图片设置尺寸,只需几分钟;而改用孤岛架构或服务端渲染则需要有计划的迁移。
| 实测信号 | 可能原因 | 首要修复 | 验证方式 |
|---|---|---|---|
| LCP,TTFB 占主导 | HTML 未缓存、重定向 | 边缘缓存、去掉重定向链 | curl 计时 |
| LCP,加载延迟占主导 | 发现太晚、首屏图延迟加载、客户端渲染 | HTML 中的 <img>、fetchpriority |
网络瀑布图 |
| LCP,加载时长占主导 | 图片过大、格式陈旧 | AVIF 或 WebP、srcset、CDN |
lcpResourceEntry |
| LCP,渲染延迟占主导 | 阻塞的 CSS、脚本或字体 | 关键 CSS、延后脚本 | Performance 跟踪 |
| INP,加载期间输入延迟高 | 水合、第三方标签 | 孤岛、延迟水合 | loadState |
| INP,处理时间长 | 事件处理函数过重 | 先绘制、让出、worker | longestScript |
| INP,呈现时间长 | DOM 过大、强制布局 | 精简 DOM、content-visibility |
交互表 |
| 加载期间的 CLS | 媒体无尺寸、字体切换 | 设置尺寸、调校后备字体 | largestShiftTarget |
| 加载之后的 CLS | 晚到内容、布局属性动画 | 预留位置、transform |
布局偏移标签页 |
验证修复并防止回退
每项改动都要确认两次。在实验室中,用基于实测数据的节流条件对比改动前后的 Performance 跟踪,确认目标子阶段确实缩短了。在实测环境中,观察自有 RUM 中受影响模板的 p75,它会在几天内有所反映,再等 CrUX 在 28 天内跟上。之后在 Search Console 中点击“开始跟踪”,启动为期 28 天的验证。
在 CI 中保留 Lighthouse 预算,用来发现新增的阻塞脚本、缺少尺寸的图片或 Total Blocking Time 的突增。它看不到真实交互,所以还要为实测 INP 设置告警,并在 RUM 看板上标注每次发布。
检查清单
- 分别查看 Search Console 和 PageSpeed Insights 中移动端与桌面端的实测数据。
- 记录流量最高的 URL 组在 p75 上未达标的指标。
- 接入
web-vitals的归因构建,并在visibilitychange时发送 beacon。 - 按模板、设备类型和
target聚合 RUM 数据。 - 在选择修复方案之前,先确定占主导的 LCP 子阶段。
- 把首屏图片放进 HTML,加上
fetchpriority="high",不使用延迟加载,并配置正确的srcset。 - 在边缘缓存 HTML,把 TTFB 控制在 0.8 秒以内。
- 用
scheduler.yield()配合setTimeout()后备拆分较长的处理函数。 - 只对交互孤岛进行水合,并审查第三方脚本。
- 为媒体、广告和横幅预留空间,并调校后备字体。
- 先在节流后的跟踪中验证修复,再看 RUM,最后看 CrUX。