前端性能之Web Vitals

469 阅读8分钟

定义与背景

“网页指标”计划是 Google 推出的一项计划,旨在就哪些质量信号对提供出色的网站用户体验至关重要提供统一指南。

三大核心指标

构成 Core Web Vitals 的指标会随着时间的推移而演变。当前这组指标侧重于用户体验的三个方面:加载速度互动性视觉稳定性,其中包括以下指标(及其各自的阈值):

为确保大多数用户都能达到这些指标的建议目标值,一个合适的衡量阈值是网页加载时间的第 75 个百分位数,并按移动设备和桌面设备进行细分。

如果网页在所有三个 Core Web Vitals 指标的第 75 百分位处达到建议的目标,则评估 Core Web Vitals 合规性的工具应将其视为通过测试。

LCP

含义

LCP 会报告视口中可见的最大图片、文本块或视频的渲染时间(相对于用户首次导航到网页的时间)。

得分标准

为了提供良好的用户体验,网站应尽量将 Largest Contentful Paint 控制在 2.5 秒或更短的时间内。为确保大多数用户都能达到此目标值,一个合适的衡量阈值是网页加载时间的第 75 个百分位数,并按移动设备和桌面设备进行细分。

指标与 API 之间的差异

  1. 后台标签页 :API 会为后台加载的网页记录 LCP 条目,但在计算时会忽略这些条目;而指标则仅在网页处于前台时才计算 LCP。
  2. 测量开始时间 :API 从导航开始时刻测量 LCP,而对于预渲染的网页,指标应从 activationStart 开始测量,以更贴近用户实际体验。
entries.forEach((entry) => {
  // 仅在页面在 LCP 之前未隐藏的情况下报告。
  if (entry.startTime < visibilityWatcher.firstHiddenTime) {
    // startTime 属性返回 renderTime 的值(如果不为 0),
    // 否则返回 loadTime 的值。使用 activationStart 引用是因为 LCP
    // 应该相对于页面激活而不是导航开始,如果页面是预渲染的。但在 activationStart
    // 发生在 LCP 之后的情况下,时间应该被限制在 0。
    metric.value = Math.max(entry.startTime - getActivationStart(), 0);
    metric.entries = [entry];
    report();
  }
});

3. 返回/前进缓存 :当网页从缓存恢复时,API 不会记录 LCP 条目,而应考虑包含该 LCP 的指标,因为这是用户感知的网页访问。

// 仅在 PerformanceObserver 成功注册后,才报告 bfcache 恢复后的情况。
onBFCacheRestore((event) => {
  metric = initMetric('LCP');
  report = bindReporter(
    onReport,
    metric,
    LCPThresholds,
    opts!.reportAllChanges,
  );

  doubleRAF(() => {
    metric.value = performance.now() - event.timeStamp;
    reportedMetricIDs[metric.id] = true;
    report(true);
  });
});

INP

含义

INP 是一项指标,通过观察用户访问网页期间发生的所有点击、点按和键盘互动的延迟时间,评估网页对用户互动的总体响应情况。最终 INP 值是观测到的最长互动时间,离群值会被忽略。

互动是指在同一逻辑用户手势期间触发的一组事件处理脚本。例如,触摸屏设备上的“点按”互动包括多个事件,例如 pointeruppointerdownclick。互动可以由 JavaScript、CSS、内置浏览器控件(例如表单元素)或它们的组合驱动。

互动延迟时间包括一组触发互动的事件处理脚本的单个最长时长,从用户开始互动到浏览器下次能够绘制帧的时刻。

得分标准

  • INP 低于或等于 200 毫秒表示网页响应速度良好
  • 如果 INP 高于 200 毫秒或低于 500 毫秒,则表示网页的响应速度需要改进
  • INP 超过 500 毫秒表示网页响应缓慢

基本逻辑

interactionId 仅针对属于用户交互的以下事件类型进行计算。否则为 0 。

事件类型用户交互行为
pointerdownpointerupclickclick / tap / drag
keydownkeyupkey press
const po = new PerformanceObserver((list) => {
  // 遍历所有监听到的性能条目(这里假设是 event 类型条目)
  list.getEntries().forEach((entry) => {
    // 将每个事件条目转换为交互对象,包含:
    const interaction = {
      id: entry.interactionId,       // 交互唯一标识(Chrome 102+ 支持)
      latency: entry.duration,       // 交互延迟时间(从输入到下一帧渲染)
      entries: [entry],              // 关联的原始性能条目数组
    };
    
    // 将交互对象添加到全局的交互列表(longestInteractionList 需在外部定义)
    longestInteractionList.push(interaction);
  });

  // 按延迟时间从高到低排序,找出最长的交互
  longestInteractionList.sort((a, b) => b.latency - a.latency);
  
  // 获取延迟最高的交互作为 INP(Interaction to Next Paint)
  const inp = longestInteractionList[0];

  // 调试输出当前 INP 值
  console.log('Current INP:', inp);
});

// 开始观察性能条目,配置参数:
po.observe({
  type: "event",            // 监听事件类型的性能条目(例如点击、键盘事件)
  buffered: true,           // 立即获取已缓存的性能条目(历史数据)
  durationThreshold: 40,    // 只捕获持续时间 >=40ms 的事件(过滤快速交互)
});

// 说明:
// 1. 此代码用于计算 INP(Interaction to Next Paint)核心 Web Vitals 指标
// 2. 最终 INP 取页面生命周期中延迟最高的交互事件
// 3. longestInteractionList 需在外部作用域定义(示例未展示)
// 4. Chrome 102+ 支持 interactionId(交互id),用于关联事件条目

CLS

含义

CLS 用于衡量在网页的整个生命周期内发生的每一次意外布局偏移的布局偏移得分的最高累计分数。

每当可见元素从一个呈现帧更改到下一个呈现帧时,都会发生布局偏移。(本指南稍后会详细介绍如何计算各个布局偏移分数。)

大量布局偏移(称为会话窗口)是指一个或多个单独的布局偏移快速连续发生,且每次偏移之间的间隔时间不到 1 秒,并且总窗口时长不超过 5 秒。

最严重的突发是指在该时段内所有布局偏移的累计分数最高的会话时段。

得分标准

为了提供良好的用户体验,网站应尽力使 CLS 得分不高于 0.1。为确保大多数用户都能达到此目标值,一个合适的衡量阈值是网页加载时间的第 75 个百分位数,并按移动设备和桌面设备进行细分。

基本逻辑

let metric = {
  value: 0,       // 当前页面最大的CLS值
  entries: [],    // 构成最大CLS值的布局偏移条目集合
};

// 会话跟踪相关变量
let sessionValue = 0;      // 当前会话窗口内的累计布局偏移值
let sessionEntries = [];   // 当前会话窗口内的布局偏移条目集合

// 创建 PerformanceObserver 监听布局偏移事件
const po = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    // 过滤条件:仅处理没有近期用户输入的布局偏移(用户主动操作触发的布局变化不计入CLS)
    if (!entry.hadRecentInput) {
      const firstSessionEntry = sessionEntries[0];      // 当前会话第一个条目
      const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; // 当前会话最后一个条目

      // 会话合并条件判断:
      // 1. 当前条目与前一条目间隔 < 1秒(快速连续偏移)
      // 2. 当前条目与会话第一个条目间隔 < 5秒(会话总窗口时长)
      if (
        sessionValue &&
        entry.startTime - lastSessionEntry?.startTime < 1000 &&
        entry.startTime - firstSessionEntry?.startTime < 5000
      ) {
        // 满足条件:合并到当前会话
        sessionValue += entry.value;        // 累加布局偏移值
        sessionEntries.push(entry);         // 记录条目
      } else {
        // 不满足条件:创建新会话
        sessionValue = entry.value;         // 重置为当前条目的值
        sessionEntries = [entry];           // 重置条目集合
      }
    }
  });

  // 如果当前会话值超过全局最大CLS值,则更新指标
  if (sessionValue > metric.value) {
    metric.value = sessionValue;            // 更新最大CLS值
    metric.entries = [...sessionEntries];   // 克隆会话条目集合(防止引用修改)
    console.log(metric, 'CLS Updated');     // 调试输出(实际应触发上报)
  }
});

// 开始观察布局偏移性能条目,配置参数:
po.observe({ 
  type: "layout-shift",  // 监听布局偏移类型条目
  buffered: true         // 立即获取已缓存的条目(历史数据)
});

/**
 * 关键逻辑说明:
 * 1. 会话窗口机制:将5秒内发生的连续布局偏移(间隔<1秒)合并为单个会话
 * 2. CLS计算规则:取所有会话中累计值最大的作为最终CLS值
 * 3. 过滤机制:排除用户交互后的500ms内的布局偏移(通过hadRecentInput判断)
 */

示例

function App() {
  const [marginTop, setMarginTop] = useState(0);
  useEffect(() => {
    setTimeout(() => {
      setMarginTop('10vh');
    }, 3000);

    setTimeout(() => {
      setMarginTop('30vh');
    }, 4000);
  }, []);

  return (
    <div className="App">
      <div
        style={{ marginTop, backgroundColor: "red", width: '10vw', height: '10vh' }}
        />
    </div>
  );
}
  1. 首次偏移(3秒时)
    • 初始位置 : marginTop: 0 → 元素占据视口顶部区域(0vh10vh)。
    • 移动后位置 : marginTop: 10vh → 元素占据新区域(10vh20vh)。
    • 影响分数 :
      移动前后的区域并集0vh-20vh × 10vw → 占视口总面积的 2%20vh × 10vw / 100vh × 100vw)。
    • 距离分数 : 移动了 10vh(视口高度的 0.1)。
    • 贡献值 : 0.02 × 0.1 = 0.002
  1. 第二次偏移(5秒时)
    • 初始位置 : marginTop: 10vh → 元素占据 10vh-20vh
    • 移动后位置 : marginTop: 30vh → 元素占据 30vh-40vh
    • 影响分数 :
      移动前后的区域无重叠 ,并集为 10vh-20vh + 30vh-40vh → 总占视口的 2%20vh × 10vw / 100vh × 100vw)。
    • 距离分数 : 移动了 20vh(视口高度的 0.2)。
    • 贡献值 : 0.02 × 0.2 = 0.004
  1. CLS = 0.004,如果第二次偏移倒计时为3900(两次偏移小于1秒) 则会合并为单个会话 CLS = 0.006

相关链接

Google Web Vitals: web.dev/articles/vi…

web-vitals.js:github.com/GoogleChrom…