目录
- 为什么是 Web Vitals?
- Web Vitals 指标长啥样(以及它们"真的在乎什么")
- 在 Next.js 中采集 Web Vitals(含自定义上报)
- 用 Lighthouse 验证与对照
- 指标对标与调优策略(含 SSR/ISR/Edge 等底层视角)
- 常见坑与排障清单
- 小结与行动清单
为什么是 Web Vitals?
- 用户体验不是玄学,它有可测量的客观指标。
- 谷歌推的 Web Vitals 已经成为"绩效考核标准",搜索引擎、转化率、留存都与之强相关。
- Next.js 自带对 Web Vitals 的采集能力,我们只需要接住它,把数据打到监控平台就能建立"可观测性闭环"。
小结:没有数据,优化都是"玄学叙事";有了 Web Vitals,我们才有"实验-验证-迭代"的工程闭环。
Web Vitals 指标长啥样?
以下是核心指标(Core Web Vitals)与常见扩展指标的"人话版":
- LCP(Largest Contentful Paint)最大内容绘制
关注页面主内容可见的时间。
逻辑理解:浏览器判断哪一块是"最大"的内容(大图、大块文本、视频封面)并记录它首次出现的时间。
目标:越快越好。通常 2.5 秒以内被视为优秀。 - CLS(Cumulative Layout Shift)累计布局偏移
页面元素跳来跳去的"烦躁指数"。
逻辑理解:根据每次布局变化的"位移比例 × 视窗影响面积比例"累计叠加。
目标:越小越好。一般 0.1 以下算优秀。 - INP(Interaction to Next Paint)交互到下一次绘制
用户交互(点击、输入)到页面下一帧渲染的延迟。
逻辑理解:把各类交互事件的响应时间分布里"接近高位"的值拿出来衡量稳定体验。
目标:200 毫秒以内优秀。 - FID(First Input Delay)首个输入延迟(逐步被 INP 替代)
首次交互到事件处理程序真正运行的延迟。
目标:100 毫秒以内优秀。 - TTFB(Time To First Byte)首字节时间
服务端到客户端第一字节到达的时间。
目标:<= 0.8 秒普遍可用;越低越好。 - FCP(First Contentful Paint)首个内容绘制
屏幕上出现第一个非白屏内容的时间。
常被用于对比不同渲染路径的可见速度。
小图标助兴:
- ⚡ 快:LCP、FCP
- 🧩 稳:CLS
- 🕹️ 灵:INP、FID
- 🚚 供:TTFB(供给链:网络、后端、边缘)
在 Next.js 中采集 Web Vitals
Next.js 为我们提供了一个"官方入口"来接收浏览器端的 Web Vitals:reportWebVitals
。你可以将数据打印到控制台、发送到你的 APM/日志平台、或者自建端点持久化。
下面给出两套写法:App Router(app/)与 Pages Router(pages/)。
1)App Router(Next.js 13+,app/)
在 app/
目录下新建 vitals.ts
,并在 app/layout.tsx
中导入以初始化。
php
// app/vitals.ts
export function onReportWebVitals(metric) {
// metric 对象结构示例:
// {
// id, name, startTime, value, label, delta, entries
// }
// name 可能为 'CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'
try {
// 示例:发送到你自己的后端收集端点
const body = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
delta: metric.delta,
label: metric.label,
startTime: metric.startTime,
page: location.pathname,
ua: navigator.userAgent,
ts: Date.now()
});
// 使用 navigator.sendBeacon 优先,失败再 fetch
const url = '/api/vitals';
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
} else {
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body
});
}
} catch (e) {
// 静默失败,避免影响用户体验
console.warn('Vitals report failed', e);
}
}
javascript
// app/layout.tsx
import './globals.css'
import { onReportWebVitals } from './vitals'
export const reportWebVitals = onReportWebVitals
export default function RootLayout({ children }) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
)
}
再创建一个 API 路由接收数据:
javascript
// app/api/vitals/route.js
export async function POST(req) {
const data = await req.json();
// 你可以在此把数据写入日志、存数据库、进消息队列等
// 下面仅示例打印到服务器日志
console.log('[web-vitals]', data.name, data.value, data.page, data.id);
return new Response('ok', { status: 200 });
}
2)Pages Router(pages/)
php
// pages/_app.js
import '../styles/globals.css'
export function reportWebVitals(metric) {
try {
const body = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
delta: metric.delta,
label: metric.label,
startTime: metric.startTime,
page: location.pathname,
ua: navigator.userAgent,
ts: Date.now()
});
const url = '/api/vitals';
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
} else {
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body
});
}
} catch (e) {
console.warn('Vitals report failed', e);
}
}
export default function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
javascript
// pages/api/vitals.js
export default async function handler(req, res) {
if (req.method === 'POST') {
// 收集与落盘/转发
console.log('[web-vitals]', req.body?.name, req.body?.value, req.body?.page, req.body?.id);
return res.status(200).send('ok');
}
res.status(405).send('Method Not Allowed');
}
贴士:
- 生产环境建议加上采样率,例如只上报 10%:
if (Math.random() > 0.1) return;
- 避免在首屏关键路径上做重量级计算或同步 IO,上报应使用 sendBeacon 或 keepalive fetch。
用 Lighthouse 验证与对照
Lighthouse 是一盏手电筒,帮你照亮"你以为"和"真实情况"的差距。你可以在 Chrome DevTools 的 Lighthouse 面板运行,或使用 lighthouse
CLI 与 CI 集成。
-
它会模拟冷启动加载,生成以下分数:Performance、Accessibility、Best Practices、SEO。
-
Performance 会给出 LCP、CLS、INP/FID、TTFB、FCP 等的"实验室数据"。
-
对照要点:
- Web Vitals 上报是"真实用户数据"(RUM);
- Lighthouse 是"合成测试"。
- 两者互相校准:Lighthouse 用来定位问题和回归测试;RUM 用来看实际用户分布与波动。
常见对照结论:
- Lighthouse LCP 佳但线上 LCP 差:CDN 地域、真实图片体积、登录/个性化带来的差异。
- Lighthouse INP 优但线上 INP 飙高:真实用户设备较弱、第三方脚本劫持事件循环、瀑布数据更多。
指标对标与调优策略(含底层视角)
从浏览器、网络、Node/边缘运行时多层入手。
1)LCP 优化
-
图像优化:
- 使用
<Image>
组件与自适应格式(AVIF/WEBP),开启next/image
的优化服务或外部 loader。 - 预加载 LCP 资源:在 Head 里添加
rel="preload"
;Next.js 通过<link rel=prefetch/preload>
可控制关键资源获取顺序。
- 使用
-
HTML 优先级:
- 减少阻塞渲染的 CSS/JS。将非关键 CSS 延迟加载;拆分上行 JS,减少初始包。
-
渲染路径:
- SSR/ISR 让用户更快拿到可渲染 HTML;用 Edge Runtime 把 TTFB 拉低,间接提振 LCP。
- 避免在渲染阶段做慢 I/O,可用并发与缓存(
fetch
的内建缓存、revalidate
)。
底层原理小剧场:
浏览器要呈现 LCP,必须先拿到 HTML、解析 DOM、下载/解析 CSS、布局、绘制。当"最大节点"(例如 Hero 图)首次绘制完成就计时。阻塞 CSS、延迟图片加载、主线程 JS 占用都会延后这个时刻。
2)CLS 优化
- 预留尺寸:给图片、广告位、组件容器设置明确的宽高或 aspect-ratio。
- 动态注入:避免在顶部插入 DOM;需要插入时占位或使用过渡动画。
- 字体闪动:用可交换字体策略(font-display: swap/optional),并为自定义字体设置合适的 fallback。
底层原理小剧场:
CLS 是对"偏移比例 × 影响面积"的累计。哪怕一个元素轻微移动,但覆盖屏幕大面积,也会有显著分值。稳定布局就是消灭"晚知道的尺寸"。
3)INP/FID 优化
-
主线程健康:
- 分割长任务(超过 50ms 的脚本),使用
requestIdleCallback
/setTimeout
切片。 - 使用 React Server Components 降低客户端 JS;用于交互的组件才下发 JS。
- 分割长任务(超过 50ms 的脚本),使用
-
事件处理:
- 避免在点击事件中做重计算和同步阻塞(如 JSON 大解析、加密、巨大 dataURL)。
- 对输入框相关逻辑做防抖/节流。
-
第三方脚本:
- 打上
async
/defer
,或采用Partytown
把第三方运行到 web worker。 - 用资源提示
preconnect
提前握手第三方域名。
- 打上
底层原理小剧场:
INP 度量交互到下次绘制的延迟。只要事件处理或渲染链路卡住主线程,下一帧就来不及。最小化主线程"独占时间"是王道。
4)TTFB 优化
-
架构:
- 使用 Edge Runtime(Vercel Edge Functions 或 Cloudflare Workers)把逻辑前移。
- 为数据请求加缓存(HTTP 缓存头、
fetch
缓存、revalidateTag
等)。
-
数据源:
- 合并往返次数,靠 BFF 接口聚合。
- 用流式 SSR(React 服务器组件/Server Actions)尽早送字节。
-
网络:
- 启用 HTTP/2 或 HTTP/3,复用连接;预连接
preconnect
关键域名。
- 启用 HTTP/2 或 HTTP/3,复用连接;预连接
在 Next.js 里把 Lighthouse 和 Web Vitals 联动起来
- 在 CI 中跑 Lighthouse(如
lighthouse-ci
),设定最低阈值;对比构建前后。 - 线上用 RUM 收集 Web Vitals,做 75 分位数统计(例如每日/每端/每地域)。
- 若 CI 分数下降但 RUM 正常,可能是实验室环境变动;若 RUM 下降而 CI 正常,可能是真实流量变了(比如一次运营投放带来大量低端机流量)。
实战代码片段:资源提示与图像优化
javascript
// app/head.js (App Router)
export default function Head() {
return (
<>
<link rel="preconnect" href="https://example-cdn.com" crossOrigin="" />
<link rel="dns-prefetch" href="https://example-cdn.com" />
{/* 关键 CSS 预加载示例(注意匹配实际构建产物) */}
{/* <link rel="preload" as="style" href="/styles/critical.css" /> */}
{/* LCP 图像预加载(如果确定该图像是首屏最大内容) */}
{/* <link rel="preload" as="image" href="/hero.avif" imagesrcset="/hero.avif 1x, /hero@2x.avif 2x" /> */}
</>
)
}
javascript
// 使用 next/image 提升 LCP 与节流带宽
import Image from 'next/image'
export default function Hero() {
return (
<div style={{ position: 'relative', minHeight: 360 }}>
<Image
src="/hero.avif"
alt="英雄横幅"
fill
priority
sizes="100vw"
style={{ objectFit: 'cover' }}
/>
<h1 className="title">你好,快速世界 ⚡</h1>
<style jsx>{`
.title {
position: absolute;
bottom: 16px;
left: 16px;
margin: 0;
color: white;
text-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
`}</style>
</div>
)
}
常见坑与排障清单
- 只在开发环境测试 Lighthouse:误差大。请用无扩展的干净 Chrome、模拟 4G/中端机配置。
- 忽略真实用户:RUM 才是决策依据;Lighthouse 只是"幻灯片拍照"。
- 图片懒加载过度:LCP 图片被懒加载,或者未
priority
,导致 LCP 被延后。 - CSS 阻塞:单个大 CSS/JS 包可能阻塞初始渲染;使用代码拆分与关键 CSS。
- 第三方脚本未隔离:热量全在主线程燃烧,INP 爆表。
- 无占位导致 CLS:广告/推荐位首次渲染后挤开布局。
- 服务器渲染慢:TTFB 高,后续指标也受拖累。考虑缓存与边缘计算。
一点"底层味道"的侦查技巧
- Performance 面板火焰图:找到超过 50ms 的长任务;把它切片或延后。
- Coverage 面板:看看首屏用到多少 JS/CSS;不需要的就别上车。
- WebSocket/Server Actions:谨慎大对象序列化,避免在关键时刻开销过大。
- React Profiler:识别重复渲染与无效 diff;使用 memo、
useMemo
、useCallback
有的放矢。 - HTTP 头与缓存:
cache-control
,etag
,stale-while-revalidate
组合拳。
小结与行动清单
- 建立数据闭环:Next.js 的
reportWebVitals
+ 你的日志/指标平台。 - 将 Lighthouse 接入 CI,设阈值守门。
- 聚焦三件事:LCP 快、CLS 稳、INP 灵。
- 架构层面:用 SSR/ISR/Edge 优化 TTFB 与传输链;用
next/image
和资源提示搞定"首屏关键资源"。 - 把慢任务切碎,把第三方脚本"关小黑屋"(worker/async/defer)。
把性能做好,不是让页面"瘦成干瘪",而是让它"肌肉分明"。
愿你的页面像短跑冠军一样起跑迅猛(LCP),落地稳健(CLS),反应敏捷(INP)。🏃♂️💨🛡️🕹️
------ 祝你在 Lighthouse 的光照下,像素闪闪发光。