一、引言
用户对「慢」的感知阈值是秒级的:页面超过 2.5 秒没显示主内容,流失率就显著上升。Google 从 2020 年起将 Core Web Vitals(CWV) 纳入搜索排名信号,2024 年正式以 INP 替换 FID,形成 LCP + INP + CLS 三件套。它不是锦上添花的「最佳实践」,而是用户真实体验的数字投影。
然而很多团队把优化做成「对着 Lighthouse 逐项打勾」,上线后 CrUX 数据却毫无起色——因为实验室数据(Lab)与现场数据(Field)测量的是两套东西。本文先讲清三个核心指标的测量原理,再给出真正影响指标的优化动作:Next.js Image 的自动优化、字体加载的 LCP 博弈、资源优先级与长任务拆分,最后讲 Lighthouse/PageSpeed 审计项怎么读、性能预算怎么落地。
建议与 Next.js App Router 深度 与 前端监控 RUM 采集 配合阅读:前者讲渲染层怎么为性能让步,后者讲怎么持续度量现场指标。
二、Core Web Vitals 指标体系
2.1 三大指标的官方定义与阈值
| 指标 | 全称 | 度量对象 | 良好 | 需改进 | 差 |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容渲染时间(首屏加载感知) | ≤ 2.5s | 2.5s–4s | > 4s |
| INP | Interaction to Next Paint | 交互到下一次绘制的延迟(响应感知) | ≤ 200ms | 200ms–500ms | > 500ms |
| CLS | Cumulative Layout Shift | 累积布局偏移(视觉稳定性) | ≤ 0.1 | 0.1–0.25 | > 0.25 |
LCP 追踪的是首屏最大可见元素(通常是 <img>、<h1>、hero 图或含背景图的块)从导航开始到完成渲染的时间。它直接对应「用户看到主内容」的时刻。
INP 采样用户在页面生命周期内的所有点击、按键、触摸,取最差(或接近最差的 P75)响应延迟。响应延迟 = 输入到下一帧绘制的时间,主要受主线程被长任务阻塞影响。
CLS 计算布局不稳定程度:每个意外位移的「影响分数 × 距离分数」之和。广告位、懒加载图片未占位、Web 字体 swap 是三大来源。
2.2 测量原理:PerformanceObserver
现场指标都通过浏览器 PerformanceObserver 采集,这与 RUM 数据同源:
// 采集 LCP
new PerformanceObserver((list) => {
const entries = list.getEntries()
const last = entries[entries.length - 1]
navigator.sendBeacon('/rum', { lcp: last.startTime })
}).observe({ type: 'largest-contentful-paint', buffered: true })
// 采集 CLS(注意 session 窗口,取最大突发值)
new PerformanceObserver((list) => {
let cls = 0
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) cls += entry.value
}
navigator.sendBeacon('/rum', { cls })
}).observe({ type: 'layout-shift', buffered: true })
// 采集 INP(兼容脚本需监听 event 并配对 performance event timing)
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'event' && entry.interactionId > 0) {
navigator.sendBeacon('/rum', { inp: entry.duration })
}
}
}).observe({ type: 'event', buffered: true })
关键认知:LCP/CLS 是持续上报最近值,INP 必须等页面生命周期结束(或用 pageshow 兜底)才能确定最终值,因为「最差交互」可能发生在最后时刻。
三、LCP 优化:把主内容尽快渲染出来
3.1 资源优先级:fetchpriority 与预加载
LCP 的核心是「关键资源加载链路」:HTML → CSS → 首屏图片/字体 → 渲染。优化第一刀是控制优先级:
<!-- 显式提升首屏主图优先级 -->
<img src="/hero.webp" fetchpriority="high" />
<!-- 对首屏立即需要的字体做预加载 -->
<link rel="preload" href="/fonts/Inter-Bold.woff2" as="font"
type="font/woff2" crossorigin />
<!-- 对次屏资源显式降级,避免与首屏抢带宽 -->
<img src="/below-fold-banner.webp" loading="lazy" fetchpriority="low" />
⚠️
fetchpriority="high"只应该用在首屏 LCP 候选元素上,用多了等于没用。loading="lazy"绝不用于 LCP 元素(可能推迟到视口内才加载,直接毁掉 LCP)。
3.2 Next.js Image 优化:自动响应式与优先级
next/image 组件包办了格式转换(WebP/AVIF)、响应式尺寸(srcset)、懒加载、占位与优先级调度:
import Image from 'next/image'
import hero from './hero.webp'
export default function Hero() {
return (
<Image
src={hero}
alt="产品主视觉"
// sizes 决定每个断点需要多大图,配合 srcset 精确下发
sizes="(max-width: 768px) 100vw, 1280px"
// 明确这是 LCP 候选,next/image 会输出 fetchpriority="high"
priority
// 避免 CLS:显式宽高比
width={1280}
height={720}
// 模糊占位,加载中不闪
placeholder="blur"
/>
)
}
priority 属性背后做三件事:跳过懒加载、提升加载优先级、触发预加载 <link rel="preload">。宽度/高度必须显式指定,否则渲染时高度为 0,图片加载后下坠——这就是 CLS 的一个隐藏来源。
对于非 next/image 的纯静态站,等价优化是构建期用 sharp 转 AVIF + 生成 srcset:
npx sharp-cli -i src/hero.png -o public/hero -f avif,webp --resize 1920,1280,768
3.3 图片格式与体积预算
| 格式 | 适用场景 | 备注 |
|---|---|---|
| AVIF | 照片、复杂图形 | 压缩率最高,现代浏览器均支持,解码 CPU 开销略高 |
| WebP | 通用 | 比 JPEG 省 25–35%,兼容性最好 |
| JPEG XL | 高保真照片 | 浏览器支持有限,可用 <picture> 渐进增强 |
| SVG | 图标、插画 | 矢量无限缩放,务必压缩路径 |
图片体积每减少 50%,LCP 大约可缩短数百毫秒。先裁图、再转格式、最后压缩——不要在代码层面烧香。
四、字体加载与 LCP 的博弈
4.1 字体的三种加载策略
字体加载是典型的「体验与性能博弈」:display: block 保证一致但延迟文字渲染;display: swap 快速显示但引起 FOIT/FOUT 抖动(影响 CLS)。font-display 四档:
| 值 | 行为 | CLS 影响 |
|---|---|---|
auto | 浏览器默认(多为 block) | 高 |
block | 字体未就绪时隐藏文本(≤3s) | 中(文字闪烁) |
swap | 立即用回退字体,就绪后替换 | 高(替换即位移) |
optional | 网络慢时直接用回退字体,不替换 | 低(推荐) |
4.2 next/font:字体优化的一体化方案
Next.js 的 next/font 把自动子集化 + 预加载 + 消除 FOIT 全部内置:
// app/layout.tsx — 在根布局加载,避免布局内重复请求
import { Inter, Noto_Sans_SC } from 'next/font/google'
// 自动按使用字符做子集化,仅下载用到的字形
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
// display: swap 会触发 CLS;用 optional 换稳定性
display: 'optional',
})
const notoSc = Noto_Sans_SC({
subsets: ['latin'],
weight: ['400', '700'],
variable: '--font-noto-sc',
display: 'optional',
})
export default function RootLayout({
children,
}: { children: React.ReactNode }) {
return (
<html lang="zh-CN" className={`${inter.variable} ${notoSc.variable}`}>
<body>{children}</body>
</html>
)
}
next/font 会自动生成 @font-face 并在 HTML 中插入 <link rel="preload">。关键设置是 display: 'optional':弱网下直接使用系统回退字体,彻底避免 CLS 的字体位移来源。
4.3 自托管字体与 font-size-adjust 兜底
自托管字体时,用 CSS 兜底减少替换时的视觉跳变:
/* 让回退字体与目标字体的 x-height 视觉对齐,减轻 swap 位移 */
@font-face {
font-family: 'Brand';
src: url('/fonts/Brand-Bold.woff2') format('woff2');
font-display: optional;
size-adjust: 100%;
ascent-override: 92%;
descent-override: 24%;
}
五、INP 优化:减少主线程阻塞
5.1 长任务与事件响应
INP 的根源是主线程被长任务(>50ms)阻塞,导致输入事件排队。优化方向是让主线程保持空闲:
时间线:
[输入点击] → [事件处理 30ms] → [长任务 120ms 阻塞渲染] → [下一帧绘制 60ms]
↑ 这就是 INP 延迟的主要构成
优化动作按 ROI 排序:
- 代码分割:只加载首屏需要的 JS。Next.js 默认按路由 + 动态导入自动分割。
next/dynamic把非关键组件下沉:import dynamic from 'next/dynamic' const HeavyChart = dynamic(() => import('@/components/heavy-chart'), { loading: () => <ChartSkeleton />, // SSR 也可以关闭,减少首屏 JS ssr: false, })- 事件处理减负:防抖/节流高频事件、把重计算挪到
requestIdleCallback或 Web Worker。 - CSS 与布局抖动:避免强制同步布局(在
rAF循环里写读交错)。
5.2 把重任务交给 Worker
// 计算密集型(图片处理、PDF 解析)挪进 Worker
const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url))
// 输入事件立即响应,重活异步执行
button.addEventListener('click', (e) => {
// 先给出视觉反馈
showSpinner()
// 重计算交还主线程
worker.postMessage({ task: 'process', payload })
})
worker.onmessage = ({ data }) => {
renderResult(data)
}
INP 的官方口径只统计交互到下一帧,主线程上的同步重计算会直接拉高它;而 Worker 里执行的计算不计入主线程阻塞。
六、CLS 优化:消除布局跳动
CLS 的三大来源与对策:
| 来源 | 对策 |
|---|---|
| 图片无尺寸 | 始终显式 width/height 或 aspect-ratio |
| 广告/嵌入内容 | 预留固定尺寸容器 + 延迟挂载 |
| Web 字体 swap | font-display: optional / size-adjust |
| 动态注入内容(toast/弹窗) | 用 transform 动画,锚定底部;弹窗用 fixed 定位 |
| 懒加载图片 | 预留占位(padding-top 或 aspect-ratio) |
/* 占位盒:按宽高比预留空间,图片加载不跳动 */
.img-frame {
aspect-ratio: 16 / 9;
background: #f3f4f6; /* 占位底色 */
}
React/Next 下给动态列表设置 min-height 也很关键:
{/* 首屏前 3 项立即渲染,后续用骨架占位,防止列表扩展顶走下方内容 */}
<ul style={{ minHeight: 360 }}>
<li>...</li>
</ul>
七、Lighthouse 与 PageSpeed Insights 指标解读
7.1 Lab vs Field:两套数字
| 维度 | Lab(实验室) | Field(现场 / CrUX) |
|---|---|---|
| 来源 | Lighthouse(本地/CI) | Chrome 用户真实访问 |
| 设备 | 固定的模拟环境 | 真实设备分布 |
| 采样 | 单次/少量 | 28 天聚合,P75 |
| 用途 | 定位问题、回归检测 | 反映真实用户、影响 SEO |
| 网络 | 固定 4G throttling | 真实网络波动 |
Lighthouse 95 分 ≠ 现场指标达标。优化流程应该是:用 Lighthouse 找具体瓶颈(哪张图太大、哪个脚本阻塞),改完后用 RUM / CrUX 验证现场值。现场数据才是 Google 排名用的。
7.2 Lighthouse 六项核心审计怎么看
| 审计 | 关注点 | 常见误判 |
|---|---|---|
| First Contentful Paint | 第一个文本/图片渲染 | 被字体 block 拖慢 |
| Largest Contentful Paint | 最大元素时间 | 报告会提示 LCP 元素是哪个 |
| Speed Index | 首屏可见内容平均时间 | 高分但 LCP 差:布局太快但主内容后到 |
| Total Blocking Time | 主线程长任务总和(与 INP 相关) | Lab TBT 高对应 Field INP 风险 |
| Cumulative Layout Shift | 布局稳定性 | 需逐个审计「avoid CLS」子项 |
| Time to Interactive | 可交互时间 | 资源密集时失真,参考 TBT |
PageSpeed Insights 的「Field data」区块直接用 CrUX 数据。如果它显示「Data not sufficient」,说明页面访问量不足以进入 CrUX 样本——那就必须自建 RUM(见前端监控 RUM)。
7.3 Lighthouse CI 与性能预算
# 安装 lighthouse-ci
npm i -D @lhci/cli
// lighthouserc.json — 在 CI 里设性能预算
{
"ci": {
"collect": { "url": ["https://example.com/"], "numberOfRuns": 3 },
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"lighthouse:no-fcp-render-blocking-resources": ["error"],
"lighthouse:largest-contentful-paint": ["error", { "maxNumericValue": 2500 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
# .github/workflows/perf.yml
name: Performance CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build && npm run start & npx wait-on http://localhost:3000
- run: npx lhci autorun
性能预算还可以落到构建产物体积上:
// budgets.json(webpack/rollup 均可)
{
"budgets": [
{ "type": "initialLoad", "resourceType": "script", "budget": 170000 },
{ "type": "any", "resourceType": "image", "budget": 300000 }
]
}
预算的意义在于防回归:性能优化是一次性的,但团队会不断往里塞功能;只有 CI 门槛能拦住无意识的体积膨胀。
八、优化优先级路线图
按「现场收益 / 投入成本」排序:
| 优先级 | 动作 | 改善指标 |
|---|---|---|
| P0 | 图片响应式 + 转 AVIF/WebP + 显式尺寸 | LCP / CLS |
| P0 | 关键 CSS 内联、去阻塞渲染资源 | LCP / FCP |
| P1 | next/font + display: optional | CLS / LCP |
| P1 | 路由级代码分割 + next/dynamic 下沉 | INP / TBT |
| P2 | 长任务拆 Worker、事件防抖 | INP |
| P2 | CDN + HTTP 缓存 + SWR | LCP(TTFB) |
| P3 | 性能预算 + Lighthouse CI | 防回归 |
| P3 | 自建 RUM 持续监控 | 全部(度量闭环) |
九、总结
Core Web Vitals 优化的核心不是「背阈值」,而是理解每条指标的测量链路:LCP 是资源加载问题(图片/字体/优先级),INP 是主线程问题(JS 执行/长任务),CLS 是布局问题(尺寸/字体/动态注入)。Next.js Image 与 next/font 把前两者的自动化做到开箱即用,剩下的关键是不用懒加载砸首屏、不用 swap 字体换稳定、不用一整包 JS 阻塞主线程。
最后,别忘了 Lab 与 Field 的差异——优化后务必用现场数据验证,并引入 RUM 持续度量,让性能成为可观测的系统属性,而不是上线前的一次性冲刺。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。