前端监测用户卡顿之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 库。

相关推荐
excel2 分钟前
全面解析 JavaScript 类继承:方式、优缺点与应用场景
前端
用户21411832636023 分钟前
dify案例分享-100% 识别率!发票、汇票、信用证全搞定的通用票据识别工作流
前端
拾光拾趣录1 小时前
基础 | HTML语义、CSS3新特性、浏览器存储、this、防抖节流、重绘回流、date排序、calc
前端·面试
小小小小宇2 小时前
监测用户在浏览界面过程中的卡顿
前端
糖墨夕2 小时前
Nest 是隐藏的“设计模式大佬”
前端
逾明3 小时前
Electron自定义菜单栏及Mac最大化无效的问题解决
前端·electron
辰九九3 小时前
Uncaught URIError: URI malformed 报错如何解决?
前端·javascript·浏览器
月亮慢慢圆3 小时前
Echarts的基本使用(待更新)
前端
芜青4 小时前
实现文字在块元素中水平/垂直居中详解
前端·css·css3