前言
你的页面 Lighthouse 跑分 90+,用户却反馈"感觉很慢"------这说明跑分和体验之间有一个鸿沟。Google 提出的 Core Web Vitals(核心 Web 指标) 就是为了弥合这个鸿沟:用三个指标量化用户的真实感受。
从 2024 年起,Google 用 INP 取代了 FID,目前的三大核心指标是:
| 指标 | 衡量什么 | 用户感受 | 达标线 |
|---|---|---|---|
| LCP | 最大内容绘制时间 | "页面加载出来了吗?" | ≤ 2.5s |
| INP | 交互到下一帧的延迟 | "点了有反应吗?" | ≤ 200ms |
| CLS | 累积布局偏移 | "页面怎么又跳了?" | ≤ 0.1 |
本文逐个拆解这三个指标的计算方式、常见问题和优化手段,每个指标都附带可落地的代码。
一、LCP:最大内容绘制
什么是 LCP?
LCP 衡量的是视口内最大可见元素完成渲染的时间。浏览器在页面加载过程中会持续更新"最大元素"的候选者,直到用户首次交互(点击、滚动、按键)为止。
ini
时间线:
0s 1s 2s 3s
│───────────│───────────│───────────│
│ TTFB │ 首屏 HTML 解析 │
│ │ ├─ 小 logo 渲染 │
│ │ ├─ 导航栏渲染 │
│ │ └─ Hero 大图渲染 ←── LCP 候选
│ │ │
│ │ 最终 LCP = 2.1s
常见的 LCP 元素:
<img>主图(最常见)<video>封面帧- 带有
background-image的块级元素 - 大段文字的块级元素(
<h1>、<p>)
LCP 常见问题
| 原因 | 表现 | 占比 |
|---|---|---|
| 资源加载慢 | 主图/字体下载耗时长 | ~40% |
| 服务端响应慢 | TTFB 高 | ~25% |
| 渲染阻塞 | CSS/JS 阻塞首屏渲染 | ~25% |
| 客户端渲染 | SPA 需要 JS 执行后才有内容 | ~10% |
LCP 优化方案
1. 预加载 LCP 资源
如果 LCP 元素是一张图片,浏览器需要先解析 HTML → 解析 CSS → 发现图片 URL → 下载图片。用 preload 跳过发现阶段:
html
<!-- 在 <head> 中尽早声明 -->
<link rel="preload" as="image" href="/hero-banner.webp" />
如果是响应式图片,用 imagesrcset:
html
<link
rel="preload"
as="image"
href="/hero-800.webp"
imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
imagesizes="100vw"
/>
2. 优化图片格式和尺寸
html
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="hero" width="1200" height="600" />
</picture>
| 格式 | 同画质体积 | 浏览器支持 |
|---|---|---|
| JPEG | 100%(基准) | 全部 |
| WebP | ~30% | 97%+ |
| AVIF | ~50% | 92%+ |
3. 消除渲染阻塞资源
html
<!-- 非关键 CSS 异步加载 -->
<link rel="preload" href="/non-critical.css" as="style"
onload="this.onload=null;this.rel='stylesheet'" />
<!-- 非关键 JS 延迟执行 -->
<script src="/analytics.js" defer></script>
4. SSR / SSG 优先输出首屏 HTML
SPA 的 LCP 问题往往最严重------用户看到的第一帧是空白的 <div id="root"></div>,要等 JS 下载执行完才有内容。
css
CSR 流程:下载 HTML → 下载 JS → 执行 JS → 请求数据 → 渲染 → LCP
SSR 流程:下载 HTML(已含首屏内容)→ LCP → 下载 JS → 激活交互
如果用 Next.js,将首屏页面改为 SSR 或 SSG 是提升 LCP 最直接的方式。
5. fetchpriority 提升主图优先级
html
<img src="/hero.webp" alt="hero" fetchpriority="high" />
浏览器默认给视口外的图片低优先级。fetchpriority="high" 告诉浏览器:这张图很重要,优先下载。
LCP 诊断代码
typescript
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', {
time: Math.round(lastEntry.startTime) + 'ms',
element: lastEntry.element?.tagName,
url: (lastEntry as any).url || 'text node',
size: lastEntry.size,
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
二、INP:交互到下一帧延迟
什么是 INP?
INP(Interaction to Next Paint)衡量的是用户交互后,页面更新到屏幕上的延迟。它取的不是单次最差值,而是所有交互中一个接近最差的值(P98 分位)。
一次交互的延迟由三部分组成:
css
用户点击按钮
│
├── Input Delay(输入延迟)
│ 主线程正忙,事件排队等待
│
├── Processing Time(处理时间)
│ 事件回调函数执行
│
├── Presentation Delay(呈现延迟)
│ 浏览器计算样式、布局、绘制
│
└── 屏幕更新 ← 用户看到反馈
INP = Input Delay + Processing Time + Presentation Delay
INP vs FID 的区别:
| FID(已废弃) | INP | |
|---|---|---|
| 衡量范围 | 仅首次交互的输入延迟 | 所有交互的全链路延迟 |
| 覆盖阶段 | 只有 Input Delay | Input Delay + Processing + Paint |
| 代表性 | 只反映首次 | 反映整体交互质量 |
INP 常见问题
- 长任务阻塞主线程 --- 一个 200ms 的同步计算会让同期所有交互排队
- 事件处理函数太重 --- 点击后做了大量 DOM 操作或同步计算
- 频繁重排 --- 处理函数中触发了强制同步布局
INP 优化方案
1. 拆分长任务
长任务指耗时超过 50ms 的任务。用 scheduler.yield() 或 setTimeout 主动让出主线程:
typescript
async function processLargeDataset(items: any[]) {
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
processChunk(chunk);
// 每处理 100 条让出一次主线程,让待处理的交互事件有机会执行
if (i + CHUNK_SIZE < items.length) {
await yieldToMain();
}
}
}
function yieldToMain(): Promise<void> {
return new Promise((resolve) => {
// scheduler.yield() 是更好的选择(Chrome 129+)
if ('scheduler' in window && 'yield' in (window as any).scheduler) {
(window as any).scheduler.yield().then(resolve);
} else {
setTimeout(resolve, 0);
}
});
}
2. 用 startTransition 标记低优先级更新(React 18+)
tsx
import { useState, useTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
// 高优先级:立即更新输入框
setQuery(e.target.value);
// 低优先级:搜索结果的更新可以延迟
startTransition(() => {
setResults(filterItems(e.target.value));
});
}
return (
<>
<input value={query} onChange={handleInput} />
{isPending ? <Spinner /> : <ResultList items={results} />}
</>
);
}
用户打字时,输入框立即响应(INP 低),搜索结果延迟更新但不阻塞交互。
3. 事件回调中避免同步 DOM 操作
typescript
// ❌ 点击后同步修改 1000 个 DOM 节点
button.addEventListener('click', () => {
items.forEach((el) => {
el.style.transform = `translateX(${calculatePosition(el)}px)`;
});
});
// ✅ 将 DOM 修改推到下一帧
button.addEventListener('click', () => {
requestAnimationFrame(() => {
items.forEach((el) => {
el.style.transform = `translateX(${calculatePosition(el)}px)`;
});
});
});
把 DOM 修改从事件回调中移出,事件处理函数就能更快返回,Input Delay 阶段的排队时间也会减少。
4. 避免频繁的 DOM 读取触发强制布局
这一点在上一篇《批量 DOM 操作优化》中详细讲过------读写分离,永远不要在修改 DOM 后立即读取布局属性。
INP 诊断代码
typescript
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn('Slow interaction:', {
type: (entry as any).name,
duration: Math.round(entry.duration) + 'ms',
target: (entry as any).target?.tagName,
inputDelay: Math.round((entry as any).inputDelay) + 'ms',
processingTime: Math.round(
(entry as any).processingEnd - (entry as any).processingStart
) + 'ms',
});
}
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
三、CLS:累积布局偏移
什么是 CLS?
CLS 衡量的是页面生命周期中所有意外布局偏移的累积分数。简单说,就是元素在用户没有预期的情况下突然"跳"了一下。
arduino
用户正准备点击 "取消" 按钮:
┌────────────────────┐ ┌────────────────────┐
│ │ │ ┌──────────────┐ │
│ │ 广告 │ │ 广告加载 │ │
│ ┌──────┐ ┌──────┐│ 加载 │ └──────────────┘ │
│ │ 确定 │ │ 取消 ││ ──→ │ │
│ └──────┘ └──────┘│ │ ┌──────┐ ┌──────┐ │
│ ↑ │ │ │ 确定 │ │ 取消 │ │ ← 按钮被推下去了!
│ 准备点这里 │ │ └──────┘ └──────┘ │
└────────────────────┘ └────────────────────┘
用户点到了 "确定" → 误操作
CLS 的计算方式:
布局偏移分数 = 影响比例 × 距离比例
影响比例:不稳定元素在前后两帧中覆盖的可视区域面积占比
距离比例:不稳定元素移动的最大距离占视口的比例
不算 CLS 的情况:
- 用户点击/按键后 500ms 内的布局变化(用户预期内的)
transform动画(不改变布局)- 新插入的元素(没有"移动")
CLS 常见元凶
| 原因 | 典型场景 | 严重程度 |
|---|---|---|
| 无尺寸的图片/视频 | 图片加载完撑开容器 | ⭐⭐⭐ |
| 动态注入的广告/横幅 | 顶部插入通知条 | ⭐⭐⭐ |
| Web Font 加载 | 字体切换导致文本重排 | ⭐⭐ |
| 动态内容插入 | API 返回后插入列表 | ⭐⭐ |
CLS 优化方案
1. 始终给图片和视频设置尺寸
html
<!-- ❌ 没有尺寸:图片加载前高度为 0,加载后撑开 → 布局偏移 -->
<img src="/photo.jpg" alt="photo" />
<!-- ✅ 明确尺寸:浏览器提前预留空间 -->
<img src="/photo.jpg" alt="photo" width="800" height="450" />
<!-- ✅ CSS aspect-ratio:响应式场景更灵活 -->
<style>
.responsive-img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
</style>
<img class="responsive-img" src="/photo.jpg" alt="photo" />
这是最常见也最容易修复的 CLS 问题。仅这一步就能解决大部分页面的 CLS 问题。
2. 为动态内容预留空间
css
/* 广告位:即使广告还没加载,也预留固定高度 */
.ad-slot {
min-height: 250px;
background: #f0f0f0;
}
/* 骨架屏:内容加载前保持布局稳定 */
.skeleton-card {
height: 200px;
border-radius: 8px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
3. 避免在顶部动态插入内容
typescript
// ❌ 在页面顶部插入通知条 → 所有内容下移 → CLS
function showNotification(msg: string) {
const banner = document.createElement('div');
banner.textContent = msg;
document.body.prepend(banner);
}
// ✅ 用固定定位覆盖在页面上方 → 不影响文档流 → 无 CLS
function showNotification(msg: string) {
const banner = document.createElement('div');
banner.textContent = msg;
Object.assign(banner.style, {
position: 'fixed',
top: '0',
left: '0',
right: '0',
zIndex: '9999',
transform: 'translateY(-100%)',
transition: 'transform 0.3s',
});
document.body.appendChild(banner);
requestAnimationFrame(() => {
banner.style.transform = 'translateY(0)';
});
}
4. 优化字体加载
css
/* 使用 font-display: optional --- 如果字体没有在极短时间内加载完,就不切换了 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional;
}
font-display 的四个值:
| 值 | 行为 | CLS 影响 |
|---|---|---|
auto |
浏览器自行决定 | 不确定 |
swap |
先用系统字体,加载完切换 | 有 CLS |
fallback |
短暂不可见 → 系统字体 → 可能切换 | 轻微 |
optional |
短暂不可见 → 没加载完就用系统字体不再切换 | 无 CLS |
如果品牌字体不是强需求,optional 是 CLS 最友好的选择。
5. 使用 contain 属性限制布局影响范围
css
.independent-section {
contain: layout;
}
contain: layout 告诉浏览器:这个元素内部的布局变化不会影响外部。浏览器可以跳过外部元素的重排计算。
CLS 诊断代码
typescript
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
console.log('Layout shift:', {
value: (entry as any).value.toFixed(4),
sources: (entry as any).sources?.map((s: any) => ({
element: s.node?.tagName,
previousRect: s.previousRect,
currentRect: s.currentRect,
})),
});
}
}
}).observe({ type: 'layout-shift', buffered: true });
四、一站式监控:web-vitals 库
Google 官方提供了 web-vitals 库,几行代码搞定三大指标的采集:
typescript
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric: { name: string; value: number; id: string }) {
// 上报到你的监控平台
navigator.sendBeacon('/analytics', JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
id: metric.id,
page: location.pathname,
timestamp: Date.now(),
}));
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
实验室数据 vs 真实用户数据:
| 实验室(Lighthouse) | 真实用户(RUM) | |
|---|---|---|
| 数据来源 | 模拟设备 | 真实用户浏览器 |
| 网络条件 | 模拟 4G | 用户实际网络 |
| 指标 | 仅能测 LCP / CLS | LCP / INP / CLS 全部 |
| 价值 | 开发阶段快速验证 | 真正反映用户体验 |
Lighthouse 跑分只是起点,上线后用 web-vitals 采集真实用户数据才是终点。
优化速查表
arduino
LCP > 2.5s?
├── 主图太大 → preload + WebP/AVIF + fetchpriority="high"
├── TTFB 高 → CDN + 服务端缓存 + SSR/SSG
├── CSS/JS 阻塞 → defer / async + 关键 CSS 内联
└── SPA 白屏 → SSR 首屏 + Streaming HTML
INP > 200ms?
├── 长任务 → 时间切片 + scheduler.yield()
├── 回调太重 → rAF 延迟 DOM 操作 + Web Worker
├── 强制布局 → 读写分离(见第 9 篇)
└── React 场景 → useTransition + useDeferredValue
CLS > 0.1?
├── 图片无尺寸 → width/height 或 aspect-ratio
├── 动态插入 → 预留空间 + fixed 定位
├── 字体闪烁 → font-display: optional
└── 广告/横幅 → min-height 占位
总结
Core Web Vitals 的三个指标各管一个维度:
- LCP → 加载速度 → 核心优化:预加载 + 压缩 + SSR
- INP → 交互响应 → 核心优化:拆分长任务 + 让出主线程
- CLS → 视觉稳定 → 核心优化:提前预留空间 + 避免动态插入
记住一个原则:Lighthouse 跑分用于开发阶段快速定位问题,web-vitals RUM 数据才是真正的优化目标。 跑分 100 分但真实用户 P75 不达标,说明测试环境和真实环境有差距------始终以真实数据为准。
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。