Core Web Vitals 核心指标优化
Core Web Vitals 核心指标优化
Core Web Vitals(核心网页指标,简称 CWV)是 Google 从海量真实用户体验数据中提炼出来的一组"以用户为中心"的性能指标。它试图回答三个最朴素的问题:页面主要内容什么时候能看见?(加载)、我点了它多久才回应我?(交互)、内容会不会在我眼皮底下乱跳?(视觉稳定)。
可以把一次网页访问类比成去餐厅吃饭:LCP 相当于"主菜多久端上桌",INP 相当于"你举手叫服务员,他多久走过来搭理你",CLS 相当于"上菜时会不会把你正夹着的菜盘子挪走害你夹空"。三者共同决定了这顿饭(这次访问)体验好不好。
为什么 Core Web Vitals 如此重要
> 一个关键认知: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 天滚动窗口),难以逐次复现 |
---
⚡ LCP(Largest Contentful Paint,最大内容绘制)
定义与阈值
LCP 衡量视口内最大的那个内容元素完成渲染的时间点,是"用户觉得页面主要内容已经出现"的最佳单一代理指标。候选元素包括:``、`
| 评级 | LCP 数值 |
| --- | --- |
| good(良好) | ≤ 2.5 秒 |
| needs-improvement(需改进) | 2.5 秒 ~ 4.0 秒 |
| poor(差) | > 4.0 秒 |
原理:LCP 由四个子阶段构成
Google 把 LCP 拆成四段,优化就是压缩这四段:
> 经验:很多 LCP 问题不是"图片太大",而是"发现太晚"。图片被 JS 动态插入、被懒加载、藏在 CSS 背景里、或排在一堆阻塞脚本后面,都会拉长"资源加载延迟"。
LCP 优化代码
预加载关键资源、预连接关键域名,让浏览器尽早发现并下载 LCP 资源:
<!-- 预加载 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>避免"资源发现太晚"的反面教材与修正:
<!-- 反例: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">用响应式图片按视口发送合适尺寸,减少"资源加载时间":
<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)
// 使用 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)
---
🎯 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:
INP 优化代码
手法一:把非紧急工作让出主线程,先给用户视觉反馈。
// 反例:点击后同步跑重活,界面在计算完成前毫无反馈,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 按优先级调度,切分长任务。
// 把一个长任务拆成多个短任务,并用 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 重渲染对交互的阻塞。
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 降低"呈现延迟"。
/* 屏幕外的长列表区块跳过渲染,减少每帧样式/布局/绘制成本 */
.long-section {
content-visibility: auto;
contain-intrinsic-size: auto 500px; /* 预留高度,避免滚动跳动 */
}INP 监控代码(含长任务定位)
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)
---
📐 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 |
原理:布局偏移从哪来
CLS 优化代码
<!-- 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 事后插入内容:
<!-- 反例:加载后 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 监控代码(含偏移源定位)
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)
---
🔧 辅助诊断指标:TTFB / FCP / TBT
核心指标告诉你"哪里差",辅助指标告诉你"为什么差"。它们不参与排名判定,但在调试时不可或缺。
TTFB(Time to First Byte,首字节时间)
import { onTTFB } from 'web-vitals';
onTTFB((metric) => {
console.log('TTFB:', metric.value, metric.rating);
// attribution 版可拆出 DNS / 连接 / 请求 / 等待 各阶段耗时
});FCP(First Contentful Paint,首次内容绘制)
import { onFCP } from 'web-vitals';
onFCP((metric) => console.log('FCP:', metric.value, metric.rating));TBT(Total Blocking Time,总阻塞时间)
// 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(真实用户监测)。下面是一套可直接落地的方案。
// 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 计算达标情况,并按设备/网络/路由分维度下钻:
// 伪代码:按路由聚合 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 会附带归因信息,直接把问题定位到具体阶段与元素。
// 引入 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 元素:
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 直接给出贡献最大的偏移源:
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 卡关,防止性能回归合入主干:
// 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 数据接入自有大盘:
// 调用 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 事半功倍。
// 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"
/>
);
}// 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>;
}// next/script 控制第三方脚本加载时机,保护主线程,利好 INP/TBT
import Script from 'next/script';
export function Analytics() {
return (
<Script
src="https://example.com/analytics.js"
strategy="lazyOnload" // 空闲时再加载,不阻塞交互
/>
);
}// 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 正则的转义写法):
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,隔离到空闲期 |
---
✅ 最佳实践总纲
---
🚀 LCP 深度优化专题
前文讲了 LCP 的四段构成与基础手段,这里针对每一段给出更系统的优化清单与代码。
优化第一段:TTFB(服务器响应)
TTFB 慢,LCP 几乎不可能快。TTFB 是 LCP 的"地基"。
// 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=imageTTFB 拆解监控(attribution):
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 资源。
<!-- 优先级提示:把 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"
>避免拖慢发现的常见反模式:
// ❌ 用 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">优化第三段:资源加载时间(下载太慢)
// 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优化第四段:元素渲染延迟(下载完却没画出来)
// 渲染延迟常见原因:
// 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 友好。
// 让页面 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 深度优化专题
拆解三段延迟,各个击破
// 输入延迟(Input Delay):主线程忙 → 事件回调迟迟不能执行
// 优化:拆分/避免长任务、延后第三方脚本、减少启动时 JS
// 处理时间(Processing Time):回调逻辑本身慢
// 优化:算法优化、把重活拆片/让路/移到 Worker、避免同步大计算
// 呈现延迟(Presentation Delay):回调后到下一帧渲染慢
// 优化:减少 DOM 更新量、用 content-visibility、避免大范围重排第三方脚本治理(INP/TBT 头号元凶)
// 第三方脚本(广告、统计、客服、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 优化
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优化交互的"先反馈后计算"模式
// 核心思想:用户操作后,先在下一帧给出可见反馈,再做重活
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 深度优化专题
常见偏移源与系统性预防
/* 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%;
}// 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
// SPA 路由切换(软导航)也会产生布局偏移,需在新的 web-vitals 中开启软导航支持
// web-vitals v4 起对 SPA 软导航有实验支持,可按路由分别归集 CLS/LCP/INP
// 关键:路由切换时给新页面预留骨架,避免内容陆续到位造成跳动📱 移动端与弱网专项
// 移动端是 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();
}🧪 性能监控体系与预算
分维度下钻分析
// 只看整体 p75 不够,要按维度下钻定位问题人群
// 上报时带上维度标签:
const dimensions = {
route: location.pathname, // 哪个页面
deviceType: getDeviceType(), // 移动/桌面
effectiveType: navigator.connection?.effectiveType, // 网络
country: getCountry(), // 地区
browser: getBrowser(), // 浏览器
isLoggedIn: !!getUser(), // 登录态(不同页面结构)
};
// 服务端按维度聚合 p75,就能发现"某地区/某机型/某页面"的短板性能预算与 CI 门禁
// 除了 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 实验中的性能观测
// 新功能上线常伴随性能代价,A/B 时应同时观测业务指标与 CWV
// 把实验分组带进 RUM 上报,对比各组的 LCP/INP/CLS p75
onLCP((m) => report({ ...m, experiment: getExperimentGroup() }));
// 避免"转化涨了但性能崩了"的隐性亏损🧩 框架与平台的内置能力
// 除 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 清单:
INP 清单:
CLS 清单:
🔬 指标计算细节(进阶)
理解指标的精确计算方式,才能避免"优化了却没效果"的困惑。
LCP 的候选与更新机制
// 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 的会话窗口算法
// 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 的取值机制
// 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,有时你想精确测量"某个特定元素"的渲染时间(如首屏关键横幅)。
<!-- 给元素加 elementtiming 属性,即可单独测量它的渲染时间 -->
<img src="/banner.jpg" elementtiming="hero-banner" alt="">
<h1 elementtiming="main-title">重要标题</h1>// 观察 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 });// 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 采集与上报方案,覆盖采样、去重、批量、上下文与错误关联。
// 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% 采样// 服务端聚合(伪代码):按维度算 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))];
}🌐 网络与协议层面的收益
// 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),
});
});// 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
// 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 |
🧭 优化实施总流程
// 一次完整的 CWV 治理闭环:
// 1. 定位:Search Console/CrUX 看全站哪些页面组红;RUM 按维度下钻
// 2. 归因:web-vitals attribution 定位到具体元素/阶段/脚本
// 3. 复现:用 Lighthouse/WebPageTest 在实验室复现并调试
// 4. 修复:针对具体指标与阶段施策(见分指标清单)
// 5. 验证:先看实验室是否改善,再等 CrUX/RUM 字段数据确认
// 6. 守护:Lighthouse CI 卡实验室、RUM 告警卡真实、性能预算防回归🛠️ 工具链深入
Lighthouse 分数解读
// Lighthouse 性能总分是多个实验室指标的加权合成(权重会随版本调整,约为):
// - TBT(总阻塞时间):约 30%(INP 的实验室代理)
// - LCP:约 25%
// - CLS:约 25%
// - FCP:约 10%
// - Speed Index:约 10%
// 提示:别只盯总分!要看下方 Opportunities(优化机会)与 Diagnostics(诊断)
// 单次跑分波动大,用 3-5 次中位数,或以字段数据为准# Lighthouse CLI 批量跑与输出 JSON
# npx lighthouse https://example.com --output=json --output-path=./report.json \
# --only-categories=performance --form-factor=mobile --throttling-method=simulateWebPageTest 深度分析
// 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 吗?
能,前提是骨架屏与真实内容尺寸一致,切换时不产生位移。
🏢 团队落地建议
// 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 | 特定元素渲染计时 |
🎓 学习与实践建议
🧰 更多可复用优化代码
图片优化封装
// 一个通用的响应式图片生成器(配合图片 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;
}字体加载优化封装
// 两阶段字体加载:先渲染后备字体,字体就绪后统一切换,减少 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 软导航的指标测量
// 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();
}));
}
}智能预取(提升后续导航的感知性能)
// 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(预渲染下一页)
<!-- 现代预渲染:浏览器可提前渲染下一页,导航时近乎瞬时(LCP 极低) -->
<script type="speculationrules">
{
"prerender": [
{
"where": { "href_matches": "/product/*" },
"eagerness": "moderate"
}
],
"prefetch": [
{
"where": { "href_matches": "/*" },
"eagerness": "conservative"
}
]
}
</script>自适应加载(根据设备/网络降级)
// 根据设备能力与网络,动态决定加载策略,保护低端设备的 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)完整用法
<!-- 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>// 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 |
典型权衡场景:
// 场景一: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,弱网/低端设备自动降级🔁 持续优化的心智模型
// CWV 优化是一个永不停止的循环,而非一次性项目:
//
// 测量(Measure) → 归因(Diagnose) → 修复(Fix) → 验证(Verify) → 守护(Guard)
// ↑ │
// └──────────────────────────────────────────────────────────┘
//
// 每一轮都让指标更接近 good,并通过守护机制防止回退。
// 关键是:用真实用户数据(RUM/CrUX)驱动,而非实验室分数的自我感动。衡量优化是否成功的标准:
🌟 写给不同角色的建议
前端工程师:
技术负责人:
产品经理:
🧯 反模式代码集(照着改)
// 反模式 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); };
}🗺️ 一页纸速记
📊 总结表格
核心指标速查
| 指标 | 衡量 | 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 这样的指标演进,才能让优化真正落到用户体验与业务结果上。
最后再强调三点,作为全文的收束:
当你把这三点变成团队的肌肉记忆,优秀的 Core Web Vitals 就不再是偶尔达标的运气,而是持续交付的必然。
性能优化是一场没有终点的马拉松:浏览器在进化、设备在更替、指标在演进、用户的期待在提高。唯有建立起"测量驱动、数据说话、机制守护"的工程文化,才能在这场长跑中始终保持领先。
行动起来,从今天做的第一步开始:
愿你的每一个页面,都能又快、又稳、又不乱跳,让每一位用户都拥有丝滑顺畅的体验。