概述
后端监控做得再花,用户打开页面白屏 5 秒,你的 SLO 全绿也是白搭。
这话不是我故意唱反调。很多团队的监控大盘里全是 CPU、内存、QPS、P99 延迟,看着一片祥和。可真实用户的投诉邮件已经在客服那边堆成山了——“页面打开慢”、“点不动”、“图片一直闪”。问题出在哪?你监控的是服务器视角,用户看到的是浏览器视角,中间隔着 CDN 缓存、DNS 解析、第三方脚本阻塞、渲染管线,任何一环拉胯,用户的体验就崩了。
这篇文章要解决的问题是:怎么把"用户实际感受到的性能"量化成指标,采集起来,配上告警,最后形成优化闭环。核心是两件事——Core Web Vitals 指标体系和真实用户监控(RUM,Real User Monitoring)。
前端性能监控和后端监控有一个本质区别:后端监控的是"我的服务处理得快不快",前端监控的是"用户等得久不久"。同一个接口,后端 P99 是 50ms,但用户在 4G 网络下等了 3 秒才看到内容——中间差的那 2.95 秒,只有前端监控能看到。
Core Web Vitals:用户感受的"体温计"
三大核心指标
Google 定义的 Core Web Vitals 是目前业界最主流的前端体验量化标准,聚焦三个维度:
| 指标 | 全称 | 衡量什么 | 良好阈值 | 需改进 | 差 |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容绘制时间 | ≤ 2.5s | 2.5-4.0s | > 4.0s |
| INP | Interaction to Next Paint | 交互到下一帧绘制延迟 | ≤ 200ms | 200-500ms | > 500ms |
| CLS | Cumulative Layout Shift | 累计布局偏移 | ≤ 0.1 | 0.1-0.25 | > 0.25 |
说人话解释一下这三个指标在量什么:
- LCP:用户打开页面后,看到的主要内容(通常是大图或大标题)花了多久才渲染出来。这是"快不快"的感觉来源。
- INP:用户点了按钮、拖了滚动条之后,页面多久才有反应。这是"卡不卡"的感觉来源。2024 年 3 月它正式取代了老指标 FID(First Input Delay),因为 FID 只测第一次交互,INP 测所有交互,更贴近真实体验。
- CLS:页面加载过程中元素到处乱跳的严重程度。你正要点一个按钮,结果广告位插进来把按钮挤走了,你点到了广告——这种体验的量化就是 CLS。
除了这三个核心指标,还有几个辅助指标值得关注:
| 指标 | 全称 | 含义 | 良好阈值 |
|---|---|---|---|
| TTFB | Time to First Byte | 首字节时间,服务器响应速度 | ≤ 800ms |
| FCP | First Contentful Paint | 首次内容绘制 | ≤ 1.8s |
| TTI | Time to Interactive | 可交互时间 | — |
为什么 INP 取代了 FID
这件事值得单独说,因为很多人还在拿 FID 的数据报告。
FID 只测量用户第一次与页面交互的延迟。问题在于:第一次交互可能很快,但后续交互可能很卡。一个典型的例子是页面加载后前 2 秒响应飞快,但 5 秒后有一段 JavaScript 长任务跑起来,用户这时候点按钮就会卡住——FID 抓不到这种情况。
INP 取所有交互的最差值(去掉离群点后),反映的是"整个页面生命周期里最差的那次交互体验"。对用户来说,最差的那一次才是最刻骨铭心的。
Google 官方公告中明确指出 INP 于 2024 年 3 月成为稳定 Core Web Vital,取代 FID。如果你现在还在看 FID 数据,该换了。
两种监控方式:RUM vs 合成监控
前端性能监控有两种数据采集方式,各有适用场景:
| 维度 | RUM(真实用户监控) | 合成监控(Synthetic) |
|---|---|---|
| 数据来源 | 真实用户浏览器 | 模拟浏览器(Lighthouse、Headless Chrome) |
| 网络环境 | 真实(4G/WiFi/弱网混合) | 可控(预设网络限速) |
| 覆盖面 | 全量用户 | 固定 URL + 固定脚本 |
| 优势 | 反映真实体验 | 可复现、可对比、可回归 |
| 劣势 | 噪声大、难复现 | 和真实用户有偏差 |
| 典型工具 | 百度统计、web-vitals.js、Sentry | Lighthouse CI、WebPageTest |
我的建议是:两者都用,但分工不同。
- RUM 是主数据源,它告诉你"用户实际体验怎样"。告警阈值、SLO 设定都基于 RUM 数据。
- 合成监控是辅助手段,用于 CI/CD 流水线里的性能回归检测。每次发版前跑一次 Lighthouse,P75 的 LCP 比上次差了 500ms 就拦截。这种场景下合成监控比 RUM 更合适,因为可复现。
别犯一个常见错误:拿合成监控的数据当作真实用户体验来汇报。实验室里千兆光纤跑出来的 LCP 是 0.8 秒,真实用户在地铁里 4G 信号下可能要 4 秒。两码事。
web-vitals.js:最轻量的 RUM 采集方案
Google 官方维护的 web-vitals 库是目前最简洁的 Core Web Vitals 采集工具。它解决了浏览器原生 API 的一个痛点:这些指标不是一次性事件,而是需要持续观察的。
为什么不能直接用 PerformanceObserver
浏览器提供了 PerformanceObserver API 来监听性能事件。但 Core Web Vitals 有个特性——LCP 和 CLS 是"最终值"概念,只在页面卸载或切到后台时才能确定最终分数。如果你在页面加载时就开始监听并上报,拿到的可能是中间值,不是最终值。
web-vitals 库帮你处理了这些边界情况:
// 最简采集方案 - 使用 web-vitals 库(v4+)
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';
// 上报函数 - 发送到你的后端采集服务
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name, // 指标名:LCP/INP/CLS 等
value: metric.value, // 数值
rating: metric.rating, // good/needs-improvement/poor
id: metric.id, // 唯一标识,用于去重
delta: metric.delta, // 增量值(CLS 特有)
page_url: window.location.href,
page_path: window.location.pathname,
user_agent: navigator.userAgent,
// 采样标识,避免全量上报压垮后端
session_id: getSessionId(),
timestamp: Date.now(),
});
// 用 sendBeacon 而不是 fetch,原因在下面解释
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/rum/collect', body);
} else {
fetch('/api/rum/collect', { body, method: 'POST', keepalive: true });
}
}
// 绑定监听 - 这些回调会在指标最终确定时触发
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onTTFB(sendToAnalytics);
onFCP(sendToAnalytics);
上报时机的坑:sendBeacon vs fetch
这是前端性能监控踩坑最多的地方。
CLS 和 INP 的最终值在页面卸载(pagehide 或 visibilitychange 事件)时才确定。如果你在页面卸载时用 fetch 上报,大概率会失败——浏览器在页面关闭时会终止进行中的请求。
sendBeacon 是浏览器专门为这种场景设计的 API:它把数据放进浏览器队列,即使页面关闭了也会在后台发完。但有体积限制(通常 64KB),所以别往里面塞大对象。
如果 sendBeacon 不可用(老浏览器),用 fetch 加 keepalive: true 参数作为后备方案,效果类似。
采样策略:别全量上报
如果你的网站日均 PV 是 100 万,全量上报每页 5 个指标就是 500 万条数据/天。后端扛不住,也不需要。
// 按 session 采样 - 10% 的 session 采集全量指标
function shouldSample() {
const sampleRate = 0.1; // 10%
// 用 session 维度采样,同一用户多次访问要么全采要么全不采
// 避免单用户只采到部分指标,数据割裂
const sessionId = getSessionId();
const hash = simpleHash(sessionId);
return (hash % 100) < (sampleRate * 100);
}
function simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
if (shouldSample()) {
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
}
采样率多少合适?取决于你的流量量级:
| 日均 PV | 建议采样率 | 日采集量(估算) |
|---|---|---|
| < 1 万 | 100% | 5 万条 |
| 1 万 - 10 万 | 50% | 25-250 万条 |
| 10 万 - 100 万 | 10% | 50-500 万条 |
| > 100 万 | 1-5% | 50-500 万条 |
原则是:采样后每天的数据量在百万条以内,统计上 P75 值足够稳定就行。
后端采集与存储
前端把数据发出来了,后端怎么接、怎么存,决定了你能做多深的分析。
采集端设计
一个最小可用的 RUM 采集服务用 Go 写大概是这样:
package main
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
type RumMetric struct {
Name string `json:"name"`
Value float64 `json:"value"`
Rating string `json:"rating"`
ID string `json:"id"`
Delta float64 `json:"delta"`
PageURL string `json:"page_url"`
PagePath string `json:"page_path"`
UserAgent string `json:"user_agent"`
SessionID string `json:"session_id"`
Timestamp int64 `json:"timestamp"`
}
func collectHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method Not Allowed", 405)
return
}
var m RumMetric
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
http.Error(w, "Bad Request", 400)
return
}
// 基础校验 - 挡掉乱七八糟的脏数据
if m.Name == "" || m.Value <= 0 || m.PagePath == "" {
http.Error(w, "Invalid metric", 400)
return
}
// 写入 Redis Stream,后端异步消费写时序库
// 用 Stream 而不是直接写库,防止突发流量打挂数据库
data, _ := json.Marshal(m)
err := rdb.XAdd(ctx, &redis.XAddArgs{
Stream: "rum:metrics",
Values: map[string]interface{}{
"data": string(data),
"received": time.Now().UnixMilli(),
},
MaxLen: 100000, // 防止积压爆内存
}).Err()
if err != nil {
log.Printf("redis XAdd failed: %v", err)
http.Error(w, "Internal Error", 500)
return
}
w.WriteHeader(204)
}
func main() {
http.HandleFunc("/api/rum/collect", collectHandler)
log.Println("RUM collector listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
存储方案选型
RUM 数据是典型的时间序列数据,存储选型有几个方向:
| 方案 | 适合规模 | 查询能力 | 成本 | 推荐场景 |
|---|---|---|---|---|
| Prometheus | 中小规模 | PromQL 聚合 | 低 | 已有 Prometheus 栈的团队 |
| VictoriaMetrics | 中大规模 | PromQL + 高压缩 | 中 | 需要长期存储 + 省钱 |
| ClickHouse | 大规模 | SQL 灵活查询 | 中 | 需要多维度分析 |
| Elasticsearch | 大规模 | 全文检索 + 聚合 | 高 | 已有 ELK 栈 |
| TimescaleDB | 中等 | SQL | 中 | 需要 SQL + 关系数据 |
我的实战推荐:
- 日均 PV < 10 万:直接推到 Prometheus,用 histogram 记录 LCP/INP/CLS 的分布。简单粗暴,几天搞定。
- 日均 PV 10 万 - 100 万:VictoriaMetrics,兼容 PromQL 但存储成本只有 Prometheus 的 1/5 到 1/10。
- 日均 PV > 100 万:ClickHouse,因为你需要按页面、设备、地域、网络类型做多维分析,PromQL 表达不了这么灵活的查询。
Prometheus 存储示例
如果用 Prometheus,需要把单个 metric 转成 histogram,用 pushgateway 或者自己写一个 exporter:
// 把 RUM 数据转成 Prometheus histogram
var (
lcpHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "rum_lcp_seconds",
Help: "Largest Contentful Paint in seconds",
Buckets: []float64{0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 8.0},
})
inpHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "rum_inp_seconds",
Help: "Interaction to Next Paint in seconds",
Buckets: []float64{0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 0.8, 1.0},
})
clsHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "rum_cls_score",
Help: "Cumulative Layout Shift score",
Buckets: []float64{0.01, 0.05, 0.1, 0.15, 0.2, 0.25, 0.5},
})
)
func recordMetric(m RumMetric) {
// 用 label 区分页面路径和设备类型
// 注意 label 基数不要太大,否则 Prometheus 会爆
switch m.Name {
case "LCP":
lcpHistogram.Observe(m.Value / 1000) // ms 转 s
case "INP":
inpHistogram.Observe(m.Value / 1000)
case "CLS":
clsHistogram.Observe(m.Value)
}
}
注意 label 基数陷阱:如果你把
page_path作为 label,而你的网站有上万个 URL,Prometheus 会因为 label 组合爆炸直接 OOM。解决方案是对 page_path 做分组归一化(比如只保留一级路径/product、/article、/search),或者用 ClickHouse 这类支持高基数的存储。
告警规则设计
有了数据,下一步是配告警。前端性能告警和后端告警逻辑一样:定义阈值,超了就触发。
用 P75 而不是平均值
这是前端性能监控的黄金规则:用百分位数(P75)而不是平均值。
原因很简单:平均值会被尾部数据拉偏。100 个用户里 90 个 LCP 是 1 秒,10 个是 10 秒,平均值是 1.9 秒——看起来还行。但 P75 是 1 秒,P95 是 10 秒。你关心的是那 10 个等了 10 秒的用户,平均值把它们藏起来了。
Google 官方的 Core Web Vitals 评估用的就是 P75:一个页面的 LCP P75 ≤ 2.5 秒才算"良好"。意思是 75% 的用户体验在 2.5 秒以内。
PromQL 告警规则
# alertmanager_rules.yml
groups:
- name: frontend-performance
rules:
# LCP P75 超过 4 秒 - 差体验,立即告警
- alert: FrontendLCPPoor
expr: |
histogram_quantile(0.75,
sum(rate(rum_lcp_seconds_bucket[1h])) by (le, page_group)
) > 4.0
for: 10m
labels:
severity: warning
team: frontend
annotations:
summary: "LCP P75 超过 4 秒: {{ $labels.page_group }}"
description: "页面 {{ $labels.page_group }} 的 LCP P75 为 {{ $value }}s,已进入差体验区间"
# INP P75 超过 500ms - 交互卡顿
- alert: FrontendINPPoor
expr: |
histogram_quantile(0.75,
sum(rate(rum_inp_seconds_bucket[1h])) by (le, page_group)
) > 0.5
for: 10m
labels:
severity: warning
team: frontend
annotations:
summary: "INP P75 超过 500ms: {{ $labels.page_group }}"
description: "交互响应延迟 P75 为 {{ $value }}s,用户会明显感到卡顿"
# CLS P75 超过 0.25 - 布局严重抖动
- alert: FrontendCLSPoor
expr: |
histogram_quantile(0.75,
sum(rate(rum_cls_score_bucket[1h])) by (le, page_group)
) > 0.25
for: 10m
labels:
severity: warning
team: frontend
annotations:
summary: "CLS P75 超过 0.25: {{ $labels.page_group }}"
description: "布局偏移严重,P75 为 {{ $value }}"
# 数据量骤降 - 采集可能挂了
- alert: RUMDataDrop
expr: |
sum(rate(rum_lcp_seconds_bucket[5m])) <
sum(rate(rum_lcp_seconds_bucket[5m] offset 1d)) * 0.3
for: 15m
labels:
severity: critical
team: sre
annotations:
summary: "RUM 数据量骤降超过 70%"
description: "可能是采集脚本异常或上报接口故障"
最后一条 RUMDataDrop 告警容易被忽略,但非常关键。RUM 采集依赖前端 JS,一旦发版改坏了上报逻辑,数据量会骤降,但这时候不是"性能变好"而是"监控瞎了"。和后端监控一样,监控本身也需要监控——元监控。
告警分级
不是所有性能劣化都值得半夜叫人。我的分级建议:
| 级别 | 触发条件 | 通知方式 | 响应时间 |
|---|---|---|---|
| P0 | 采集完全中断 | 电话 + IM | 15 分钟 |
| P1 | 核心页面 LCP P75 > 4s 持续 10 分钟 | IM 群 @ | 1 小时 |
| P2 | 次要页面 INP P75 > 500ms 持续 30 分钟 | IM 群消息 | 4 小时 |
| P3 | CLS P75 > 0.25 持续 1 小时 | 日报汇总 | 下个工作日 |
P0 必须电话——采集挂了意味着你看不到任何前端数据,这比性能差更可怕。一个监控盲区的系统,你连出问题了都不知道。
优化方向:从指标到代码
告警响了之后,怎么修?不同指标的优化手段差别很大。
LCP 优化
LCP 元素通常是首屏大图或大标题。优化思路就一条:让这个元素尽快出现。
<!-- 错误做法:LCP 图片用懒加载 -->
<img src="hero.jpg" loading="lazy" alt="首屏主图">
<!-- 懒加载会让首屏图片延迟加载,LCP 直接爆炸 -->
<!-- 正确做法:对 LCP 元素用 preload + eager -->
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
<img src="hero.webp" width="1080" height="720" fetchpriority="high" alt="首屏主图">
<!-- 图片用现代格式:WebP/AVIF 比 JPEG 小 30-50% -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" width="1080" height="720" alt="首屏主图">
</picture>
| 优化手段 | 预期收益 | 实现难度 |
|---|---|---|
| LCP 图片 preload | -0.5 到 -1.5s | 低 |
| WebP/AVIF 替换 JPEG | -30% 到 -50% 体积 | 低 |
| CDN 加速静态资源 | -200ms 到 -800ms | 低 |
| 服务端渲染(SSR) | -1s 到 -3s | 高 |
| 关键 CSS 内联 | -200ms 到 -500ms | 中 |
| 字体 preload + font-display: swap | -300ms 到 -800ms | 低 |
INP 优化
INP 卡的原因 90% 是 JavaScript 长任务(执行超过 50ms 的任务)。优化思路:把长任务拆成短任务。
// 错误做法:一次性处理大数据
function processAllItems(items) {
items.forEach(item => {
// 每个处理耗时 5ms,1000 个就是 5 秒的长任务
heavyProcess(item);
});
}
// 正确做法:用 scheduler.yield 或 setTimeout 拆分
async function processAllItems(items) {
const CHUNK_SIZE = 20; // 每批处理 20 个
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => heavyProcess(item));
// 让出主线程,让浏览器响应用户交互
if (typeof scheduler !== 'undefined' && scheduler.yield) {
// 新 API,Chrome 129+ 支持
await scheduler.yield();
} else {
// 后备方案:用 setTimeout(0) 让出主线程
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
scheduler.yield() 是 2024 年才标准化的新 API,专门为拆分长任务设计。比 setTimeout(0) 更精准——它会让浏览器优先处理待响应用户输入,然后再回来执行你的任务。
CLS 优化
CLS 的根因几乎都是"元素尺寸不确定"。修法就一句话:给所有媒体元素指定尺寸。
<!-- 错误:没指定 width/height -->
<img src="photo.jpg" alt="照片">
<!-- 浏览器不知道图片多大,加载完后布局重排,CLS 飙升 -->
<!-- 正确:指定尺寸 -->
<img src="photo.jpg" width="800" height="600" alt="照片">
<!-- 更好:用 CSS aspect-ratio,适配响应式 -->
<img src="photo.jpg" style="aspect-ratio: 4/3; width: 100%;" alt="照片">
<!-- 广告位:预留占位空间 -->
<div class="ad-slot" style="min-height: 250px;">
<!-- 广告加载前就有高度,不会把下方内容挤走 -->
</div>
| CLS 根因 | 占比(实测) | 修法 |
|---|---|---|
| 图片无尺寸 | 40% | 加 width/height 或 aspect-ratio |
| 字体加载闪烁 | 25% | font-display: swap + 字体 preload |
| 动态插入广告 | 20% | 预留占位 min-height |
| 异步内容加载 | 15% | 骨架屏占位 |
Grafana 仪表盘
监控有了、告警有了,还需要一个可视化大盘让团队一眼看到趋势。
核心面板建议:
- 总览面板:三个核心指标的 P75 值,按页面分组,绿/黄/红三色标注
- 趋势面板:过去 7 天 LCP/INP/CLS 的 P75 趋势线,标注发版时间点
- 分布面板:LCP 的直方图,看分布形状而非单一数值
- 设备面板:按设备类型(Mobile/Tablet/Desktop)分组的 P75 对比
- 页面面板:按 page_group 分组的指标排名,找出最差的页面
# 总览面板 - 三大指标 P75 按页面分组
histogram_quantile(0.75, sum(rate(rum_lcp_seconds_bucket[1h])) by (le, page_group))
# 设备维度对比
histogram_quantile(0.75, sum(rate(rum_lcp_seconds_bucket[1h])) by (le, device_type))
# 发版前后对比 - 用 offset 对比昨天同时段
histogram_quantile(0.75, sum(rate(rum_lcp_seconds_bucket[1h])) by (le))
-
histogram_quantile(0.75, sum(rate(rum_lcp_seconds_bucket[1h] offset 1d)) by (le))
发版时间点标注是实战中非常好用的功能。在 Grafana 里用 annotation 标记每次发版时间,如果某次发版后 LCP 骤升,一眼就能关联到是哪次发版引入的退化。
实战踩坑总结
做了几个项目的前端性能监控后,几个反复踩过的坑:
坑 1:只报成功,不报失败。 采集脚本本身报错了,用户那边的指标没采到,你的大盘显示一切正常。解法:采集脚本要加错误上报,用 window.onerror 捕获自身异常,单独推一条 rum_collector_error 指标。
坑 2:SPA 路由切换不重置。 单页应用路由切换后,LCP 的候选元素变了,但 web-vitals 不会自动重置。需要在路由切换时调用 onLCP 的清理函数重新绑定,或者用 SPA 专用的采集逻辑。
坑 3:第三方脚本拖累。 统计 SDK、广告 SDK、客服插件这些第三方脚本会严重拖慢 INP。你优化半天没效果,一查发现是某个客服插件的 JS 占了主线程 2 秒。解法:用 requestIdleCallback 延迟加载非关键第三方脚本,或者用 Partytown 把第三方脚本扔到 Web Worker 里跑。
坑 4:弱网用户被采样漏掉。 如果按 session 采样,弱网用户可能因为 JS 加载慢导致采集脚本没跑完就离开了,他们的数据永远不会进入你的系统。解法:在 <head> 里尽早加载采集脚本(不是放在 </body> 前),用 defer 而不是动态加载。
坑 5:把实验室数据当真实数据。 Lighthouse CI 跑出来的分数很好看,但那是在理想网络下的模拟。真实用户的体验只有 RUM 能告诉你。别拿 Lighthouse 分数向上汇报真实用户体验,会翻车。
总结
前端性能监控的核心思路:用 Core Web Vitals 量化用户感受,用 RUM 采集真实数据,用 P75 百分位设定告警阈值,最后形成"采集-告警-优化-验证"的闭环。
几个关键决策点:
- 指标选 INP 不选 FID——FID 已被 Google 废弃,INP 才反映真实交互体验
- 数据源选 RUM 不选合成监控——合成监控是 CI 回归用的,告警和 SLO 必须基于 RUM
- 百分位选 P75 不选平均值——平均值会掩盖尾部用户的问题
- 存储按流量选型——小流量用 Prometheus,大流量用 ClickHouse,别硬扛
- 告警必须包含元监控——采集挂了比性能差更要命,得能发现
前端性能监控不是一次性项目,是持续运营。每周看一次趋势,每次发版对比前后数据,发现退化及时修。用户不会因为 LCP 从 3 秒优化到 2 秒来夸你,但他们会因为不再投诉"页面慢"而默默留下来——这就是做这件事的价值。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- web-vitals - GitHub — Google Chrome 团队,Core Web Vitals 官方采集库,本文采集方案的核心依赖
- 前端性能优化:Web Vitals 指标分析与实战改进 — dblens,Core Web Vitals 指标详解与代码级优化方案
- 极致渲染:Paint Timing API 与 Web 性能优化深度实战 — CSDN 博主,浏览器渲染管线与 Paint Timing API 的底层原理
- 第6期:前端性能优化 —— 从指标监控到极致渲染 — 51CTO 博主,2026 年 Core Web Vitals 指标体系更新与 INP 取代 FID 的说明