引言
性能优化最怕「凭感觉」:改了构建配置、拆了 chunk,到底有没有让真实用户变快?答案只能来自真实用户监控(RUM)——把 LCP、INP、CLS 这些 Core Web Vitals 指标从真实浏览器里采集回来,再和构建产物、版本号关联起来分析。
本文从三个核心指标讲起,逐步搭建采集(web-vitals 库)、构建期埋点(注入脚本与版本标记)、上报(采样与 beacon 传输)与归因(落到具体资源)的完整链路,再讨论 Lighthouse CI 的实验室数据、产物体积与指标的关联,最后给出持续监控与高频陷阱的排查清单。
前置:运行期性能优化、构建产物优化。产物分析见 包体分析与性能监控:Bundle Analyzer、性能预算与门禁。
目录
- 1. Core Web Vitals 的三个指标:LCP、INP 与 CLS
- 2. 指标采集:web-vitals 库与 PerformanceObserver
- 3. 构建期埋点:注入上报脚本与版本标记
- 4. RUM 上报:采样、批量与 beacon 传输
- 5. 归因分析:把指标落到具体资源与元素
- 6. 实验室数据:Lighthouse CI 与性能预算
- 7. 构建产物体积与指标的关联
- 8. 上报数据的服务端处理与可视化
- 9. 常见陷阱:重复上报、采样偏差与阻塞
- 10. 持续监控与性能回归防护
- 延伸阅读
1. Core Web Vitals 的三个指标:LCP、INP 与 CLS
1.1 三个指标的含义
| 指标 | 衡量 | 良好阈值 |
|---|---|---|
| LCP | 最大内容元素渲染时间 | ≤ 2.5s |
| INP | 交互到下次绘制的延迟 | ≤ 200ms |
| CLS | 累计布局偏移 | ≤ 0.1 |
1.2 为什么是这三个
它们分别对应加载、交互、视觉稳定三个体验维度,且都能被浏览器原生观测。相比 FCP、TTI 等旧指标,它们更贴近用户真实感受,也更容易被 RUM 采集。
LCP 差 → 首屏资源太重、关键 CSS 未内联、图片未优化
INP 差 → 主线程被长任务占用、事件处理过重
CLS 差 → 图片无尺寸、字体加载抖动、动态插入内容
记忆:三个核心指标对应加载、交互、稳定——LCP 看首屏资源、INP 看主线程长任务、CLS 看尺寸与字体抖动。
2. 指标采集:web-vitals 库与 PerformanceObserver
2.1 用 web-vitals 采集
官方 web-vitals 库把底层 PerformanceObserver 的细节封装好了:
import { onLCP, onINP, onCLS } from 'web-vitals'
function report(metric) {
console.log(metric.name, metric.value, metric.rating)
}
onLCP(report)
onINP(report)
onCLS(report)
2.2 底层是 PerformanceObserver
理解底层有助于排查采集不到的情况:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// largest-contentful-paint / layout-shift / event
console.log(entry.entryType)
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
2.3 关键时机
LCP → 页面隐藏或首次交互后最终确定
INP → 整段会话中持续更新,取最差交互
CLS → 会话窗口内累计,页面隐藏时最终确定
因此指标必须在 visibilitychange(页面隐藏)时再最终上报一次,否则会漏掉最终值。
记忆:用 web-vitals 库采集、底层是 PerformanceObserver——指标在页面隐藏时才最终确定,务必在
visibilitychange时补报一次。
3. 构建期埋点:注入上报脚本与版本标记
3.1 注入版本号
把构建版本注入代码,才能把指标与具体发布关联:
export default defineConfig({
define: {
__BUILD_VERSION__: JSON.stringify(process.env.GIT_SHA ?? 'dev'),
},
})
// 上报时带上版本
sendBeacon('/rum', JSON.stringify({ name, value, version: __BUILD_VERSION__ }))
3.2 只进生产构建
监控脚本不应进开发态,避免污染数据:
if (import.meta.env.PROD) {
const { initRUM } = await import('./rum')
initRUM()
}
import.meta.env.PROD 在开发态为 false,配合动态导入可以让整个监控模块在 dev 里被 tree-shake 掉。
3.3 上报端点配置
const RUM_ENDPOINT = import.meta.env.VITE_RUM_ENDPOINT
用环境变量区分测试与生产的上报地址。
记忆:构建期把版本号注入(
define)、监控脚本用import.meta.env.PROD包住——既能把指标和发布关联,又不会污染开发态数据。
4. RUM 上报:采样、批量与 beacon 传输
4.1 用 sendBeacon 传输
页面卸载时同步 XHR 会被中断,必须用 navigator.sendBeacon:
function send(payload: object) {
const body = JSON.stringify(payload)
if (navigator.sendBeacon) {
navigator.sendBeacon('/rum', body)
} else {
fetch('/rum', { method: 'POST', body, keepalive: true })
}
}
4.2 采样
高流量站点必须采样,否则上报量爆炸:
const SAMPLE_RATE = 0.1
const sampled = Math.random() < SAMPLE_RATE
if (sampled) send(metric)
4.3 批量与合并
把一次会话里的多个指标合并成一条上报,减少请求数:
const queue: object[] = []
function enqueue(metric: object) {
queue.push(metric)
if (queue.length >= 5) flush()
}
function flush() {
if (queue.length) send({ metrics: queue.splice(0) })
}
记忆:上报用
sendBeacon(卸载时不被中断)、高流量必须采样、多指标合并成一条批量发——三点做到才不至于把监控变成新的性能负担。
5. 归因分析:把指标落到具体资源与元素
5.1 采集归因信息
只有数值没有归因,优化就无从下手。web-vitals 提供了归因构建:
import { onLCP } from 'web-vitals/attribution'
onLCP((metric) => {
console.log(metric.attribution)
// element: 最大内容元素
// url: 触发 LCP 的资源
// timeToFirstByte / resourceLoadDelay ...
})
5.2 关键归因字段
| 指标 | 关键归因 |
|---|---|
| LCP | 元素选择器、资源 URL、TTFB 分解 |
| INP | 目标元素、事件类型、脚本 URL |
| CLS | 偏移来源元素、偏移量 |
5.3 把归因与产物关联
归因里拿到的资源 URL 自带 hash,正好可以和构建产物对应:
归因 url: /assets/hero-a1b2c3.webp
→ 在产物清单里定位到具体文件与体积
→ 决定是压缩图片还是改懒加载
记忆:归因是优化的前提——用
web-vitals/attribution拿到元素与资源 URL,再和带 hash 的产物对应,优化才有明确靶子。
6. 实验室数据:Lighthouse CI 与性能预算
6.1 为什么需要实验室数据
RUM 反映真实分布但滞后且嘈杂;实验室数据在受控环境下可复现,适合做 CI 门禁。
6.2 Lighthouse CI 配置
// lighthouserc.js
module.exports = {
ci: {
collect: { staticDistDir: './dist', numberOfRuns: 3 },
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
},
},
},
}
6.3 性能预算
- 首屏 JS ≤ 170KB(gzip)
- 首屏 CSS ≤ 30KB(gzip)
- LCP 资源 ≤ 200KB
- Lighthouse 性能分 ≥ 0.9
记忆:RUM 看真实分布、Lighthouse CI 做受控门禁——把性能预算写成 CI 断言,回归就会在合并前被拦下。
7. 构建产物体积与指标的关联
7.1 体积与 LCP 的关系
LCP 主要由首屏关键资源决定,而首屏资源大小直接来自构建产物:
# 看首屏入口 chunk 大小
ls -la dist/assets/index-*.js dist/assets/index-*.css
# 看产物构成
npx vite build --report
7.2 建立体积基线
每次发布记录:
- 入口 JS/CSS 的 gzip 体积
- 首屏异步 chunk 数量
- 最大资源体积
与上一版本对比,超过阈值即告警
7.3 从指标反推优化项
| 指标异常 | 优先检查 |
|---|---|
| LCP 偏高 | 入口 chunk、首屏图片、字体 |
| INP 偏高 | 长任务、大依赖同步执行 |
| CLS 偏高 | 图片尺寸、字体 swap、广告位 |
记忆:把体积基线和指标绑在一起看——LCP 高先查入口 chunk 与首屏资源,INP 高先查长任务,CLS 高先查尺寸与字体。
8. 上报数据的服务端处理与可视化
8.1 上报格式
{
"version": "a1b2c3",
"metrics": [
{ "name": "LCP", "value": 2340, "rating": "good", "url": "/home" },
{ "name": "INP", "value": 180, "rating": "good", "url": "/home" }
]
}
8.2 关注分位数而非均值
性能数据是长尾分布,均值会被少数极端值带偏,应看 p75 与 p95:
p75 → 大多数用户的体验
p95 → 尾部体验(易被忽略但影响口碑)
8.3 可视化维度
- 按版本对比(本次发布是否回归)
- 按页面路由对比(哪个页面最慢)
- 按设备类型对比(移动端通常更差)
- 按地理区域对比(CDN 覆盖差异)
记忆:性能数据看 p75 与 p95 而非均值——按版本、路由、设备、区域四个维度切分,才能定位到底是哪次发布、哪个页面、哪类设备在退化。
9. 常见陷阱:重复上报、采样偏差与阻塞
9.1 高频陷阱
| 现象 | 原因 | 处理 |
|---|---|---|
| 指标重复计数 | 多次注册回调 | 只注册一次 |
| 数据量爆炸 | 未采样 | 加采样率 |
| 上报丢失 | 用了同步 XHR | 改 sendBeacon |
| 指标偏低 | 页面隐藏时未补报 | 监听 visibilitychange |
| 监控拖慢页面 | 脚本阻塞主线程 | 动态导入、延迟执行 |
| 数据不可比 | 版本号未注入 | define 注入版本 |
9.2 采样偏差
采样必须在会话级别做,而不是每条指标各自随机——否则同一用户的部分指标上报、部分不上报,分布会被扭曲。
9.3 别让监控成为负担
监控脚本自身也要算性能成本:用动态导入延迟加载、只在生产启用、上报体量控制在几 KB。
记忆:监控翻车集中在「重复、采样、丢失、阻塞」四类——会话级采样、sendBeacon 上报、动态导入延迟执行,监控才不会变成新的性能问题。
10. 持续监控与性能回归防护
10.1 回归防护的三道闸
第一道:Lighthouse CI 断言(合并前拦截明显回归)
第二道:产物体积基线对比(构建后对比阈值)
第三道:RUM 版本对比(发布后看 p75 是否恶化)
10.2 落地清单
□ 生产环境启用 RUM,开发态自动关闭
□ 构建版本号已注入上报数据
□ 上报走 sendBeacon 且按会话采样
□ 指标在 visibilitychange 时补报
□ Lighthouse CI 已配置性能预算断言
□ 每次发布记录入口 JS/CSS 体积基线
□ 看板按版本、路由、设备、区域切分
10.3 一句话总结
采集要准(可见性补报 + 会话采样)
上报要轻(beacon + 批量)
归因要细(元素与资源 URL)
门禁要硬(CI 断言 + 体积基线)
记忆:性能监控的闭环是「采集准、上报轻、归因细、门禁硬」——RUM 看真实分布、Lighthouse 做受控门禁、体积基线守回归,三者缺一不可。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。