前端监测用户卡顿之INP

INP (Interaction to Next Paint) 是 Google Core Web Vitals 中的一个实验性指标,旨在衡量页面对用户交互的整体响应能力。它通过记录用户与页面进行交互(例如点击、拖动、按键)到浏览器实际绘制出视觉更新之间的时间,来评估页面的响应速度。

INP 测量原理:

一个交互的生命周期可以分解为几个阶段:

  1. 输入延迟 (Input Delay): 从用户开始交互(例如 pointerdown 事件)到浏览器主线程开始处理事件回调之间的时间。
  2. 处理时间 (Processing Time): 事件回调函数执行以及浏览器更新 DOM 所需的时间。这包括 JavaScript 执行、样式计算、布局(Layout)和绘制(Paint)等。
  3. 呈现延迟 (Presentation Delay): 从事件处理完成到浏览器实际在屏幕上呈现出视觉更新(即下一帧绘制完成)之间的时间。

INP 的值是: 在页面生命周期内,所有符合条件的交互中,最长的那次交互的持续时间(通常是 75th 百分位数,以避免极端值)。

如何测量 INP?

前端主要通过 PerformanceObserver API 来监听 event 类型的性能条目(Performance Entry)。这些条目包含了交互的关键时间戳。

PerformanceEventTiming 接口的关键属性:

  • name: 事件名称,例如 "click", "keydown", "pointerdown"。
  • entryType: 始终为 "event"。
  • startTime: 事件开始时间(用户输入发生的时间)。
  • duration: 事件总持续时间(从 startTimerenderTime)。
  • processingStart: 浏览器开始处理事件回调的时间。
  • processingEnd: 事件回调执行完成且浏览器完成样式计算和布局的时间。
  • renderTime: 浏览器完成此事件引起的视觉更新并将其绘制到屏幕上的时间。这是 INP 测量中最重要的时间点。
  • interactionId: (实验性)一个唯一标识符,用于将属于同一用户交互的多个事件(例如 pointerdownclick)关联起来。

INP 的计算公式(单次交互):

INP = renderTime - startTime

代码实现详解:

我们将创建一个 setupINPMonitor 函数,它会:

  1. 使用 PerformanceObserver 监听 event 类型的性能条目。
  2. 过滤出与用户交互相关的事件(如 click, keydown, mousedown)。
  3. 对于每个事件,计算其 renderTime - startTime 作为该事件的交互持续时间。
  4. 维护一个列表,记录所有有效交互的持续时间。
  5. 在页面即将卸载时(例如 pagehide 事件),计算所有记录的交互持续时间的 75th 百分位数,并报告最终的 INP 值。
js 复制代码
/**
 * 计算数组的 75th 百分位数
 * @param {Array<number>} arr - 数字数组
 * @returns {number} 75th 百分位数
 */
function calculateP75(arr) {
  if (arr.length === 0) {
    return 0;
  }
  arr.sort((a, b) => a - b);
  const index = Math.floor(arr.length * 0.75);
  return arr[index];
}

/**
 * 监听 INP (Interaction to Next Paint) 指标
 * 这是一个简化的手动实现,用于理解原理。
 * 生产环境强烈建议使用 Google 官方的 'web-vitals' 库。
 *
 * @param {function(number): void} onINPChange - INP 值变化时的回调函数,参数为当前 INP 值(毫秒)
 */
function setupINPMonitor(onINPChange) {
  // 检查浏览器是否支持 PerformanceObserver 和 event entryType
  if (!('PerformanceObserver' in window) || !('event' in PerformanceObserver.supportedEntryTypes)) {
    console.warn('当前浏览器不支持 Event Timing API 或 INP 相关功能。');
    return () => {}; // 返回空函数
  }

  // 存储所有有效交互的持续时间
  const interactionDurations = [];

  // 存储当前正在进行的交互,以 interactionId 为键
  // 这样可以处理一个交互包含多个事件的情况(例如 pointerdown -> click)
  const activeInteractions = new Map();

  // 监听 PerformanceEntry
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 确保是 PerformanceEventTiming 类型
      if (entry.entryType !== 'event') {
        continue;
      }

      // 过滤掉非用户交互事件(例如,非用户触发的动画事件等)
      // 并且只关注有 renderTime 的事件,因为 INP 依赖于视觉更新
      // 常见的用户交互事件类型:click, keydown, mousedown, pointerdown
      const isUserInteraction = ['click', 'keydown', 'mousedown', 'pointerdown'].includes(entry.name);
      if (!isUserInteraction || entry.renderTime === 0) {
        continue;
      }

      // 获取交互 ID。interactionId 是用于将相关事件分组的关键。
      // 如果浏览器不支持 interactionId,则回退到使用事件的 startTime 作为唯一 ID。
      const interactionId = entry.interactionId || entry.startTime;

      // 如果是新的交互,或者该交互的 renderTime 更晚(表示更完整的视觉更新)
      // 则更新或记录该交互
      if (!activeInteractions.has(interactionId) || entry.renderTime > activeInteractions.get(interactionId).renderTime) {
        activeInteractions.set(interactionId, {
          startTime: entry.startTime,
          renderTime: entry.renderTime,
          processingEnd: entry.processingEnd, // 记录处理结束时间,可用于调试
          name: entry.name // 记录事件名称
        });
      }
    }
  });

  // 开始观察 'event' 类型的性能条目
  // buffered: true 意味着可以获取在 observer 注册之前发生的事件
  observer.observe({ type: 'event', buffered: true });

  // 页面隐藏或卸载时报告最终 INP 值
  const reportINP = () => {
    // 将所有 activeInteractions 中的交互持续时间添加到 interactionDurations 数组
    activeInteractions.forEach(interaction => {
      const duration = interaction.renderTime - interaction.startTime;
      if (duration >= 0) { // 确保持续时间是正值
        interactionDurations.push(duration);
      }
    });

    // 清空 activeInteractions,避免重复计算
    activeInteractions.clear();

    if (interactionDurations.length > 0) {
      // INP 通常取 75th 百分位数
      const finalINP = calculateP75(interactionDurations);
      console.log(`最终 INP (75th percentile): ${finalINP.toFixed(2)}ms`);
      onINPChange(finalINP);

      // 可以在这里上报最终的 INP 值到你的分析服务
      // 例如:sendToAnalytics('INP', finalINP);
    } else {
      console.log('没有检测到有效的用户交互来计算 INP。');
      onINPChange(0); // 或者其他默认值
    }

    // 停止观察
    observer.disconnect();
  };

  // 监听 pagehide 事件,这是报告最终指标的推荐时机
  // 因为它在页面卸载前触发,且比 beforeunload 更可靠
  window.addEventListener('pagehide', reportINP);

  // 也可以监听 visibilitychange 事件,当页面变为 hidden 时报告
  // 这对于单页应用 (SPA) 或长时间运行的页面可能更合适
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      reportINP();
    }
  });

  // 返回一个函数用于停止监测 (如果需要提前停止)
  return () => {
    observer.disconnect();
    window.removeEventListener('pagehide', reportINP);
    document.removeEventListener('visibilitychange', reportINP);
  };
}

// --- 示例用法 ---

// 启动 INP 监测
const stopINPMonitor = setupINPMonitor((inpValue) => {
  console.log(`报告 INP 值: ${inpValue}ms`);
  // 根据 INP 值进行判断和告警
  if (inpValue > 200) { // 超过 200ms 通常被认为是需要改进
    console.warn('INP 值过高,可能存在交互卡顿问题!');
  }
});

// 模拟一个会造成 INP 问题的按钮点击
document.addEventListener('DOMContentLoaded', () => {
  const button = document.createElement('button');
  button.textContent = '点击我(可能卡顿)';
  button.style.padding = '10px 20px';
  button.style.fontSize = '18px';
  button.style.margin = '20px';
  document.body.appendChild(button);

  const resultDiv = document.createElement('div');
  resultDiv.style.margin = '20px';
  document.body.appendChild(resultDiv);

  button.addEventListener('click', () => {
    console.log('按钮被点击了!开始执行耗时操作...');
    resultDiv.textContent = '正在处理...';

    // 模拟一个耗时操作,阻塞主线程
    let sum = 0;
    for (let i = 0; i < 500000000; i++) { // 增加循环次数,模拟长时间阻塞
      sum += i;
    }
    console.log('耗时操作完成,结果:', sum);
    resultDiv.textContent = `处理完成!结果: ${sum}`;

    // 可以在这里模拟一个 DOM 更新,确保有 renderTime 产生
    const newElement = document.createElement('p');
    newElement.textContent = '新的内容已添加!';
    resultDiv.appendChild(newElement);

    // 确保有视觉更新,否则 renderTime 可能为 0
    setTimeout(() => {
        newElement.style.color = 'blue';
    }, 0);
  });

  // 模拟一个会造成 INP 问题的键盘输入
  document.addEventListener('keydown', (event) => {
    if (event.key === 'a') {
      console.log('按下了 "a" 键!开始执行耗时操作...');
      resultDiv.textContent = '正在处理键盘输入...';
      let sum = 0;
      for (let i = 0; i < 300000000; i++) {
        sum += i;
      }
      console.log('键盘输入处理完成,结果:', sum);
      resultDiv.textContent = `键盘输入处理完成!结果: ${sum}`;
    }
  });
});

// 可以在某个时机停止监测,例如在 SPA 路由切换时
// setTimeout(() => {
//   stopINPMonitor();
//   console.log('INP 监测已停止。');
// }, 60000); // 1分钟后停止

代码讲解:

  1. calculateP75(arr) 函数:

    • 这是一个辅助函数,用于计算给定数字数组的 75th 百分位数。INP 的官方定义就是取所有交互持续时间的 75th 百分位数,而不是简单地取最大值,这能更好地反映大多数用户的体验。
  2. setupINPMonitor(onINPChange) 函数:

    • 能力检测: 首先检查 PerformanceObserverevent entryType 是否被当前浏览器支持。如果不支持,则直接返回一个空函数,避免报错。

    • interactionDurations 数组: 用于存储所有被视为有效交互的持续时间(renderTime - startTime)。最终的 INP 将从这个数组中计算得出。

    • activeInteractions Map: 这是一个关键的数据结构。由于一个用户交互(例如,一次完整的鼠标点击)可能由多个性能事件(如 pointerdown, mousedown, click)组成,并且这些事件可能在不同的时间点触发,我们需要一个机制来将它们归类到同一个逻辑交互中。

      • interactionId 属性(如果可用)是浏览器提供的一种将这些相关事件分组的方式。如果不支持,我们回退到使用 startTime 作为临时 ID。
      • activeInteractions Map 会以 interactionId 为键,存储该交互的 startTime 和最新的 renderTime。我们总是保留最晚的 renderTime,因为 INP 关注的是最终的视觉更新。
    • PerformanceObserver 实例:

      • new PerformanceObserver((list) => { ... }):创建一个观察者,当检测到符合条件的性能条目时,会执行回调函数。
      • observer.observe({ type: 'event', buffered: true }):告诉观察者我们对 event 类型的性能条目感兴趣。buffered: true 非常重要,它允许我们获取在 PerformanceObserver 注册之前就已经发生的事件,这对于捕获页面加载初期发生的交互至关重要。
    • 回调函数逻辑:

      • 过滤事件: 只处理 entry.entryType === 'event' 的条目。
      • 用户交互判断: 进一步过滤,只关注与用户直接交互相关的事件,如 click, keydown, mousedown, pointerdown。同时,entry.renderTime === 0 表示该事件没有引起视觉更新,不应计入 INP,因此也过滤掉。
      • interactionId 处理: 尝试使用 entry.interactionId 来唯一标识一个交互。如果浏览器不支持(旧版本),则使用 entry.startTime 作为回退。
      • 更新 activeInteractions 如果是新的交互 ID,或者当前事件的 renderTime 比 Map 中已记录的该交互的 renderTime 更晚,则更新 Map 中的记录。这确保我们总是捕获到该交互所导致的最终视觉更新时间。
    • reportINP() 函数:

      • 在页面即将隐藏或卸载时调用(通过 pagehidevisibilitychange 事件)。
      • 遍历 activeInteractions Map,计算每个交互的持续时间 (interaction.renderTime - interaction.startTime),并将其添加到 interactionDurations 数组中。
      • 清空 activeInteractions Map。
      • 如果 interactionDurations 数组中有数据,则计算 75th 百分位数作为最终的 INP 值,并通过 onINPChange 回调函数报告。
      • observer.disconnect():停止观察者,释放资源。
    • 事件监听:

      • window.addEventListener('pagehide', reportINP):这是报告最终 Web Vitals 指标的推荐时机,因为它在页面卸载前触发,且比 beforeunload 更可靠。
      • document.addEventListener('visibilitychange', ...):当页面可见性状态改变为 hidden 时,也触发报告。这对于单页应用(SPA)或用户切换标签页等场景很有用。

注意事项和限制:

  1. 复杂性: 手动实现 INP 监测比看起来要复杂得多。上述代码是一个简化版本,用于理解核心原理。它没有处理所有边缘情况,例如:

    • 异步任务: 如果一个交互触发了异步任务(如 fetch 请求),并且这些异步任务在事件回调结束后才导致最终的视觉更新,那么 entry.renderTime 可能无法完全捕获到整个交互的持续时间。
    • 长任务: 如果事件处理过程中有长任务阻塞主线程,renderTime 应该反映出这个阻塞。PerformanceEventTiming 旨在包含这些,但实际情况可能复杂。
    • 非交互事件: 准确区分哪些 event 条目是真正的用户交互,哪些是浏览器内部事件,需要更精细的过滤。
    • interactionId 的兼容性: interactionId 属性是实验性的,并非所有浏览器都完全支持。在不支持的浏览器中,我们的回退逻辑(使用 startTime)可能导致一些不准确的交互分组。
  2. 推荐使用 web-vitals 库:

    • 对于生产环境,强烈推荐使用 Google 官方提供的 web-vitals JavaScript 库

    • 这个库由 Google 团队维护,它封装了所有复杂的逻辑,包括对各种边缘情况的处理、浏览器兼容性、以及精确的 INP 计算(包括对 renderTime 的高级处理)。

    • 使用 web-vitals 库非常简单,只需几行代码即可:

      js 复制代码
      import { onINP } from 'web-vitals';
      
      onINP((metric) => {
        console.log('INP 报告:', metric);
        // 将 metric.value 发送到你的分析服务
      });
    • 它会为你处理所有的 PerformanceObserver 注册、事件过滤、交互分组、以及最终的 75th 百分位数计算和报告时机。

手动实现有助于深入理解 INP 的工作原理,但在实际项目中,为了准确性和维护性,请务必使用 web-vitals 库。

相关推荐
崔庆才丨静觅1 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了2 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅3 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊3 小时前
jwt介绍
前端
爱敲代码的小鱼3 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax