Web Vitals 数据采集机制分析

18 阅读10分钟

本文档基于 Google web-vitals 库源码,详细分析 CLS、FCP、INP、LCP、TTFB 五个核心指标的数据采集原理与实现细节。


目录

  1. 通用基础架构
  2. CLS (Cumulative Layout Shift)
  3. FCP (First Contentful Paint)
  4. INP (Interaction to Next Paint)
  5. LCP (Largest Contentful Paint)
  6. TTFB (Time to First Byte)
  7. 各指标对比总结

通用基础架构

所有指标共享一套基础工具函数,理解这些是分析各指标的前提。

observe() — PerformanceObserver 封装

所有基于 PerformanceObserver 的指标(CLS、FCP、INP、LCP)都通过 observe() 函数统一创建观察者:

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() 创建一个标准结构的指标对象:

{
  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 时上报
  • forceReporttruereportAllChangestrue 时才执行上报
  • 计算 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 Improvement0.1 ~ 0.25
Poor> 0.25

数据采集流程

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 算法:

  1. 将连续发生的布局偏移分组为 "session"
  2. 同一 session 条件:与上一条目间隔 < 1 秒 与 session 首条目间隔 < 5 秒
  3. 每个 session 的总偏移值 = session 内所有条目 value 之和
  4. CLS = 所有 session 中最大的那个 session 的总偏移值

关键特点

  • 依赖 FCP:只有 FCP 成功触发后才会开始 CLS 采集(通过 onFCP(runOnce(...)) 实现)
  • 持续监听:CLS 在整个页面生命周期内持续采集,不会自动停止
  • 页面隐藏时上报:通过 visibilityWatcher.onHidden() 在页面切到后台时强制取出残留记录并上报
  • 过滤用户输入hadRecentInputtrue 的偏移(如点击导致的展开)被排除

涉及的浏览器 API

  • PerformanceObserverlayout-shift 类型
  • LayoutShift.value / LayoutShift.hadRecentInput / LayoutShift.startTime

FCP (First Contentful Paint)

定义

首次内容绘制时间,从页面导航到浏览器首次渲染 DOM 内容(文本、图片、SVG 等)的时间。

阈值

评级范围
Good≤ 1800ms
Needs Improvement1800ms ~ 3000ms
Poor> 3000ms

数据采集流程

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

  • PerformanceObserverpaint 类型
  • PerformancePaintTimingname === 'first-contentful-paint'
  • PerformanceNavigationTiming.activationStart

INP (Interaction to Next Paint)

定义

交互到下一次绘制的延迟,衡量页面对用户交互的响应速度。取所有交互中近似 P98 的最慢交互延迟。

阈值

评级范围
Good≤ 200ms
Needs Improvement200ms ~ 500ms
Poor> 500ms

数据采集流程

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 的核心逻辑:

  1. 维护 Top 10 最慢交互列表:只保留持续时间最长的 10 个交互
  2. 同一交互合并:通过 interactionId 识别同一交互的多个事件条目,取最大 duration
  3. 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

  • PerformanceObserverevent 类型 + first-input 类型
  • PerformanceEventTiming.interactionId
  • PerformanceEventTiming.duration
  • performance.interactionCount(+ polyfill)

LCP (Largest Contentful Paint)

定义

最大内容绘制时间,从页面导航到最大可见内容元素(图片、文本块等)完成渲染的时间。

阈值

评级范围
Good≤ 2500ms
Needs Improvement2500ms ~ 4000ms
Poor> 4000ms

数据采集流程

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

  • PerformanceObserverlargest-contentful-paint 类型
  • LargestContentfulPaint.startTime(返回 renderTime 或 loadTime)
  • LargestContentfulPaint.size / LargestContentfulPaint.element
  • PerformanceNavigationTiming.activationStart

TTFB (Time to First Byte)

定义

首字节时间,从页面导航开始到接收到服务器响应的第一个字节的时间。包含 DNS 查询、TCP 连接、TLS 协商、服务器处理时间、网络延迟。

阈值

评级范围
Good≤ 800ms
Needs Improvement800ms ~ 1800ms
Poor> 1800ms

数据采集流程

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')PerformanceNavigationTiming
  • PerformanceNavigationTiming.responseStart
  • PerformanceNavigationTiming.activationStart

各指标对比总结

特性CLSFCPINPLCPTTFB
采集方式PerformanceObserverPerformanceObserverPerformanceObserverPerformanceObserverNavigation Timing API
监听的条目类型layout-shiftpaintevent + first-inputlargest-contentful-paintnavigation
一次性/持续性持续采集一次性持续采集用户交互后停止一次性
默认值0-1-1-1-1
阈值 (good/poor)0.1 / 0.251800ms / 3000ms200ms / 500ms2500ms / 4000ms800ms / 1800ms
可见性过滤
页面隐藏时上报是(同时停止采集)
BFCache 处理重置 session,重新采集重新采集(doubleRAF)重置交互列表重新采集(doubleRAF)报告值 0
预渲染支持通过 FCP 间接支持是(whenActivated)是(whenActivated)是(whenActivated)是(whenReady)
依赖其他指标依赖 FCP
核心算法Session Window(最大窗口)取首个 FCP 条目P98 估算(Top 10 交互)取最后/最大绘制条目直接读取 responseStart

数据流示意图

浏览器 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)