本文档基于 Google web-vitals 库源码,详细分析 CLS、FCP、INP、LCP、TTFB 五个核心指标的数据采集原理与实现细节。
目录
- 通用基础架构
- [CLS (Cumulative Layout Shift)](#CLS (Cumulative Layout Shift) "#cls-cumulative-layout-shift")
- [FCP (First Contentful Paint)](#FCP (First Contentful Paint) "#fcp-first-contentful-paint")
- [INP (Interaction to Next Paint)](#INP (Interaction to Next Paint) "#inp-interaction-to-next-paint")
- [LCP (Largest Contentful Paint)](#LCP (Largest Contentful Paint) "#lcp-largest-contentful-paint")
- [TTFB (Time to First Byte)](#TTFB (Time to First Byte) "#ttfb-time-to-first-byte")
- 各指标对比总结
通用基础架构
所有指标共享一套基础工具函数,理解这些是分析各指标的前提。
observe() --- PerformanceObserver 封装
所有基于 PerformanceObserver 的指标(CLS、FCP、INP、LCP)都通过 observe() 函数统一创建观察者:
typescript
const po = new PerformanceObserver((list) => {
queueMicrotask(() => {
callback(list.getEntries());
});
});
po.observe({ type, buffered: true, ...opts });
关键点:
buffered: true:可以获取在观察者注册之前已经发生的性能条目,避免遗漏queueMicrotask:将回调延迟到微任务中执行,解决 Safari 中回调被立即同步调用的 bug- 特性检测 :通过
PerformanceObserver.supportedEntryTypes.includes(type)检测浏览器是否支持该条目类型
initMetric() --- 指标对象初始化
每个指标都会通过 initMetric() 创建一个标准结构的指标对象:
typescript
{
name, // 指标名称:'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB'
value, // 指标值,默认 -1
rating: 'good', // 评级:'good' | 'needs-improvement' | 'poor'
delta: 0, // 与上次报告值的差值
entries: [], // 相关的 PerformanceEntry 数组
id: generateUniqueID(), // 唯一 ID(v4 UUID 格式)
navigationType, // 导航类型:'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender' | 'restore'
}
bindReporter() --- 上报控制
控制何时触发回调,避免重复/无意义上报:
- 只在
metric.value >= 0时上报 forceReport为true或reportAllChanges为true时才执行上报- 计算
delta(当前值与上次报告值的差值),只有 delta 非零或首次上报时才触发回调 - 根据阈值计算
rating(good / needs-improvement / poor)
getVisibilityWatcher() --- 页面可见性追踪
追踪页面首次隐藏时间 firstHiddenTime,用于判断指标是否在页面可见时采集(不可见时采集的数据不可靠):
- 页面初始隐藏(后台加载)→
firstHiddenTime = 0 - 页面初始可见 →
firstHiddenTime = Infinity(直到visibilitychange事件更新) - 优先使用
visibility-state性能条目(更精确),降级使用document.visibilityState - 提供
onHidden(cb)注册页面隐藏时的回调
onBFCacheRestore() --- BFCache 恢复处理
监听 pageshow 事件的 event.persisted 属性,在页面从 BFCache(前进/后退缓存)恢复时重新初始化指标。所有指标都处理了 BFCache 场景。
whenActivated() --- 预渲染激活等待
如果页面正在预渲染(document.prerendering === true),等待 prerenderingchange 事件触发后再执行采集逻辑。
getActivationStart() --- 激活时间获取
返回 PerformanceNavigationTiming.activationStart,用于预渲染场景下将指标值相对于页面激活时间计算,而非导航开始时间。
CLS (Cumulative Layout Shift)
定义
累积布局偏移,衡量页面生命周期内视觉稳定性。值越低越好。
阈值
| 评级 | 范围 |
|---|---|
| Good | ≤ 0.1 |
| Needs Improvement | 0.1 ~ 0.25 |
| Poor | > 0.25 |
数据采集流程
scss
onCLS()
│
├─ 等待 FCP 触发(匹配 CrUX 行为,只有 FCP 报告了才报告 CLS)
│
├─ observe('layout-shift', handleEntries)
│ 监听所有布局偏移条目
│
├─ LayoutShiftManager._processEntry(entry)
│ ├─ 忽略 hadRecentInput === true 的条目(用户主动操作导致的偏移不计入)
│ ├─ 按 Session Window 算法分组:
│ │ - 相邻条目间隔 < 1 秒
│ │ - 整个 session 持续时间 < 5 秒
│ │ - 超出则开启新 session
│ └─ 累加 session 内的偏移值
│
├─ 取所有 session 中最大值作为 CLS 值
│
├─ visibilityWatcher.onHidden() → 页面隐藏时强制上报
│
└─ onBFCacheRestore() → BFCache 恢复时重置 session,重新采集
核心算法:Session Window
LayoutShiftManager 实现了 CLS 的 Session Window 算法:
- 将连续发生的布局偏移分组为 "session"
- 同一 session 条件 :与上一条目间隔 < 1 秒 且 与 session 首条目间隔 < 5 秒
- 每个 session 的总偏移值 = session 内所有条目
value之和 - CLS = 所有 session 中最大的那个 session 的总偏移值
关键特点
- 依赖 FCP :只有 FCP 成功触发后才会开始 CLS 采集(通过
onFCP(runOnce(...))实现) - 持续监听:CLS 在整个页面生命周期内持续采集,不会自动停止
- 页面隐藏时上报 :通过
visibilityWatcher.onHidden()在页面切到后台时强制取出残留记录并上报 - 过滤用户输入 :
hadRecentInput为true的偏移(如点击导致的展开)被排除
涉及的浏览器 API
PerformanceObserver→layout-shift类型LayoutShift.value/LayoutShift.hadRecentInput/LayoutShift.startTime
FCP (First Contentful Paint)
定义
首次内容绘制时间,从页面导航到浏览器首次渲染 DOM 内容(文本、图片、SVG 等)的时间。
阈值
| 评级 | 范围 |
|---|---|
| Good | ≤ 1800ms |
| Needs Improvement | 1800ms ~ 3000ms |
| Poor | > 3000ms |
数据采集流程
scss
onFCP()
│
├─ whenActivated() → 预渲染页面等待激活
│
├─ observe('paint', handleEntries)
│ 监听 paint 类型条目
│
├─ handleEntries()
│ ├─ 遍历条目,找到 name === 'first-contentful-paint' 的条目
│ ├─ 检查 entry.startTime < firstHiddenTime(页面在可见状态下才采集)
│ ├─ 计算值:Math.max(entry.startTime - activationStart, 0)
│ ├─ 断开 PerformanceObserver(只需采集一次)
│ └─ 强制上报 report(true)
│
└─ onBFCacheRestore() → BFCache 恢复时:
├─ 重新初始化指标
└─ doubleRAF() 后计算:performance.now() - event.timeStamp
关键特点
- 一次性采集 :找到
first-contentful-paint条目后立即断开观察者 - 可见性过滤:只有在页面首次隐藏之前的 FCP 才会被报告(后台页面的 FCP 不可靠)
- 预渲染修正 :使用
activationStart修正预渲染场景下的时间起点 - BFCache 场景 :通过
doubleRAF(两帧后)计算performance.now() - event.timeStamp作为 BFCache 恢复后的 FCP
涉及的浏览器 API
PerformanceObserver→paint类型PerformancePaintTiming(name === 'first-contentful-paint')PerformanceNavigationTiming.activationStart
INP (Interaction to Next Paint)
定义
交互到下一次绘制的延迟,衡量页面对用户交互的响应速度。取所有交互中近似 P98 的最慢交互延迟。
阈值
| 评级 | 范围 |
|---|---|
| Good | ≤ 200ms |
| Needs Improvement | 200ms ~ 500ms |
| Poor | > 500ms |
数据采集流程
scss
onINP()
│
├─ 特性检测:PerformanceEventTiming + interactionId 是否支持
│
├─ whenActivated() → 预渲染页面等待激活
│
├─ initInteractionCountPolyfill() → 交互计数 polyfill
│
├─ observe('event', handleEntries, { durationThreshold: 40 })
│ 监听 event 类型条目,默认忽略 duration < 40ms 的交互
│
├─ po.observe({ type: 'first-input', buffered: true })
│ 额外监听 first-input,兜底首次交互 < durationThreshold 的情况
│
├─ handleEntries() → whenIdleOrHidden() 中异步处理
│ ├─ InteractionManager._processEntry(entry)
│ │ ├─ 过滤无 interactionId 且非 first-input 的条目
│ │ ├─ 维护最多 10 个最慢交互的列表(按 duration 降序排列)
│ │ ├─ 同一 interactionId 的多个条目合并(取最大 duration)
│ │ └─ 超出 10 个时移除最短的
│ │
│ └─ InteractionManager._estimateP98LongestInteraction()
│ ├─ 索引 = min(列表长度 - 1, floor(interactionCount / 50))
│ └─ 返回该索引处的交互作为 P98 估算值
│
├─ visibilityWatcher.onHidden() → 页面隐藏时强制上报
│
└─ onBFCacheRestore() → 重置交互列表,重新采集
核心算法:P98 估算
InteractionManager 的核心逻辑:
- 维护 Top 10 最慢交互列表:只保留持续时间最长的 10 个交互
- 同一交互合并 :通过
interactionId识别同一交互的多个事件条目,取最大duration - P98 计算 :
index = min(listLength - 1, floor(totalInteractionCount / 50))- 例如:100 次交互 → index = floor(100/50) = 2 → 取第 3 慢的交互
- 这是一种近似 P98 分位数的高效估算方式
关键特点
durationThreshold:默认 40ms,低于此值的交互不被 PerformanceObserver 报告(Event Timing 的 duration 精度为 8ms 取整)first-input兜底 :额外观察first-input类型,防止首次交互 duration < durationThreshold 时完全无数据whenIdleOrHidden异步处理:将条目处理延迟到空闲时或页面隐藏时,增加同一交互的多个条目都已派发的概率,同时减少对 INP 本身的影响- 持续监听:INP 在整个页面生命周期内持续采集
涉及的浏览器 API
PerformanceObserver→event类型 +first-input类型PerformanceEventTiming.interactionIdPerformanceEventTiming.durationperformance.interactionCount(+ polyfill)
LCP (Largest Contentful Paint)
定义
最大内容绘制时间,从页面导航到最大可见内容元素(图片、文本块等)完成渲染的时间。
阈值
| 评级 | 范围 |
|---|---|
| Good | ≤ 2500ms |
| Needs Improvement | 2500ms ~ 4000ms |
| Poor | > 4000ms |
数据采集流程
scss
onLCP()
│
├─ whenActivated() → 预渲染页面等待激活
│
├─ observe('largest-contentful-paint', handleEntries)
│ 监听最大内容绘制条目
│
├─ handleEntries()
│ ├─ 非 reportAllChanges 模式只处理最后一个条目
│ ├─ LCPEntryManager._processEntry(entry)(可选扩展点)
│ ├─ 检查 entry.startTime < firstHiddenTime
│ ├─ 计算值:Math.max(entry.startTime - activationStart, 0)
│ └─ report()
│
├─ stopListening(runOnce 保证只执行一次)
│ ├─ 取出残留记录 po.takeRecords()
│ ├─ 断开 PerformanceObserver
│ └─ 强制上报 report(true)
│
├─ stopListeningWrapper → 用户交互或页面隐藏时停止监听
│ ├─ 监听 'keydown' / 'click' / 'visibilitychange'
│ ├─ 只处理 event.isTrusted(过滤程序触发的事件)
│ ├─ 通过 whenIdleOrHidden 延迟执行,减少对 INP 的影响
│ └─ 执行后移除事件监听
│
└─ onBFCacheRestore() → BFCache 恢复时:
├─ 重新初始化指标
└─ doubleRAF() 后计算:performance.now() - event.timeStamp
关键特点
- 最终性机制 :LCP 在用户首次交互(
keydown/click)或页面隐藏(visibilitychange)时停止采集。浏览器规范中 LCP 在用户交互后不再更新 - 不监听 scroll:虽然 scroll 也会停止 LCP 观察,但因 scroll 可被程序触发,所以不监听
isTrusted过滤 :只响应用户真实操作,忽略dispatchEvent等程序触发的事件whenIdleOrHidden:停止监听逻辑包裹在空闲回调中,避免影响 INP 指标runOnce:保证停止逻辑只执行一次,即使多个事件同时触发- 可见性过滤:只有在页面首次隐藏之前的 LCP 才会被报告
涉及的浏览器 API
PerformanceObserver→largest-contentful-paint类型LargestContentfulPaint.startTime(返回 renderTime 或 loadTime)LargestContentfulPaint.size/LargestContentfulPaint.elementPerformanceNavigationTiming.activationStart
TTFB (Time to First Byte)
定义
首字节时间,从页面导航开始到接收到服务器响应的第一个字节的时间。包含 DNS 查询、TCP 连接、TLS 协商、服务器处理时间、网络延迟。
阈值
| 评级 | 范围 |
|---|---|
| Good | ≤ 800ms |
| Needs Improvement | 800ms ~ 1800ms |
| Poor | > 1800ms |
数据采集流程
scss
onTTFB()
│
├─ initMetric('TTFB') + bindReporter() → 初始化(无需等待激活)
│
├─ whenReady() → 等待页面完全加载
│ ├─ document.prerendering → 等待预渲染结束后递归
│ ├─ document.readyState !== 'complete' → 监听 load 事件后递归
│ └─ 页面加载完成 → setTimeout(callback) 在 loadEventEnd 之后执行
│
├─ getNavigationEntry()
│ ├─ performance.getEntriesByType('navigation')[0]
│ └─ 校验 responseStart:> 0 且 < performance.now()
│ (过滤隐私保护或 bug 导致的异常值)
│
├─ 计算值:Math.max(navigationEntry.responseStart - activationStart, 0)
│
├─ report(true) → 立即强制上报
│
└─ onBFCacheRestore() → BFCache 恢复时:
├─ initMetric('TTFB', 0) → 初始值设为 0
└─ report(true) → 立即上报
关键特点
- 不使用 PerformanceObserver :直接通过
performance.getEntriesByType('navigation')获取 Navigation Timing 条目 - 等待页面完全加载 :通过
whenReady()确保在loadEventEnd之后获取数据,此时 Navigation Timing 的所有属性才完整 - 数据校验 :
responseStart必须大于 0 且小于当前时间,排除浏览器隐私保护或 bug 导致的异常值 - BFCache 场景:恢复时 TTFB 值设为 0(因为没有网络请求)
- 预渲染支持 :使用
activationStart修正时间起点
涉及的浏览器 API
Performance.getEntriesByType('navigation')→PerformanceNavigationTimingPerformanceNavigationTiming.responseStartPerformanceNavigationTiming.activationStart
各指标对比总结
| 特性 | CLS | FCP | INP | LCP | TTFB |
|---|---|---|---|---|---|
| 采集方式 | PerformanceObserver | PerformanceObserver | PerformanceObserver | PerformanceObserver | Navigation Timing API |
| 监听的条目类型 | layout-shift |
paint |
event + first-input |
largest-contentful-paint |
navigation |
| 一次性/持续性 | 持续采集 | 一次性 | 持续采集 | 用户交互后停止 | 一次性 |
| 默认值 | 0 | -1 | -1 | -1 | -1 |
| 阈值 (good/poor) | 0.1 / 0.25 | 1800ms / 3000ms | 200ms / 500ms | 2500ms / 4000ms | 800ms / 1800ms |
| 可见性过滤 | 无 | 是 | 无 | 是 | 无 |
| 页面隐藏时上报 | 是 | 否 | 是 | 是(同时停止采集) | 否 |
| BFCache 处理 | 重置 session,重新采集 | 重新采集(doubleRAF) | 重置交互列表 | 重新采集(doubleRAF) | 报告值 0 |
| 预渲染支持 | 通过 FCP 间接支持 | 是(whenActivated) | 是(whenActivated) | 是(whenActivated) | 是(whenReady) |
| 依赖其他指标 | 依赖 FCP | 无 | 无 | 无 | 无 |
| 核心算法 | Session Window(最大窗口) | 取首个 FCP 条目 | P98 估算(Top 10 交互) | 取最后/最大绘制条目 | 直接读取 responseStart |
数据流示意图
scss
浏览器 Performance API
│
▼
PerformanceObserver / getEntriesByType
│
▼
observe() 封装(buffered + microtask)
│
▼
handleEntries() ─── 各指标特有的处理逻辑
│ │
│ ┌───────┼───────┐──────────┐──────────┐
│ CLS FCP INP LCP TTFB
│ Session 首次 P98 最大 responseStart
│ Window 绘制 交互延迟 内容绘制
│
▼
bindReporter() ─── 统一上报控制
│
├─ 计算 delta
├─ 评估 rating(good / needs-improvement / poor)
└─ 触发用户回调 callback(metric)