Core Web Vitals 全解:LCP / INP / CLS 逐个击破

前言

你的页面 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 常见问题

  1. 长任务阻塞主线程 --- 一个 200ms 的同步计算会让同期所有交互排队
  2. 事件处理函数太重 --- 点击后做了大量 DOM 操作或同步计算
  3. 频繁重排 --- 处理函数中触发了强制同步布局

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 不达标,说明测试环境和真实环境有差距------始终以真实数据为准。


如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。

相关推荐
VillenK1 小时前
版本依赖问题:vite-plugin-dts@3.1.0 与 jiti 的兼容性
前端·typescript·vite
Apifox1 小时前
如何在 Apifox 中快速构建和调试 AI Agent
前端·agent·ai编程
一晌贪欢i1 小时前
WebContainer 重点介绍
前端·webcontainer
山河木马1 小时前
Emscripten 从 C/C++ 调用 JavaScript
前端·javascript·c++
鹏程十八少2 小时前
12. Android 协程通关秘籍:31 道资深工程师面试题精讲
前端·后端·面试
Dlrb12112 小时前
C语言-字符串指针与函数指针
java·c语言·前端
PBitW2 小时前
组件封装注意事项
前端·vue.js
weiggle2 小时前
Android 输入事件分发流程:从物理触控到 Activity 的完整旅程
前端
yingyima2 小时前
开发者必备在线工具集合 2025:实战案例解析
前端