Web Vitals 数据采集机制分析

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


目录

  1. 通用基础架构
  2. [CLS (Cumulative Layout Shift)](#CLS (Cumulative Layout Shift) "#cls-cumulative-layout-shift")
  3. [FCP (First Contentful Paint)](#FCP (First Contentful Paint) "#fcp-first-contentful-paint")
  4. [INP (Interaction to Next Paint)](#INP (Interaction to Next Paint) "#inp-interaction-to-next-paint")
  5. [LCP (Largest Contentful Paint)](#LCP (Largest Contentful Paint) "#lcp-largest-contentful-paint")
  6. [TTFB (Time to First Byte)](#TTFB (Time to First Byte) "#ttfb-time-to-first-byte")
  7. 各指标对比总结

通用基础架构

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

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 时上报
  • 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 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 算法:

  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 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

  • PerformanceObserverpaint 类型
  • PerformancePaintTimingname === '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 的核心逻辑:

  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 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

  • PerformanceObserverlargest-contentful-paint 类型
  • LargestContentfulPaint.startTime(返回 renderTime 或 loadTime)
  • LargestContentfulPaint.size / LargestContentfulPaint.element
  • PerformanceNavigationTiming.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')PerformanceNavigationTiming
  • PerformanceNavigationTiming.responseStart
  • PerformanceNavigationTiming.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)
相关推荐
sniper2 小时前
AI+Shopify 前端开发:实战一年后,聊聊 AI Agent 和前端的生死局
前端
南囝coding2 小时前
OpenClaw 到底能干什么?可以看看这 60 个真实用例
前端·后端
重庆穿山甲2 小时前
Java开发者的大模型入门:AgentScope Java组件全攻略(二)
前端·后端
我爱吃土豆11192 小时前
从零到上架:Chrome 新标签页生产力扩展 FocusTab
前端·产品
敲代码的约德尔人2 小时前
我在 3 个项目中踩坑后,才真正理解了 JavaScript 设计模式
前端·javascript
子淼8122 小时前
Kali Linux 入门指南:基础操作与常用指令解析
前端
QYR市场调研3 小时前
低密度聚乙烯市场竞争格局变化趋势
前端
学以智用3 小时前
Vue 3 组件完全指南
前端·vue.js
重庆穿山甲3 小时前
Java开发者的大模型入门:AgentScope Java组件全攻略(一)
前端·后端