Core Web Vitals 核心指标优化

中等 🟡性能优化
9 个标签
预计阅读时间:104 分钟
性能优化Core Web VitalsLCPINPFIDCLSweb-vitalsRUMCrUX

Core Web Vitals 核心指标优化

Core Web Vitals(核心网页指标,简称 CWV)是 Google 从海量真实用户体验数据中提炼出来的一组"以用户为中心"的性能指标。它试图回答三个最朴素的问题:页面主要内容什么时候能看见?(加载)我点了它多久才回应我?(交互)内容会不会在我眼皮底下乱跳?(视觉稳定)

可以把一次网页访问类比成去餐厅吃饭:LCP 相当于"主菜多久端上桌",INP 相当于"你举手叫服务员,他多久走过来搭理你",CLS 相当于"上菜时会不会把你正夹着的菜盘子挪走害你夹空"。三者共同决定了这顿饭(这次访问)体验好不好。

为什么 Core Web Vitals 如此重要

直接影响 SEO 排名。 自 2021 年起,Core Web Vitals 已成为 Google 搜索排名的一部分(Page Experience 信号)。在其他相关性因素接近时,CWV 更好的页面会获得排名优势。
直接影响业务转化。 Google 与合作站点公布过大量案例:CWV 达标能显著降低跳出率、提升转化。例如页面加载从 1 秒到 3 秒,跳出概率提升约 32%;从 1 秒到 5 秒,跳出概率提升约 90%。
它衡量的是真实用户感受,不是机器跑分。 CWV 强调"字段数据(Field Data / RUM)",即真实用户在真实设备、真实网络下的体验,而不仅仅是实验室里一台高配机器的理想成绩。
它是可量化、可追踪、可回归的。 每个指标都有明确阈值(good / needs-improvement / poor),可以纳入 CI、监控大盘和告警。

> 一个关键认知:Core Web Vitals 是"会演进"的。2020 年三大指标是 LCP、FID、CLS;2024 年 3 月,INP(Interaction to Next Paint)正式取代 FID,成为衡量交互响应的核心指标。本文会以 INP 为交互指标的主角来讲解,同时保留 FID 的历史背景与迁移说明。

指标全景:核心指标与辅助指标

Google 把指标分成两类:核心指标(Core Web Vitals) 与用于诊断的辅助/其他指标

| 指标 | 全称 | 衡量维度 | 是否核心 CWV | good 阈值 | poor 阈值 | 数据类型 |

| --- | --- | --- | --- | --- | --- | --- |

| LCP | Largest Contentful Paint | 加载(主内容可见) | 是 | ≤ 2.5s | > 4.0s | 实验室 + 字段 |

| INP | Interaction to Next Paint | 交互响应 | 是(2024 起) | ≤ 200ms | > 500ms | 主要字段 |

| CLS | Cumulative Layout Shift | 视觉稳定 | 是 | ≤ 0.1 | > 0.25 | 实验室 + 字段 |

| FID | First Input Delay | 首次输入延迟 | 否(已被 INP 取代) | ≤ 100ms | > 300ms | 仅字段 |

| TTFB | Time to First Byte | 服务器响应 | 否(诊断) | ≤ 800ms | > 1800ms | 实验室 + 字段 |

| FCP | First Contentful Paint | 首次内容绘制 | 否(诊断) | ≤ 1.8s | > 3.0s | 实验室 + 字段 |

| TBT | Total Blocking Time | 主线程阻塞 | 否(实验室代理 INP) | ≤ 200ms | > 600ms | 仅实验室 |

阈值判定口径:Google 采用第 75 百分位(p75)来评价一个页面/站点是否达标。也就是说,不是看平均值,而是看"最差的那 25% 用户之外"的体验——要让 75% 的访问都落在 good 区间,指标才算通过。这条口径非常重要,它意味着少数慢设备、弱网用户也会把你的成绩拖下水,平均值好看没有意义。

实验室数据 vs 真实用户数据(Lab vs Field)

理解 CWV 前必须先分清两类数据,否则会出现"Lighthouse 满分但 Search Console 报红"的困惑。

| 维度 | 实验室数据(Lab / 合成监测) | 字段数据(Field / RUM / 真实用户) |

| --- | --- | --- |

| 采集方式 | 固定设备、固定网络模拟,单次或多次跑分 | 真实用户浏览器上报,海量样本 |

| 代表工具 | Lighthouse、PageSpeed 的 Lab 部分、WebPageTest | CrUX、web-vitals 自建 RUM、Search Console |

| 可测指标 | LCP、FCP、TBT、CLS、TTFB(可测 TBT,测不了真实 INP/FID) | LCP、INP、CLS、FCP、TTFB(真实交互) |

| 优点 | 可复现、可调试、适合 CI 回归 | 反映真实体验,是 Google 排名依据 |

| 缺点 | 不代表真实用户分布,测不到真实交互 | 有采集延迟(CrUX 为 28 天滚动窗口),难以逐次复现 |

CrUX(Chrome User Experience Report) 是 Google 的公开字段数据集,来自开启同步、符合条件的 Chrome 真实用户。它是 Search Console 和 PageSpeed"Field Data"板块的数据来源,采用 28 天滚动窗口 聚合,所以你今天的优化,通常要几周后才在 CrUX 里完整体现。
黄金法则:用实验室数据定位与调试问题(可复现),用字段数据判断是否真的解决了问题(真实用户为准)。二者不一致时,以字段数据为准,因为它才是 Google 排名和真实体验的依据。

---

⚡ LCP(Largest Contentful Paint,最大内容绘制)

定义与阈值

LCP 衡量视口内最大的那个内容元素完成渲染的时间点,是"用户觉得页面主要内容已经出现"的最佳单一代理指标。候选元素包括:``、``(SVG 内)、`

| 评级 | LCP 数值 |

| --- | --- |

| good(良好) | ≤ 2.5 秒 |

| needs-improvement(需改进) | 2.5 秒 ~ 4.0 秒 |

| poor(差) | > 4.0 秒 |

原理:LCP 由四个子阶段构成

Google 把 LCP 拆成四段,优化就是压缩这四段:

1.TTFB(Time to First Byte):从发起导航到收到 HTML 第一个字节。
2.资源加载延迟(Load Delay):从 TTFB 到浏览器开始加载 LCP 资源之间的空档。这段往往是"发现晚了"造成的浪费。
3.资源加载时间(Load Time):LCP 资源本身下载耗时。
4.元素渲染延迟(Render Delay):资源下载完到真正绘制到屏幕之间的耗时(常被 CSS/JS 阻塞)。

> 经验:很多 LCP 问题不是"图片太大",而是"发现太晚"。图片被 JS 动态插入、被懒加载、藏在 CSS 背景里、或排在一堆阻塞脚本后面,都会拉长"资源加载延迟"。

LCP 优化代码

预加载关键资源、预连接关键域名,让浏览器尽早发现并下载 LCP 资源:

htmlCode
<!-- 预加载 LCP 图片,并用 fetchpriority 提升优先级 -->
<link rel="preload" as="image" href="/hero-image.webp" fetchpriority="high">

<!-- 预加载首屏关键字体,避免文本 LCP 因字体阻塞而延迟 -->
<link rel="preload" as="font" href="/fonts/main.woff2" type="font/woff2" crossorigin>

<!-- 预连接到承载 LCP 资源的第三方域名(建立 TCP + TLS) -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="dns-prefetch" href="https://api.example.com">

<!-- 直接在图片标签上标注高优先级;首屏图片切勿 loading="lazy" -->
<picture>
  <source srcset="/hero-image.avif" type="image/avif">
  <source srcset="/hero-image.webp" type="image/webp">
  <img
    src="/hero-image.jpg"
    alt="首屏主图"
    loading="eager"
    fetchpriority="high"
    decoding="async"
    width="1200"
    height="600"
  >
</picture>

避免"资源发现太晚"的反面教材与修正:

htmlCode
<!-- 反例:LCP 图片用背景图 + JS 注入,浏览器无法在预加载扫描阶段发现它 -->
<div id="hero"></div>
<script>
  document.getElementById('hero').style.backgroundImage = "url('/hero.jpg')";
</script>

<!-- 正例:用真实 <img>,让预加载扫描器(preload scanner)第一时间发现 -->
<img src="/hero.jpg" alt="hero" fetchpriority="high" width="1200" height="600">

用响应式图片按视口发送合适尺寸,减少"资源加载时间":

htmlCode
<img
  src="/hero-800.webp"
  srcset="/hero-480.webp 480w, /hero-800.webp 800w, /hero-1200.webp 1200w"
  sizes="(max-width: 600px) 480px, (max-width: 1024px) 800px, 1200px"
  alt="响应式主图"
  fetchpriority="high"
  width="1200"
  height="600"
>

LCP 监控代码(web-vitals)

javascriptCode
// 使用 web-vitals 库监控真实用户 LCP
import { onLCP } from 'web-vitals';

onLCP((metric) => {
  // metric.value 为毫秒;metric.rating 为 good/needs-improvement/poor
  console.log('LCP:', metric.value, metric.rating);

  // 通过 sendBeacon 上报,页面卸载时也能可靠发送
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    rating: metric.rating,
    navigationType: metric.navigationType,
  });
  (navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
    fetch('/analytics', { body, method: 'POST', keepalive: true });
});

真实优化案例(LCP)

Vodafone:通过优化 LCP(预加载、图片格式、减少阻塞)使 LCP 改善约 31%,带来销量提升 8%、访问增加 15%、加购率提升 11%。
农业电商 Tokopedia:对首屏主图做 fetchpriority 与格式优化后,LCP 明显下降,页面可见速度加快。
常见收益量级:把一张 900KB 的 JPEG 首图换成 AVIF(约 120KB)并加 `fetchpriority="high"`,在 4G 中端机上 LCP 从约 4.1s 降到约 2.2s,直接从 poor 跨到 good。

---

🎯 INP(Interaction to Next Paint,交互到下一次绘制)

为什么是 INP:FID 的谢幕

INP 已于 2024 年 3 月正式取代 FID,成为 Core Web Vitals 中衡量交互响应能力的核心指标。 这是近年 CWV 最重要的变化,必须理解 FID→INP 的演进逻辑。

| 对比项 | FID(旧) | INP(新) |

| --- | --- | --- |

| 全称 | First Input Delay | Interaction to Next Paint |

| 衡量什么 | 仅"首次交互"的输入延迟部分 | 整页生命周期内所有交互的完整响应时间 |

| 覆盖阶段 | 只算 输入延迟(等待主线程) | 输入延迟 + 处理时间 + 渲染绘制,全链路 |

| 取值方式 | 首次交互的延迟 | 通常取所有交互中的(近似)最差值(p98 附近) |

| 盲区 | 测不到事件处理慢、渲染慢;首次交互往往偏乐观 | 无盲区,反映用户"点了半天没反应"的真实痛点 |

| good 阈值 | ≤ 100ms | ≤ 200ms |

| poor 阈值 | > 300ms | > 500ms |

一句话总结:FID 只量"开门快不快",INP 量"开门 + 办事 + 给回执"的全过程。很多站点 FID 很漂亮(首次点击响应快),但用户中途操作卡顿严重,FID 完全看不出来,INP 才把这些问题暴露出来。

INP 的定义与阈值

INP 观察页面整个生命周期内用户的所有点击、点按(tap)和键盘交互,测量每次交互从用户操作到浏览器下一帧绘制出视觉反馈的延迟,最终报告一个能代表整体响应质量的值(长会话下接近 p98,排除极少数离群值)。

| 评级 | INP 数值 |

| --- | --- |

| good(良好) | ≤ 200 毫秒 |

| needs-improvement(需改进) | 200 ~ 500 毫秒 |

| poor(差) | > 500 毫秒 |

原理:一次交互的三段延迟

INP 把每次交互拆成三段,任何一段过长都会拉高 INP:

1.输入延迟(Input Delay):从用户操作到事件回调开始执行。主线程被长任务占用时这段会变长。
2.处理时间(Processing Time):事件回调(你的 JS 逻辑)执行耗时。
3.呈现延迟(Presentation Delay):回调结束到浏览器完成下一帧渲染(重新计算样式、布局、绘制)的耗时。

INP 优化代码

手法一:把非紧急工作让出主线程,先给用户视觉反馈。

javascriptCode
// 反例:点击后同步跑重活,界面在计算完成前毫无反馈,INP 飙高
button.addEventListener('click', () => {
  const result = doHeavyWork(dataset); // 阻塞 300ms+
  render(result);
});

// 正例:先让浏览器绘制反馈,再用 yield 把重活切到后续任务
button.addEventListener('click', async () => {
  showSpinner();                 // 立即给用户可见反馈,先绘制这一帧
  await yieldToMain();           // 让出主线程,让上面的绘制先发生
  const result = doHeavyWork(dataset);
  render(result);
});

// 通用让路函数:优先用 scheduler.yield,回退到 setTimeout
function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

手法二:用 scheduler.postTask 按优先级调度,切分长任务。

javascriptCode
// 把一个长任务拆成多个短任务,并用 scheduler.postTask 标注优先级
async function processInChunks(items) {
  for (let i = 0; i < items.length; i += 100) {
    const chunk = items.slice(i, i + 100);
    await scheduler.postTask(() => handleChunk(chunk), {
      priority: 'user-visible', // user-blocking / user-visible / background
    });
  }
}

手法三:用 startTransition / useDeferredValue 降低 React 重渲染对交互的阻塞。

javascriptCode
import { useState, useTransition, useDeferredValue } from 'react';

function SearchBox({ allItems }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  // 输入框实时更新(紧急),列表过滤延后(非紧急),交互不卡
  const deferredQuery = useDeferredValue(query);
  const filtered = allItems.filter((x) => x.includes(deferredQuery));

  return (
    <>
      <input
        value={query}
        onChange={(e) => {
          setQuery(e.target.value); // 紧急更新:输入框立刻响应
          startTransition(() => {}); // 非紧急更新在后台并发处理
        }}
      />
      {isPending && <span>更新中…</span>}
      <List items={filtered} />
    </>
  );
}

手法四:用 CSS content-visibility 降低"呈现延迟"。

cssCode
/* 屏幕外的长列表区块跳过渲染,减少每帧样式/布局/绘制成本 */
.long-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px; /* 预留高度,避免滚动跳动 */
}

INP 监控代码(含长任务定位)

javascriptCode
import { onINP } from 'web-vitals';

// reportAllChanges 便于开发期观察每次交互;生产上报建议默认只报最终值
onINP((metric) => {
  console.log('INP:', metric.value, metric.rating);
  navigator.sendBeacon?.('/analytics', JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
  }));
}, { reportAllChanges: false });

// 额外:用 PerformanceObserver 抓 >50ms 的长任务,辅助定位 INP 元凶
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn('Long Task:', Math.round(entry.duration), 'ms', entry.name);
    }
  }
}).observe({ type: 'longtask', buffered: true });

真实优化案例(INP)

The Economic Times:通过降低第三方脚本影响、拆分长任务优化 INP,跳出率下降约 43%。
redBus:系统性优化 INP(事件处理拆分、延迟非关键脚本)后,多个关键页面 INP 进入 good 区间,转化随之提升。
典型量级:一个搜索页把过滤逻辑从"onChange 同步过滤 2 万条"改为 `useDeferredValue` + 分片,INP 从约 520ms(poor)降到约 160ms(good)。

---

📐 CLS(Cumulative Layout Shift,累积布局偏移)

定义与阈值

CLS 衡量页面生命周期内所有意外布局偏移的累计得分。单次偏移得分 = 影响比例(impact fraction)× 距离比例(distance fraction)。CLS 取一个会话窗口内偏移得分之和的最大值(会话窗口:相邻偏移间隔 < 1s、总时长 ≤ 5s)。"意外"是关键——用户主动交互(点击展开)后 500ms 内的偏移不计入。

| 评级 | CLS 数值 |

| --- | --- |

| good(良好) | ≤ 0.1 |

| needs-improvement(需改进) | 0.1 ~ 0.25 |

| poor(差) | > 0.25 |

原理:布局偏移从哪来

图片/视频/iframe/广告没有预留尺寸,加载后把下方内容"顶开"。
Web 字体从 fallback 切换到自定义字体时,字宽变化导致 FOUT 抖动。
动态注入的横幅、通知条、Cookie 提示插在已有内容之上。
用改变 `top/left/width/height` 的方式做动画,而不是 `transform`。

CLS 优化代码

htmlCode
<!-- 1) 给媒体元素显式尺寸或 aspect-ratio,浏览器提前占位 -->
<img src="/photo.jpg" alt="示例" width="800" height="600">

<style>
  .media {
    aspect-ratio: 16 / 9; /* 即使 src 未加载也占好位置 */
    width: 100%;
    background: #f0f0f0;
  }
  /* 2) 为动态内容(广告/推荐位)预留最小高度 */
  .ad-slot { min-height: 250px; }
  /* 3) 动画只用 transform / opacity,不触发布局 */
  .toast { transition: transform .2s ease; will-change: transform; }
</style>

<!-- 4) 字体加载优化:size-adjust + font-display 减少切换抖动 -->
<style>
  @font-face {
    font-family: 'Brand';
    src: url('/fonts/brand.woff2') format('woff2');
    font-display: optional;      /* 短暂等待,超时就用 fallback,减少抖动 */
    size-adjust: 97%;            /* 对齐 fallback 字体尺寸 */
  }
</style>

用 CSS 占位而非 JS 事后插入内容:

htmlCode
<!-- 反例:加载后 append,把下方内容顶下去 -->
<script>document.body.appendChild(makeBanner());</script>

<!-- 正例:预留容器,内容原地填充,不产生偏移 -->
<div id="banner-slot" style="min-height: 120px;"></div>
<script>
  document.getElementById('banner-slot').replaceChildren(makeBanner());
</script>

CLS 监控代码(含偏移源定位)

javascriptCode
import { onCLS } from 'web-vitals';

onCLS((metric) => {
  console.log('CLS:', metric.value, metric.rating);
  navigator.sendBeacon?.('/analytics', JSON.stringify({
    name: metric.name, value: metric.value, rating: metric.rating, id: metric.id,
  }));
});

// 定位偏移来源:打印每次 layout-shift 涉及的 DOM 节点
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput && entry.value > 0.01) {
      console.warn('Layout Shift', entry.value,
        entry.sources?.map((s) => s.node));
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

真实优化案例(CLS)

Yahoo! JAPAN 新闻:为广告位与图片预留空间、稳定字体加载,CLS 改善约 0.2,页面翻页量提升约 15%,会话时长提升约 13%。
典型量级:给首屏 6 张无尺寸图片补上 `width/height` 与 `aspect-ratio`,CLS 从 0.31(poor)降到 0.02(good)。

---

🔧 辅助诊断指标:TTFB / FCP / TBT

核心指标告诉你"哪里差",辅助指标告诉你"为什么差"。它们不参与排名判定,但在调试时不可或缺。

TTFB(Time to First Byte,首字节时间)

定义:从发起请求到收到响应第一个字节的时间,反映服务器响应 + 网络往返。是 LCP 的第一段,TTFB 慢则 LCP 几乎不可能快。
阈值:good ≤ 800ms,poor > 1800ms。
优化:CDN 边缘缓存、服务端缓存、数据库查询优化、启用 HTTP/2/3、减少重定向链、SSR 页面用流式渲染(streaming)。
javascriptCode
import { onTTFB } from 'web-vitals';
onTTFB((metric) => {
  console.log('TTFB:', metric.value, metric.rating);
  // attribution 版可拆出 DNS / 连接 / 请求 / 等待 各阶段耗时
});

FCP(First Contentful Paint,首次内容绘制)

定义:任意首个内容(文本、图片、SVG、非白背景)绘制到屏幕的时间。FCP 早说明"页面开始出现东西"了。
阈值:good ≤ 1.8s,poor > 3.0s。
优化:减少阻塞渲染的 CSS/JS、内联关键 CSS、消除重定向、优化 TTFB。
javascriptCode
import { onFCP } from 'web-vitals';
onFCP((metric) => console.log('FCP:', metric.value, metric.rating));

TBT(Total Blocking Time,总阻塞时间)

定义:FCP 到 TTI 之间,所有长任务(>50ms)超出 50ms 的部分之和。它是实验室里 INP 的最佳代理——TBT 高,真实 INP 大概率也差。
阈值:good ≤ 200ms,poor > 600ms(Lighthouse 口径)。
优化:代码分割、延迟第三方脚本、拆分长任务、减少主线程 JS。TBT 只能在实验室测(Lighthouse/WebPageTest),线上用 INP 观测真实表现。
javascriptCode
// TBT 无法直接在真实用户端测量,但可用长任务近似估算主线程阻塞
let blockingTime = 0;
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) blockingTime += Math.max(0, e.duration - 50);
  console.log('approx blocking time:', Math.round(blockingTime), 'ms');
}).observe({ type: 'longtask', buffered: true });

---

📡 统一的字段数据采集与上报(web-vitals + RUM)

生产环境应把所有核心指标统一采集、批量上报,建成自己的 RUM(真实用户监测)。下面是一套可直接落地的方案。

javascriptCode
// rum.js —— 统一采集 LCP/INP/CLS/TTFB/FCP 并批量上报
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';

const queue = new Set();

function addToQueue(metric) {
  queue.add({
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating,
    id: metric.id,
    navigationType: metric.navigationType,
    // 附带上下文,便于分维度下钻分析
    url: location.pathname,
    // 有 Network Information API 时带上网络类型
    effectiveType: navigator.connection?.effectiveType,
    deviceMemory: navigator.deviceMemory,
    ts: Date.now(),
  });
}

function flushQueue() {
  if (queue.size === 0) return;
  const body = JSON.stringify([...queue]);
  // 优先 sendBeacon,保证页面卸载时也能送达
  (navigator.sendBeacon && navigator.sendBeacon('/rum', body)) ||
    fetch('/rum', { body, method: 'POST', keepalive: true });
  queue.clear();
}

onLCP(addToQueue);
onINP(addToQueue);
onCLS(addToQueue);
onTTFB(addToQueue);
onFCP(addToQueue);

// 页面进入后台或卸载时统一冲刷,避免 SPA 场景丢数据
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flushQueue();
});
addEventListener('pagehide', flushQueue);

服务端聚合时,务必按 p75 计算达标情况,并按设备/网络/路由分维度下钻:

javascriptCode
// 伪代码:按路由聚合 p75,判断是否达标
function p75(values) {
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.floor(sorted.length * 0.75)];
}

const THRESHOLDS = { LCP: 2500, INP: 200, CLS: 0.1 };
function evaluate(route, samples) {
  const result = {};
  for (const name of ['LCP', 'INP', 'CLS']) {
    const p = p75(samples[name] || []);
    result[name] = { p75: p, pass: p <= THRESHOLDS[name] };
  }
  return { route, ...result };
}

---

🧭 attribution 归因调试:从"数值差"到"哪行代码"

普通版 web-vitals 只告诉你 INP = 480ms,但不告诉你是哪个交互、卡在哪一段。web-vitals 的 attribution build 会附带归因信息,直接把问题定位到具体阶段与元素。

javascriptCode
// 引入 attribution 构建,获得细粒度归因
import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  const attr = metric.attribution;
  console.log('INP total:', metric.value);
  console.log('交互类型:', attr.interactionType);        // pointer / keyboard
  console.log('目标元素:', attr.interactionTarget);      // 触发交互的选择器
  console.log('输入延迟:', attr.inputDelay);             // 第一段
  console.log('处理耗时:', attr.processingDuration);     // 第二段
  console.log('呈现延迟:', attr.presentationDelay);      // 第三段
  console.log('归属脚本:', attr.longAnimationFrameEntries); // LoAF:慢在哪段脚本
});

LCP 的 attribution 能直接告诉你四段耗时和 LCP 元素:

javascriptCode
import { onLCP } from 'web-vitals/attribution';

onLCP((metric) => {
  const a = metric.attribution;
  console.log('LCP 元素:', a.element);              // 具体 DOM 节点选择器
  console.log('资源 URL:', a.url);
  console.log('TTFB:', a.timeToFirstByte);
  console.log('资源加载延迟:', a.resourceLoadDelay);
  console.log('资源加载时间:', a.resourceLoadDuration);
  console.log('元素渲染延迟:', a.elementRenderDelay);
});

CLS 的 attribution 直接给出贡献最大的偏移源:

javascriptCode
import { onCLS } from 'web-vitals/attribution';

onCLS((metric) => {
  const a = metric.attribution;
  console.log('最大偏移源:', a.largestShiftTarget);   // 罪魁选择器
  console.log('该偏移得分:', a.largestShiftValue);
  console.log('发生时间:', a.largestShiftTime);
});

> 实战建议:生产 RUM 用普通版(体积小),另开一小部分采样流量用 attribution 版做深度归因,把 `interactionTarget`、`element`、`largestShiftTarget` 上报,就能在大盘里直接看到"哪个按钮、哪张图、哪个元素"最拖后腿。

---

🛠️ 工具实操:Lighthouse / PageSpeed / Search Console

| 工具 | 数据类型 | 适用场景 | 关键提示 |

| --- | --- | --- | --- |

| Lighthouse(DevTools/CLI/CI) | 实验室 | 本地调试、CI 回归、逐项 opportunities 诊断 | 分数是加权合成,别只盯总分;用 CI 卡阈值 |

| PageSpeed Insights | 实验室 + 字段(CrUX) | 单页快速体检,既看 Lab 又看真实 CrUX | 上方 Field 才是排名依据,下方 Lab 用来找原因 |

| Search Console(核心网页指标报告) | 字段(CrUX) | 全站规模化监控、按 URL 分组看红黄绿 | 数据有 28 天窗口延迟;按"来源组"批量修复 |

| CrUX Dashboard / API | 字段 | 趋势分析、竞品对比、按国家/设备下钻 | 数据源与 Search Console 一致 |

| WebPageTest | 实验室 | 多地点、多网络、瀑布图深度分析 | 适合定位 TTFB、资源阻塞、第三方影响 |

Lighthouse CI 卡关,防止性能回归合入主干:

javascriptCode
// lighthouserc.js —— 在 CI 中对 CWV 相关指标设阈值
module.exports = {
  ci: {
    collect: { url: ['https://example.com/'], numberOfRuns: 3 },
    assert: {
      assertions: {
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time': ['error', { maxNumericValue: 200 }],
        'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
      },
    },
    upload: { target: 'temporary-public-storage' },
  },
};

用 CrUX API 拉取真实用户 p75 数据接入自有大盘:

javascriptCode
// 调用 CrUX API 获取某 origin 的字段数据(需 API Key)
async function fetchCrux(origin, apiKey) {
  const res = await fetch(
    'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=' + apiKey,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ origin, metrics: [
        'largest_contentful_paint',
        'interaction_to_next_paint',
        'cumulative_layout_shift',
      ] }),
    }
  );
  const data = await res.json();
  // data.record.metrics.interaction_to_next_paint.percentiles.p75
  return data.record?.metrics;
}

---

⚙️ 框架实践:Next.js 中的 CWV 优化

现代框架把很多最佳实践内置了,用对 API 事半功倍。

javascriptCode
// next/image 自动处理尺寸、懒加载、格式协商、避免 CLS
import Image from 'next/image';

export function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="首屏主图"
      width={1200}
      height={600}
      priority                 // 首屏图:预加载 + 高优先级,利好 LCP
      placeholder="blur"       // 模糊占位,避免布局跳动
      sizes="(max-width: 768px) 100vw, 1200px"
    />
  );
}
javascriptCode
// next/font 自托管字体,自动 size-adjust + font-display,几乎消除字体导致的 CLS
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });

export default function RootLayout({ children }) {
  return <html className={inter.className}><body>{children}</body></html>;
}
javascriptCode
// next/script 控制第三方脚本加载时机,保护主线程,利好 INP/TBT
import Script from 'next/script';

export function Analytics() {
  return (
    <Script
      src="https://example.com/analytics.js"
      strategy="lazyOnload"   // 空闲时再加载,不阻塞交互
    />
  );
}
javascriptCode
// app/layout 中用内置 useReportWebVitals 上报(App Router)
'use client';
import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    navigator.sendBeacon?.('/rum', JSON.stringify(metric));
  });
  return null;
}

打包层面用 splitChunks 拆分第三方依赖,减小主包、降低阻塞(注意 webpack 正则的转义写法):

javascriptCode
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: { compress: { drop_console: true } },
      }),
    ],
  },
  performance: {
    hints: 'warning',
    maxEntrypointSize: 512000,
    maxAssetSize: 512000,
  },
};

---

⚠️ 常见坑(踩坑清单)

| 坑 | 现象 | 正确做法 |

| --- | --- | --- |

| 只看 Lighthouse 总分 | Lab 满分但线上报红 | 以字段数据(CrUX/RUM)p75 为准 |

| 给首屏 LCP 图加 loading="lazy" | LCP 反而变慢 | 首屏图用 eager + fetchpriority=high |

| LCP 图用 CSS 背景或 JS 注入 | 预加载扫描器发现不到,加载延迟大 | 用真实 img 标签,或 preload |

| 用 FID 好看当交互没问题 | INP 却是 poor | 直接监控 INP,FID 已被取代 |

| onChange 里做重计算 | 输入卡顿,INP 高 | useDeferredValue / 分片 / yield 让路 |

| 图片/广告不设尺寸 | 加载后布局乱跳,CLS 高 | 显式 width/height 或 aspect-ratio |

| 用 top/left 做动画 | 触发布局,掉帧 + 偏移 | 只用 transform / opacity |

| 忽略 p75、只看均值 | 慢设备用户被平均掉 | 按 p75 评估,分设备/网络下钻 |

| 优化后立刻看 CrUX 没变 | 误以为没效果 | CrUX 为 28 天窗口,需等待数据滚动 |

| 第三方脚本同步阻塞 | TBT/INP 双高 | defer/async/lazyOnload,隔离到空闲期 |

---

✅ 最佳实践总纲

先测量,后优化,再验证。 用 Lab 找问题、改问题,用 Field 确认真解决,形成闭环。
优先级排序:一般按 LCP → INP → CLS 的顺序处理,但要以你自己 RUM 里"红得最多"的指标为准。
LCP:尽早发现关键资源(preload + fetchpriority),现代图片格式(AVIF/WebP),优化 TTFB,首屏图不要懒加载。
INP:拆分长任务、先给视觉反馈再算、让出主线程(scheduler.yield/postTask)、React 用 startTransition/useDeferredValue、隔离第三方脚本。
CLS:一切媒体/动态位都预留尺寸,动画只用 transform,字体用 size-adjust + 合适的 font-display。
持续监控:建自有 RUM,attribution 采样归因,Lighthouse CI 卡回归,Search Console 看全站趋势。
按 p75 评估,覆盖真实设备与网络分布,别被平均值骗。
紧跟指标演进:CWV 会变(FID→INP 就是例证),保持对 Google Web Vitals 官方更新的关注。

---

🚀 LCP 深度优化专题

前文讲了 LCP 的四段构成与基础手段,这里针对每一段给出更系统的优化清单与代码。

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

TTFB 慢,LCP 几乎不可能快。TTFB 是 LCP 的"地基"。

javascriptCode
// 1. 边缘缓存:把 HTML/资源放到离用户最近的 CDN 边缘节点
// 2. 服务端缓存:页面级/片段级缓存,避免每次都走数据库
// 3. 流式 SSR:不等整页渲染完,先把 <head> 和首屏骨架发出去
// import { renderToPipeableStream } from 'react-dom/server';
// const { pipe } = renderToPipeableStream(<App />, {
//   onShellReady() {
//     res.statusCode = 200;
//     res.setHeader('Content-Type', 'text/html');
//     pipe(res); // 首屏 shell 立即开始传输,TTFB 更早
//   },
// });

// 4. 早期提示 103 Early Hints:在服务器还在生成 HTML 时,先让浏览器预连接/预加载
// HTTP/1.1 103 Early Hints
// Link: </hero.webp>; rel=preload; as=image

TTFB 拆解监控(attribution):

javascriptCode
import { onTTFB } from 'web-vitals/attribution';

onTTFB((metric) => {
  const a = metric.attribution;
  console.log('DNS 耗时:', a.dnsDuration);
  console.log('连接耗时:', a.connectionDuration);
  console.log('请求耗时:', a.requestDuration);
  console.log('等待耗时:', a.waitingDuration); // 服务器处理时间
});

优化第二段:资源加载延迟(发现太晚)

核心:让浏览器的预加载扫描器(Preload Scanner)尽早发现 LCP 资源。

htmlCode
<!-- 优先级提示:把 LCP 图标为最高优先级,把非关键图降级 -->
<img src="/hero.webp" fetchpriority="high" alt="首屏主图">
<img src="/decoration.webp" fetchpriority="low" alt="装饰图">

<!-- 对通过 CSS 背景加载的 LCP 图,用 preload 弥补扫描器发现不到的问题 -->
<link rel="preload" as="image" href="/hero-bg.webp" fetchpriority="high">

<!-- 对响应式背景图,用 imagesrcset 让 preload 也能选对尺寸 -->
<link
  rel="preload"
  as="image"
  href="/hero-800.webp"
  imagesrcset="/hero-480.webp 480w, /hero-800.webp 800w, /hero-1200.webp 1200w"
  imagesizes="100vw"
>

避免拖慢发现的常见反模式:

javascriptCode
// ❌ 用 JS 动态创建并插入 LCP 图(扫描器发现不到,等 JS 执行才开始下载)
const img = document.createElement('img');
img.src = '/hero.jpg';
document.querySelector('.hero').appendChild(img);

// ❌ 把 LCP 图放在懒加载库里(首屏图被延迟)
// <img data-src="/hero.jpg" class="lazyload">

// ✅ 首屏 LCP 图用真实 img 标签 + eager + fetchpriority
// <img src="/hero.jpg" loading="eager" fetchpriority="high" width="1200" height="600">

优化第三段:资源加载时间(下载太慢)

javascriptCode
// 1. 现代格式:AVIF < WebP < JPEG(体积),优先 AVIF
// 2. 响应式图片:按视口/DPR 发送合适尺寸,不给手机发桌面大图
// 3. 图片 CDN:动态裁剪/压缩/格式协商(如 ?w=800&format=auto&quality=75)
// 4. 压缩:合理的质量(75-85 通常肉眼无损)
// 5. HTTP/2/3:多路复用,减少队头阻塞

// 计算合适质量与格式的经验:
// - 照片类:AVIF q50-60 或 WebP q75
// - 图形/插画:WebP 或 PNG(有透明)
// - 图标:SVG

优化第四段:元素渲染延迟(下载完却没画出来)

javascriptCode
// 渲染延迟常见原因:
// 1. 资源已下载,但被阻塞渲染的 CSS/JS 卡住了主线程
// 2. 客户端渲染(CSR):要等 JS 下载执行、水合完才显示内容
// 3. 字体阻塞:文本 LCP 因等自定义字体而延迟

// 对策:
// - 内联关键 CSS,延后非关键 JS(defer/async)
// - 用 SSR/SSG 让内容不依赖 JS 即可显示
// - 字体用 font-display: optional/swap + 预加载
// - 减少首屏 JS 体积,避免长任务阻塞首帧

bfcache(往返缓存)与 LCP

bfcache 是浏览器把整个页面(含 JS 堆、DOM)快照缓存起来,用户点"后退/前进"时瞬间恢复,LCP 近乎为 0。要确保页面 bfcache 友好。

javascriptCode
// 让页面 bfcache 友好:
// 1. 不使用 unload 事件(会禁用 bfcache),改用 pagehide/visibilitychange
addEventListener('pagehide', saveState);
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flushAnalytics();
});

// 2. 监听 bfcache 恢复,刷新可能过期的数据
addEventListener('pageshow', (event) => {
  if (event.persisted) {
    // 从 bfcache 恢复,重新拉取实时数据
    refreshDynamicContent();
  }
});

// 3. 避免长期持有的连接(如未关闭的 WebSocket)阻止 bfcache

⚡ INP 深度优化专题

拆解三段延迟,各个击破

javascriptCode
// 输入延迟(Input Delay):主线程忙 → 事件回调迟迟不能执行
// 优化:拆分/避免长任务、延后第三方脚本、减少启动时 JS

// 处理时间(Processing Time):回调逻辑本身慢
// 优化:算法优化、把重活拆片/让路/移到 Worker、避免同步大计算

// 呈现延迟(Presentation Delay):回调后到下一帧渲染慢
// 优化:减少 DOM 更新量、用 content-visibility、避免大范围重排

第三方脚本治理(INP/TBT 头号元凶)

javascriptCode
// 第三方脚本(广告、统计、客服、AB 测试)常年占用主线程,是 INP 杀手

// 1. 延后加载:非关键脚本用 defer/async 或空闲时加载
function loadScriptWhenIdle(src) {
  requestIdleCallback(() => {
    const s = document.createElement('script');
    s.src = src;
    s.async = true;
    document.body.appendChild(s);
  }, { timeout: 3000 });
}

// 2. 用 Partytown 把第三方脚本移到 Web Worker,彻底释放主线程
// <script type="text/partytown" src="https://analytics.example.com/a.js"></script>

// 3. facade 模式:先放一张"假的"轻量占位(如视频封面、聊天按钮),
//    用户交互时才加载真正的重型第三方组件
button.addEventListener('click', () => import('./load-chat-widget'), { once: true });

// 4. 用 iframe 隔离,把第三方对主页面主线程的影响降到最低

React 场景的 INP 优化

javascriptCode
import { useState, useTransition, useDeferredValue, memo } from 'react';

// 1. useTransition:把非紧急更新标记为可中断,保持输入流畅
function Filter({ items }) {
  const [text, setText] = useState('');
  const [list, setList] = useState(items);
  const [isPending, startTransition] = useTransition();

  function onChange(e) {
    setText(e.target.value); // 紧急:输入框立即更新
    startTransition(() => {
      // 非紧急:大列表过滤在后台并发进行,可被打断
      setList(items.filter((x) => x.includes(e.target.value)));
    });
  }
  return <input value={text} onChange={onChange} />;
}

// 2. useDeferredValue:延迟派生一个"较旧"的值给重型子树
function Search({ allItems }) {
  const [query, setQuery] = useState('');
  const deferred = useDeferredValue(query);
  const results = useMemo(() => filter(allItems, deferred), [allItems, deferred]);
  return <><input onChange={(e) => setQuery(e.target.value)} /><Results data={results} /></>;
}

// 3. memo + 稳定引用:避免大列表在每次交互时整体重渲染
const Row = memo(function Row({ item }) { return <li>{item.name}</li>; });

// 4. 虚拟化长列表:只渲染可视项,减少呈现延迟
// 用 react-window / @tanstack/react-virtual

优化交互的"先反馈后计算"模式

javascriptCode
// 核心思想:用户操作后,先在下一帧给出可见反馈,再做重活
async function onSubmit() {
  // 1. 立即给反馈(禁用按钮、显示 loading),这一帧先绘制出来
  submitBtn.disabled = true;
  spinner.hidden = false;

  // 2. 让出主线程,确保上面的反馈先渲染
  await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
  // 或:await scheduler.yield();

  // 3. 再执行耗时逻辑
  const result = await heavyProcess();
  render(result);
}

📐 CLS 深度优化专题

常见偏移源与系统性预防

cssCode
/* 1. 所有媒体元素预留尺寸 */
img, video, iframe { aspect-ratio: attr(width) / attr(height); }
/* 或显式 width/height,或容器 aspect-ratio */

/* 2. 广告/嵌入位预留最小高度,避免加载后撑开 */
.ad-slot { min-height: 280px; }

/* 3. 骨架屏与真实内容尺寸一致,切换时不跳动 */

/* 4. 字体切换用 size-adjust / ascent-override 对齐后备字体度量 */
@font-face {
  font-family: 'Brand';
  src: url('/brand.woff2') format('woff2');
  font-display: optional;
  size-adjust: 100%;
  ascent-override: 90%;
  descent-override: 20%;
}
javascriptCode
// 5. 动态插入内容用"预留 + 原地填充",不要 append 到已有内容之上
// ✅ 先占位
document.querySelector('#promo-slot').style.minHeight = '120px';
// 就绪后填充
fetch('/promo').then((r) => r.text()).then((html) => {
  document.querySelector('#promo-slot').innerHTML = html; // 原地填充,不顶开
});

// 6. 交互引起的位移放在 500ms 内(算"预期"不计入 CLS),
//    但仍应尽量用 transform 做展开动画,避免真实布局位移

bfcache 恢复与 SPA 软导航的 CLS

javascriptCode
// SPA 路由切换(软导航)也会产生布局偏移,需在新的 web-vitals 中开启软导航支持
// web-vitals v4 起对 SPA 软导航有实验支持,可按路由分别归集 CLS/LCP/INP
// 关键:路由切换时给新页面预留骨架,避免内容陆续到位造成跳动

📱 移动端与弱网专项

javascriptCode
// 移动端是 CWV 的主战场(Google 主要看移动端字段数据)
// 1. 设备更弱:JS 解析执行更慢,长任务更容易超标 → 更要拆分、减 JS
// 2. 网络更差:资源下载慢 → 更要压缩、按需、用现代格式
// 3. 屏幕更小:响应式图片按小视口发小图,别浪费流量
// 4. 触摸交互:INP 关注 tap,事件处理要轻

// 根据网络自适应加载策略
const conn = navigator.connection;
if (conn && (conn.saveData || conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g')) {
  // 弱网/省流量:加载低清图、跳过非关键动画与预取
  loadLowQualityAssets();
} else {
  loadHighQualityAssets();
}

🧪 性能监控体系与预算

分维度下钻分析

javascriptCode
// 只看整体 p75 不够,要按维度下钻定位问题人群
// 上报时带上维度标签:
const dimensions = {
  route: location.pathname,          // 哪个页面
  deviceType: getDeviceType(),       // 移动/桌面
  effectiveType: navigator.connection?.effectiveType, // 网络
  country: getCountry(),             // 地区
  browser: getBrowser(),             // 浏览器
  isLoggedIn: !!getUser(),           // 登录态(不同页面结构)
};
// 服务端按维度聚合 p75,就能发现"某地区/某机型/某页面"的短板

性能预算与 CI 门禁

javascriptCode
// 除了 Lighthouse CI 卡实验室指标,也可用真实数据做发布门禁
// 例:灰度期间监控真实 INP p75,超标则自动回滚
async function checkCanaryVitals() {
  const metrics = await fetchRumP75('canary');
  const budget = { LCP: 2500, INP: 200, CLS: 0.1 };
  const failed = Object.keys(budget).filter((k) => metrics[k] > budget[k]);
  if (failed.length) {
    console.error('灰度性能超标:', failed);
    await rollbackCanary();
  }
}

A/B 实验中的性能观测

javascriptCode
// 新功能上线常伴随性能代价,A/B 时应同时观测业务指标与 CWV
// 把实验分组带进 RUM 上报,对比各组的 LCP/INP/CLS p75
onLCP((m) => report({ ...m, experiment: getExperimentGroup() }));
// 避免"转化涨了但性能崩了"的隐性亏损

🧩 框架与平台的内置能力

javascriptCode
// 除 Next.js 外,主流框架都内置了大量 CWV 最佳实践:
// - Nuxt:<NuxtImg>、自动预加载、字体优化模块
// - Astro:默认零 JS、按需 hydration(islands),天然利好 INP
// - SvelteKit:预加载 link、代码分割、SSR
// - Remix:嵌套路由 + 预取、流式渲染
// - Angular:@defer 块延迟加载、Image 指令

// 通用原则:优先使用框架提供的 Image/Font/Script 组件,
// 它们已内置尺寸预留、懒加载、优先级、格式协商等最佳实践

⚠️ 进阶常见误区

| 误区 | 真相 |

| --- | --- |

| INP 只跟 JS 有关 | 呈现延迟(DOM/样式/布局)也占很大比重 |

| CLS 是加载期的事 | 整个生命周期都算,动态内容/动画都可能贡献 |

| 加了 preload 就一定更快 | 过度 preload 会争抢带宽,反而拖慢关键资源 |

| SSR 一定利好所有指标 | SSR 利好 LCP,但过大的水合 JS 会拖累 INP |

| 第三方脚本无能为力 | facade、Partytown、延迟加载都能显著改善 |

| 桌面达标就行 | Google 主看移动端字段数据 |

| CrUX 没变就是没效果 | CrUX 是 28 天窗口,需等待数据滚动 |

| 单次 Lighthouse 可信 | 单次波动大,应多次取中位或看字段数据 |

✅ 分指标行动清单

LCP 清单:

[ ] LCP 元素用真实 img,不用 JS 注入/懒加载。
[ ] 首屏图 fetchpriority=high + eager,preload 关键资源。
[ ] 现代格式(AVIF/WebP)+ 响应式 + 图片 CDN。
[ ] 优化 TTFB(CDN、缓存、流式 SSR、Early Hints)。
[ ] 内联关键 CSS,延后非关键 JS,字体预加载。
[ ] 页面 bfcache 友好(不用 unload)。

INP 清单:

[ ] 拆分 >50ms 的长任务,交互前让出主线程。
[ ] 先给视觉反馈,再做重活。
[ ] 第三方脚本延后/facade/Partytown 隔离。
[ ] React 用 useTransition/useDeferredValue/memo/虚拟化。
[ ] 减少每次交互引起的 DOM 更新量与重排范围。

CLS 清单:

[ ] 所有图片/视频/iframe/广告预留尺寸。
[ ] 字体用 size-adjust + 合适 font-display。
[ ] 动态内容预留占位,原地填充不顶开。
[ ] 动画只用 transform/opacity。
[ ] SPA 软导航给新页面预留骨架。

🔬 指标计算细节(进阶)

理解指标的精确计算方式,才能避免"优化了却没效果"的困惑。

LCP 的候选与更新机制

javascriptCode
// LCP 会随渲染过程多次更新,取"最大内容元素"的最终渲染时间
// 候选元素:img、image(SVG)、video 封面、CSS 背景图、含文本的块级元素
// 关键点:
// 1. 用户首次交互(滚动/点击/按键)后,LCP 停止更新(之后出现的大元素不算)
// 2. 元素被移除后,它仍可能保留为 LCP(除非有更大的后续元素)
// 3. 视口外的元素不参与;元素的"尺寸"取可见部分

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('LCP 候选:', entry.startTime, entry.size, entry.element);
    // 每次有更大元素渲染,就会新增一条 entry
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

CLS 的会话窗口算法

javascriptCode
// CLS 不是所有偏移的简单相加,而是"会话窗口"中最大的一段之和
// 会话窗口定义:
// - 相邻两次偏移间隔 < 1 秒
// - 窗口总时长 ≤ 5 秒
// - 超出则开启新窗口
// CLS = 所有会话窗口中,偏移得分之和最大的那个窗口的值

// 单次偏移得分 = 影响比例(impact fraction) × 距离比例(distance fraction)
// 影响比例:偏移影响的可视区域占比
// 距离比例:元素移动的最大距离占视口的比例

// 用户交互后 500ms 内的偏移标记 hadRecentInput=true,不计入
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('偏移值:', entry.value, '来源:', entry.sources?.map((s) => s.node));
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

INP 的取值机制

javascriptCode
// INP 观察整个生命周期的所有交互,最终报告一个代表值:
// - 交互数 < 50:取最差(最大)的那次
// - 交互数较多:大致取 p98(每 50 次交互忽略一个最差值)
// 目的:排除极少数离群值,但仍反映"很卡"的真实体验

// event-timing 观察每次交互的耗时
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // duration 是从输入到下一帧的总时长
    if (entry.interactionId && entry.duration > 40) {
      console.log('交互:', entry.name, entry.duration, 'ms', entry.target);
    }
  }
}).observe({ type: 'event', durationThreshold: 40, buffered: true });

🎯 Element Timing 与自定义指标

除了 LCP,有时你想精确测量"某个特定元素"的渲染时间(如首屏关键横幅)。

htmlCode
<!-- 给元素加 elementtiming 属性,即可单独测量它的渲染时间 -->
<img src="/banner.jpg" elementtiming="hero-banner" alt="">
<h1 elementtiming="main-title">重要标题</h1>
javascriptCode
// 观察 element timing
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.identifier, '渲染时间:', Math.round(entry.renderTime), 'ms');
    // hero-banner 渲染时间: 1240 ms
  }
}).observe({ type: 'element', buffered: true });
javascriptCode
// User Timing:标记业务关键时刻,纳入性能分析
performance.mark('app-interactive');
performance.measure('boot-to-interactive', 'navigationStart', 'app-interactive');

// 自定义指标:如"首屏商品列表可见"
performance.mark('products-visible');
const measure = performance.measure('time-to-products', 'navigationStart', 'products-visible');
report({ name: 'time-to-products', value: measure.duration });

📡 生产级 RUM 完整实现

下面是一套更完整、可直接落地的 RUM 采集与上报方案,覆盖采样、去重、批量、上下文与错误关联。

javascriptCode
// rum-pro.js —— 生产级真实用户监测
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';

class RUM {
  constructor(options = {}) {
    this.endpoint = options.endpoint || '/rum';
    this.sampleRate = options.sampleRate ?? 1;     // 采样率,控制上报量
    this.queue = [];
    this.context = this.collectContext();
    this.sampled = Math.random() < this.sampleRate;
    if (this.sampled) this.init();
  }

  collectContext() {
    const nav = performance.getEntriesByType('navigation')[0];
    return {
      url: location.pathname,
      referrer: document.referrer,
      deviceMemory: navigator.deviceMemory,
      hardwareConcurrency: navigator.hardwareConcurrency,
      effectiveType: navigator.connection?.effectiveType,
      saveData: navigator.connection?.saveData,
      dpr: window.devicePixelRatio,
      viewport: window.innerWidth + 'x' + window.innerHeight,
      navigationType: nav?.type,
    };
  }

  add(metric) {
    this.queue.push({
      name: metric.name,
      value: Math.round(metric.value * 1000) / 1000,
      rating: metric.rating,
      id: metric.id,
      delta: metric.delta,
      ...this.context,
      ts: Date.now(),
    });
  }

  flush() {
    if (this.queue.length === 0) return;
    const body = JSON.stringify(this.queue);
    // sendBeacon 保证卸载时也能送达;失败回退 fetch keepalive
    const ok = navigator.sendBeacon && navigator.sendBeacon(this.endpoint, body);
    if (!ok) {
      fetch(this.endpoint, { method: 'POST', body, keepalive: true }).catch(() => {});
    }
    this.queue = [];
  }

  init() {
    onLCP((m) => this.add(m));
    onINP((m) => this.add(m));
    onCLS((m) => this.add(m));
    onTTFB((m) => this.add(m));
    onFCP((m) => this.add(m));

    // 页面隐藏时统一冲刷(SPA/多标签场景都可靠)
    addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') this.flush();
    });
    addEventListener('pagehide', () => this.flush());
  }
}

new RUM({ endpoint: '/rum', sampleRate: 0.2 }); // 20% 采样
javascriptCode
// 服务端聚合(伪代码):按维度算 p75,输出达标看板
function aggregate(samples) {
  const groups = groupBy(samples, (s) => s.url + '|' + s.effectiveType);
  const report = {};
  for (const [key, list] of Object.entries(groups)) {
    report[key] = {};
    for (const metric of ['LCP', 'INP', 'CLS']) {
      const values = list.filter((s) => s.name === metric).map((s) => s.value);
      report[key][metric] = {
        p75: percentile(values, 75),
        p95: percentile(values, 95),
        count: values.length,
      };
    }
  }
  return report;
}

function percentile(values, p) {
  if (!values.length) return null;
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p / 100))];
}

🌐 网络与协议层面的收益

javascriptCode
// HTTP/2:多路复用,一个连接并发多请求,消除队头阻塞 → 利好 LCP
// HTTP/3(QUIC):基于 UDP,连接建立更快、弱网/切换网络更稳 → 利好 TTFB/LCP
// 关键动作:
// 1. 全站 HTTPS + HTTP/2/3(现代 CDN 默认支持)
// 2. 减少域名分片(HTTP/2 下分片反而增加连接开销)
// 3. preconnect 关键第三方域,省去建连时间

// Resource Timing:分析每个资源的加载瓶颈
performance.getEntriesByType('resource')
  .filter((r) => r.duration > 500)
  .forEach((r) => {
    console.log(r.name, {
      dns: Math.round(r.domainLookupEnd - r.domainLookupStart),
      tcp: Math.round(r.connectEnd - r.connectStart),
      ttfb: Math.round(r.responseStart - r.requestStart),
      download: Math.round(r.responseEnd - r.responseStart),
    });
  });
javascriptCode
// Server Timing:让服务端把内部耗时通过响应头暴露给前端
// 响应头:Server-Timing: db;dur=53, cache;dur=5, render;dur=120
performance.getEntriesByType('navigation')[0]?.serverTiming?.forEach((t) => {
  console.log(t.name, t.duration, 'ms', t.description);
});
// 这样前端 RUM 就能关联到"慢在服务端哪一段"

🗂️ Service Worker 与 CWV

javascriptCode
// Service Worker 缓存能极大改善二次访问的 TTFB/LCP
// 但要避免它拖慢首次访问或阻塞导航

// 预缓存关键资源(App Shell)
// workbox: precacheAndRoute(self.__WB_MANIFEST);

// 运行时策略:
// - 静态资源(带 hash):CacheFirst(长期缓存)
// - HTML:NetworkFirst(保证新鲜,失败回退缓存)
// - 图片:StaleWhileRevalidate(先给缓存,后台更新)

// 注意:SW 注册本身要延后,避免与首屏关键资源争抢
addEventListener('load', () => {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js');
  }
});

📚 完整案例研究

案例一:电商首页 LCP 从 4.3s 到 1.8s

背景: 移动端 LCP p75 为 4.3s(poor),跳出率高。

诊断(attribution): 资源加载延迟占 2.1s——LCP 主图是 CSS 背景图 + JS 注入,扫描器发现不到;且首图是 1.1MB 的 JPEG。

措施与逐项收益:

| 措施 | LCP 改善 |

| --- | --- |

| 背景图改真实 img + fetchpriority=high | -1.2s |

| JPEG(1.1MB) 换 AVIF(140KB) | -0.7s |

| preconnect 图片 CDN | -0.2s |

| 内联关键 CSS,defer 非关键 JS | -0.4s |

| 合计 | 4.3s → 1.8s |

业务结果: 移动端跳出率下降 18%,转化率提升 5.4%。

案例二:SaaS 后台 INP 从 480ms 到 150ms

背景: 复杂表格页交互卡顿,INP p75 为 480ms(poor)。

诊断(attribution): 处理时间占大头——每次筛选同步过滤并重渲染 2 万行;第三方埋点脚本频繁占用主线程。

措施与逐项收益:

| 措施 | INP 改善 |

| --- | --- |

| 表格虚拟化(只渲染可视行) | -180ms |

| 筛选改 useDeferredValue + 分片 | -100ms |

| 埋点脚本用 Partytown 移到 Worker | -50ms |

| 交互先给 loading 反馈再计算 | 感知大幅提升 |

| 合计 | 480ms → 150ms |

案例三:新闻站 CLS 从 0.28 到 0.03

背景: 阅读时内容频繁跳动,CLS p75 为 0.28(poor)。

诊断(attribution): 最大偏移源是文中广告位与未设尺寸的插图,以及字体切换。

措施与逐项收益:

| 措施 | CLS 改善 |

| --- | --- |

| 所有插图补 width/height + aspect-ratio | -0.14 |

| 广告位预留 min-height | -0.08 |

| 字体 size-adjust + font-display: optional | -0.03 |

| 合计 | 0.28 → 0.03 |

🧭 优化实施总流程

javascriptCode
// 一次完整的 CWV 治理闭环:
// 1. 定位:Search Console/CrUX 看全站哪些页面组红;RUM 按维度下钻
// 2. 归因:web-vitals attribution 定位到具体元素/阶段/脚本
// 3. 复现:用 Lighthouse/WebPageTest 在实验室复现并调试
// 4. 修复:针对具体指标与阶段施策(见分指标清单)
// 5. 验证:先看实验室是否改善,再等 CrUX/RUM 字段数据确认
// 6. 守护:Lighthouse CI 卡实验室、RUM 告警卡真实、性能预算防回归

🛠️ 工具链深入

Lighthouse 分数解读

javascriptCode
// Lighthouse 性能总分是多个实验室指标的加权合成(权重会随版本调整,约为):
// - TBT(总阻塞时间):约 30%(INP 的实验室代理)
// - LCP:约 25%
// - CLS:约 25%
// - FCP:约 10%
// - Speed Index:约 10%
// 提示:别只盯总分!要看下方 Opportunities(优化机会)与 Diagnostics(诊断)
// 单次跑分波动大,用 3-5 次中位数,或以字段数据为准
bashCode
# Lighthouse CLI 批量跑与输出 JSON
# npx lighthouse https://example.com --output=json --output-path=./report.json \
#   --only-categories=performance --form-factor=mobile --throttling-method=simulate

WebPageTest 深度分析

javascriptCode
// WebPageTest 的独特价值:
// 1. 多地点、多真实设备、多网络档位测试
// 2. 详细瀑布图:逐资源看 DNS/连接/TTFB/下载/阻塞
// 3. Filmstrip(胶片视图):逐帧看页面视觉变化,直观定位 LCP
// 4. Connection View:看连接复用与协议
// 5. 可对比两次测试(before/after)
// 适合深挖 TTFB、资源阻塞、第三方影响、渲染时序

工具选择速查

| 需求 | 推荐工具 |

| --- | --- |

| 快速本地体检 | Lighthouse(DevTools) |

| 看真实用户 + 实验室 | PageSpeed Insights |

| 全站规模化监控 | Search Console |

| 趋势/竞品/下钻 | CrUX Dashboard / API |

| 深度瀑布分析 | WebPageTest |

| CI 防回归 | Lighthouse CI |

| 归因到元素/阶段 | web-vitals attribution |

| 自有实时监控 | 自建 RUM |

💬 各指标常见问答

LCP FAQ

Q:LCP 元素一直在变,以哪个为准?

以用户首次交互前"最大内容元素"的最终渲染时间为准。交互后不再更新。

Q:把首图变小就能优化 LCP 吗?

不一定。若瓶颈在"发现太晚"或"TTFB 慢"或"渲染被阻塞",仅压缩图片收效有限。要先用 attribution 看是哪一段最长。

Q:文本也能是 LCP 吗?

能。包含文本的块级元素是候选。文本 LCP 常受字体加载与阻塞渲染的 CSS/JS 影响。

INP FAQ

Q:为什么 FID 好但 INP 差?

FID 只测首次交互的输入延迟,往往偏乐观。INP 测所有交互的完整响应(含处理与呈现),能暴露中途卡顿。

Q:防抖能优化 INP 吗?

防抖减少了"执行次数",但单次交互若仍触发重活,INP 依旧高。关键是让单次交互轻、快、先反馈。

Q:INP 测不到怎么办?

INP 是字段指标,需真实交互。实验室用 TBT 作代理,或在 DevTools 里手动交互并看 Performance。

CLS FAQ

Q:为什么我的动画导致 CLS 高?

若动画改变了 top/left/width/height/margin 等布局属性,会产生真实布局偏移。改用 transform/opacity。

Q:轮播图算 CLS 吗?

用 transform 切换的轮播不算;若每次切换改变布局尺寸则算。

Q:骨架屏能降 CLS 吗?

能,前提是骨架屏与真实内容尺寸一致,切换时不产生位移。

🏢 团队落地建议

javascriptCode
// CWV 治理不只是技术问题,也是组织问题:
// 1. 定责任人:每个核心页面组有明确的性能负责人
// 2. 设看板:把 RUM p75 做成团队可见的大盘,红黄绿一目了然
// 3. 进流程:性能预算纳入 CI 门禁,超标 PR 不能合
// 4. 定节奏:定期性能评审,跟踪趋势与专项治理
// 5. 建文化:新功能上线要评估性能代价,A/B 同时看 CWV
// 6. 做培训:让团队理解指标含义与优化手段,避免反复踩坑

性能治理成熟度模型:

| 阶段 | 特征 | 目标 |

| --- | --- | --- |

| 无意识 | 出问题才救火 | 建立基础监控 |

| 被动 | 有监控但不看 | 建看板、定责任人 |

| 主动 | 定期评审、专项治理 | 进 CI 门禁 |

| 预防 | 性能预算、A/B 观测 | 防回归常态化 |

| 卓越 | 性能是产品文化 | 持续领先 |

📖 术语速查表

| 术语 | 含义 |

| --- | --- |

| CWV | Core Web Vitals,核心网页指标 |

| LCP | 最大内容绘制,衡量加载 |

| INP | 交互到下次绘制,衡量交互响应 |

| FID | 首次输入延迟,已被 INP 取代 |

| CLS | 累积布局偏移,衡量视觉稳定 |

| TTFB | 首字节时间 |

| FCP | 首次内容绘制 |

| TBT | 总阻塞时间,INP 实验室代理 |

| Field Data | 字段数据,真实用户 |

| Lab Data | 实验室数据,合成监测 |

| CrUX | Chrome 用户体验报告 |

| RUM | 真实用户监测 |

| p75 | 第 75 百分位,达标判定口径 |

| bfcache | 往返缓存 |

| attribution | web-vitals 归因构建 |

| Element Timing | 特定元素渲染计时 |

🎓 学习与实践建议

从测量开始:先接入 web-vitals 与 Search Console,看清自己的现状与短板。
一次专注一个指标:按 RUM 里最红的指标优先治理,逐个击破。
用 attribution 归因:不要盲猜,让数据告诉你卡在哪个元素、哪个阶段。
实验室调试、字段验证:形成"复现-修复-验证"的闭环。
盯住 p75 与移动端:这是 Google 的判定口径与主战场。
建立长效机制:CI 门禁 + RUM 告警 + 性能预算,让优化成果不回退。
跟进指标演进:FID→INP 已是例证,保持对官方更新的关注。

🧰 更多可复用优化代码

图片优化封装

javascriptCode
// 一个通用的响应式图片生成器(配合图片 CDN)
function responsiveImage({ src, alt, sizes = '100vw', priority = false, cdn }) {
  const widths = [480, 768, 1024, 1280, 1920];
  const srcset = widths
    .map((w) => cdn + '?url=' + encodeURIComponent(src) + '&w=' + w + '&format=auto&q=75 ' + w + 'w')
    .join(', ');
  const img = document.createElement('img');
  img.src = cdn + '?url=' + encodeURIComponent(src) + '&w=1024&format=auto&q=75';
  img.srcset = srcset;
  img.sizes = sizes;
  img.alt = alt;
  img.decoding = 'async';
  if (priority) {
    img.loading = 'eager';
    img.fetchPriority = 'high';
  } else {
    img.loading = 'lazy';
  }
  return img;
}

字体加载优化封装

javascriptCode
// 两阶段字体加载:先渲染后备字体,字体就绪后统一切换,减少 FOUT 抖动
async function loadFonts(fonts) {
  // fonts: [{ family, url, weight }]
  const faces = fonts.map((f) => {
    const face = new FontFace(f.family, 'url(' + f.url + ')', { weight: f.weight });
    return face.load();
  });
  try {
    const loaded = await Promise.all(faces);
    loaded.forEach((f) => document.fonts.add(f));
    document.documentElement.classList.add('fonts-loaded');
  } catch (e) {
    // 字体加载失败,保持后备字体
  }
}

// CSS 配合:默认后备字体,加载完成后再应用自定义字体
// html { font-family: system-ui, sans-serif; }
// html.fonts-loaded { font-family: 'Brand', system-ui, sans-serif; }

SPA 软导航的指标测量

javascriptCode
// SPA 路由切换没有真正的页面加载,需要手动测量"软导航"性能
class SoftNavMetrics {
  constructor() {
    this.navStart = 0;
  }

  // 路由切换开始时调用
  onRouteChangeStart() {
    this.navStart = performance.now();
    // 清空上一路由的 LCP 观察
    this.observeLCP();
  }

  observeLCP() {
    let lcpValue = 0;
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      lcpValue = entries[entries.length - 1].startTime;
    });
    observer.observe({ type: 'largest-contentful-paint', buffered: false });

    // 路由内容渲染完后计算相对 LCP
    requestAnimationFrame(() => requestAnimationFrame(() => {
      const softLCP = performance.now() - this.navStart;
      report({ name: 'soft-navigation-lcp', value: softLCP, route: location.pathname });
      observer.disconnect();
    }));
  }
}

智能预取(提升后续导航的感知性能)

javascriptCode
// 1. 悬停预取:鼠标悬停链接时预取目标页面资源
document.querySelectorAll('a[data-prefetch]').forEach((link) => {
  link.addEventListener('mouseenter', () => {
    const l = document.createElement('link');
    l.rel = 'prefetch';
    l.href = link.href;
    document.head.appendChild(l);
  }, { once: true });
});

// 2. 视口预取:链接进入视口时预取(配合网络判断,弱网不预取)
const conn = navigator.connection;
if (!conn || (!conn.saveData && conn.effectiveType === '4g')) {
  const io = new IntersectionObserver((entries) => {
    entries.forEach((e) => {
      if (e.isIntersecting) {
        const l = document.createElement('link');
        l.rel = 'prefetch';
        l.href = e.target.href;
        document.head.appendChild(l);
        io.unobserve(e.target);
      }
    });
  });
  document.querySelectorAll('a[data-prefetch-viewport]').forEach((a) => io.observe(a));
}

Speculation Rules API(预渲染下一页)

htmlCode
<!-- 现代预渲染:浏览器可提前渲染下一页,导航时近乎瞬时(LCP 极低) -->
<script type="speculationrules">
{
  "prerender": [
    {
      "where": { "href_matches": "/product/*" },
      "eagerness": "moderate"
    }
  ],
  "prefetch": [
    {
      "where": { "href_matches": "/*" },
      "eagerness": "conservative"
    }
  ]
}
</script>

自适应加载(根据设备/网络降级)

javascriptCode
// 根据设备能力与网络,动态决定加载策略,保护低端设备的 CWV
function getLoadingStrategy() {
  const conn = navigator.connection;
  const memory = navigator.deviceMemory || 4;
  const cores = navigator.hardwareConcurrency || 4;

  const isLowEnd = memory <= 2 || cores <= 2;
  const isSlowNet = conn && (conn.saveData ||
    conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g');

  if (isLowEnd || isSlowNet) {
    return {
      imageQuality: 'low',
      enableAnimations: false,   // 关闭非必要动画,省 CPU
      prefetch: false,           // 不预取,省流量
      lazyThreshold: '0px',      // 更保守的懒加载
    };
  }
  return {
    imageQuality: 'high',
    enableAnimations: true,
    prefetch: true,
    lazyThreshold: '400px',
  };
}

优先级提示(Priority Hints)完整用法

htmlCode
<!-- fetchpriority 可用于 img、link、script、iframe、以及 fetch() -->
<img src="/hero.webp" fetchpriority="high" alt="首屏主图">
<img src="/carousel-2.webp" fetchpriority="low" alt="非首屏轮播">
<link rel="preload" as="script" href="/critical.js" fetchpriority="high">
<script src="/analytics.js" fetchpriority="low" defer></script>
javascriptCode
// fetch 也支持 priority
fetch('/api/critical-data', { priority: 'high' });
fetch('/api/analytics', { priority: 'low' });

⚖️ 指标间的相互影响与权衡

优化不是孤立的,一个手段可能同时影响多个指标,有时甚至此消彼长。理解这些关联,才能做出全局最优的决策。

| 优化手段 | LCP | INP | CLS | 说明 |

| --- | --- | --- | --- | --- |

| SSR/SSG | 改善 | 可能变差 | 改善 | 内容早现但水合 JS 可能拖累交互 |

| 代码分割 | 改善 | 改善 | 中性 | 首屏更小、主线程更轻 |

| 图片懒加载 | 首屏图误用会变差 | 中性 | 需配尺寸否则变差 | 首屏图不要懒加载 |

| 预加载(preload) | 用对改善 | 中性 | 中性 | 过度会争抢带宽 |

| 第三方脚本延后 | 中性/改善 | 改善 | 改善 | 释放主线程 |

| 骨架屏 | 感知改善 | 中性 | 尺寸不一致会变差 | 骨架要与内容等尺寸 |

| 字体自定义 | 文本 LCP 可能变差 | 中性 | 切换会变差 | 用 size-adjust + 预加载 |

| 动画特效 | 中性 | 复杂动画变差 | 布局动画变差 | 只用 transform/opacity |

典型权衡场景:

javascriptCode
// 场景一:CSR vs SSR
// CSR:首屏白等 JS(LCP 差),但一旦加载完交互可能很快
// SSR:内容早现(LCP 好),但大量水合 JS 可能让 INP 变差
// 平衡:SSR + 渐进/选择性水合(islands)+ 减少首屏 JS

// 场景二:预加载的度
// 适度 preload 关键资源 → LCP 改善
// 过度 preload(把一堆资源都设 high)→ 争抢带宽,关键资源反而慢
// 原则:只 preload 真正的关键渲染资源(LCP 图、关键字体、关键 CSS)

// 场景三:动画体验 vs 稳定性
// 华丽的入场动画提升观感,但若用布局属性会伤 CLS/INP
// 平衡:动画只用 transform/opacity,弱网/低端设备自动降级

🔁 持续优化的心智模型

javascriptCode
// CWV 优化是一个永不停止的循环,而非一次性项目:
//
//   测量(Measure) → 归因(Diagnose) → 修复(Fix) → 验证(Verify) → 守护(Guard)
//        ↑                                                          │
//        └──────────────────────────────────────────────────────────┘
//
// 每一轮都让指标更接近 good,并通过守护机制防止回退。
// 关键是:用真实用户数据(RUM/CrUX)驱动,而非实验室分数的自我感动。

衡量优化是否成功的标准:

不是"Lighthouse 分数涨了",而是"CrUX/RUM 的 p75 进入 good 区间"。
不是"某台机器上快了",而是"75% 的真实用户体验达标"。
不是"上线时快",而是"持续保持、不因新需求回退"。

🌟 写给不同角色的建议

前端工程师:

掌握每个指标的三段/四段构成,能用 attribution 精准归因。
把优化手段内化为日常编码习惯(图片设尺寸、动画用 transform、交互先反馈)。
主动接入 RUM,用数据说话。

技术负责人:

把性能预算写进 CI,建立 RUM 看板与告警。
为核心页面组指定性能负责人,定期评审。
在需求评审中纳入性能影响评估。

产品经理:

理解 CWV 与业务转化的强相关,把性能当作产品体验的一部分。
在新功能与性能之间做明智权衡,支持必要的性能投入。

🧯 反模式代码集(照着改)

javascriptCode
// 反模式 1:首屏 LCP 图懒加载
// ❌ <img data-src="/hero.jpg" class="lazyload">
// ✅ <img src="/hero.jpg" loading="eager" fetchpriority="high" width="1200" height="600">

// 反模式 2:onChange 里同步跑重活
// ❌
input.addEventListener('input', (e) => {
  results.innerHTML = renderAll(filterHuge(e.target.value)); // 每次输入都卡
});
// ✅ 防抖 + 让路 + 分片,或用 useDeferredValue(React)
input.addEventListener('input', debounce(async (e) => {
  await yieldToMain();
  results.replaceChildren(renderVisible(filterHuge(e.target.value)));
}, 150));

// 反模式 3:动态插入内容顶开正文
// ❌ document.body.insertBefore(banner, article); // 正文被顶下去,CLS 飙升
// ✅ 预留占位再原地填充
// <div id="banner" style="min-height:120px"></div>
document.getElementById('banner').replaceChildren(banner);

// 反模式 4:用 top/left 做动画
// ❌ el.style.transition = 'top .3s'; el.style.top = '100px';
// ✅ el.style.transition = 'transform .3s'; el.style.transform = 'translateY(100px)';

// 反模式 5:同步加载一堆第三方脚本
// ❌ <script src="ads.js"></script><script src="chat.js"></script>
// ✅ 延后/facade/Partytown,交互时或空闲时再加载

// 反模式 6:只在桌面高配机上自测就上线
// ❌ 忽略移动端与弱网 → 真实 p75 报红
// ✅ 用移动档位测试,看 CrUX 移动端字段数据

function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) return scheduler.yield();
  return new Promise((r) => setTimeout(r, 0));
}
function debounce(fn, ms) {
  let t;
  return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}

🗺️ 一页纸速记

三大核心指标:LCP(加载,≤2.5s)、INP(交互,≤200ms)、CLS(稳定,≤0.1)。
判定口径:p75,主看移动端字段数据。
数据两类:实验室找问题、字段验效果,冲突以字段为准。
LCP 抓手:早发现(preload+fetchpriority)、快下载(现代格式)、快渲染(少阻塞)、快响应(TTFB)。
INP 抓手:拆长任务、先反馈后计算、让路主线程、隔离第三方。
CLS 抓手:一切预留尺寸、字体 size-adjust、动画只用 transform。
闭环:测量→归因→修复→验证→守护,用 attribution 精准定位。

📊 总结表格

核心指标速查

| 指标 | 衡量 | good | needs-improvement | poor | 头号优化手段 |

| --- | --- | --- | --- | --- | --- |

| LCP | 主内容可见速度 | ≤ 2.5s | 2.5s~4.0s | > 4.0s | 预加载 + fetchpriority + 图片优化 + TTFB |

| INP | 交互响应(全生命周期) | ≤ 200ms | 200ms~500ms | > 500ms | 拆长任务 + 让路主线程 + 先反馈 |

| CLS | 视觉稳定 | ≤ 0.1 | 0.1~0.25 | > 0.25 | 预留尺寸 + transform 动画 + 字体 size-adjust |

辅助指标速查

| 指标 | 角色 | good | poor | 用途 |

| --- | --- | --- | --- | --- |

| TTFB | LCP 第一段 | ≤ 800ms | > 1800ms | 定位服务器/网络瓶颈 |

| FCP | 首次出现内容 | ≤ 1.8s | > 3.0s | 定位阻塞渲染问题 |

| TBT | INP 的实验室代理 | ≤ 200ms | > 600ms | CI 中把关主线程阻塞 |

| FID | 已被 INP 取代 | ≤ 100ms | > 300ms | 仅作历史参考 |

FID vs INP 一图流

| 维度 | FID | INP |

| --- | --- | --- |

| 覆盖交互 | 仅首次 | 全部交互 |

| 覆盖阶段 | 仅输入延迟 | 输入延迟 + 处理 + 呈现 |

| good 阈值 | 100ms | 200ms |

| 现状 | 2024 年已退役 | 2024 年起为核心指标 |

数据类型与工具对应

| 目标 | 用什么数据 | 用什么工具 |

| --- | --- | --- |

| 复现与调试问题 | 实验室 | Lighthouse、WebPageTest、DevTools |

| 判断是否真解决 | 字段(真实用户) | CrUX、Search Console、自建 RUM |

| CI 防回归 | 实验室 | Lighthouse CI |

| 归因到具体元素/阶段 | 字段 | web-vitals attribution build |

一句话收尾:Core Web Vitals 的本质不是刷分,而是把"用户看得见、点得动、不乱跳"这件事做到位。 用实验室数据找问题、用真实用户数据验证效果、盯住 p75、拥抱 INP 这样的指标演进,才能让优化真正落到用户体验与业务结果上。

最后再强调三点,作为全文的收束:

以用户为中心:所有指标都是"用户真实感受"的量化代理,别为数字而数字,要为人而优化。
以数据为准绳:真实用户字段数据(CrUX/RUM 的 p75)是唯一的裁判,实验室分数只是调试工具。
以机制为保障:把测量、预算、门禁、告警固化到流程里,让性能成为不会退化的团队资产。

当你把这三点变成团队的肌肉记忆,优秀的 Core Web Vitals 就不再是偶尔达标的运气,而是持续交付的必然。

性能优化是一场没有终点的马拉松:浏览器在进化、设备在更替、指标在演进、用户的期待在提高。唯有建立起"测量驱动、数据说话、机制守护"的工程文化,才能在这场长跑中始终保持领先。

行动起来,从今天做的第一步开始:

今天:接入 web-vitals,打开 Search Console 看现状。
本周:定位最红的一个指标,用 attribution 归因并修复。
本月:建起 RUM 看板与 Lighthouse CI 门禁,防止回退。

愿你的每一个页面,都能又快、又稳、又不乱跳,让每一位用户都拥有丝滑顺畅的体验。