INP (Interaction to Next Paint) 是 Google Core Web Vitals 中的一个实验性指标,旨在衡量页面对用户交互的整体响应能力。它通过记录用户与页面进行交互(例如点击、拖动、按键)到浏览器实际绘制出视觉更新之间的时间,来评估页面的响应速度。
INP 测量原理:
一个交互的生命周期可以分解为几个阶段:
- 输入延迟 (Input Delay): 从用户开始交互(例如
pointerdown
事件)到浏览器主线程开始处理事件回调之间的时间。 - 处理时间 (Processing Time): 事件回调函数执行以及浏览器更新 DOM 所需的时间。这包括 JavaScript 执行、样式计算、布局(Layout)和绘制(Paint)等。
- 呈现延迟 (Presentation Delay): 从事件处理完成到浏览器实际在屏幕上呈现出视觉更新(即下一帧绘制完成)之间的时间。
INP 的值是: 在页面生命周期内,所有符合条件的交互中,最长的那次交互的持续时间(通常是 75th 百分位数,以避免极端值)。
如何测量 INP?
前端主要通过 PerformanceObserver
API 来监听 event
类型的性能条目(Performance Entry)。这些条目包含了交互的关键时间戳。
PerformanceEventTiming
接口的关键属性:
name
: 事件名称,例如 "click", "keydown", "pointerdown"。entryType
: 始终为 "event"。startTime
: 事件开始时间(用户输入发生的时间)。duration
: 事件总持续时间(从startTime
到renderTime
)。processingStart
: 浏览器开始处理事件回调的时间。processingEnd
: 事件回调执行完成且浏览器完成样式计算和布局的时间。renderTime
: 浏览器完成此事件引起的视觉更新并将其绘制到屏幕上的时间。这是 INP 测量中最重要的时间点。interactionId
: (实验性)一个唯一标识符,用于将属于同一用户交互的多个事件(例如pointerdown
和click
)关联起来。
INP 的计算公式(单次交互):
INP = renderTime - startTime
代码实现详解:
我们将创建一个 setupINPMonitor
函数,它会:
- 使用
PerformanceObserver
监听event
类型的性能条目。 - 过滤出与用户交互相关的事件(如
click
,keydown
,mousedown
)。 - 对于每个事件,计算其
renderTime - startTime
作为该事件的交互持续时间。 - 维护一个列表,记录所有有效交互的持续时间。
- 在页面即将卸载时(例如
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分钟后停止
代码讲解:
-
calculateP75(arr)
函数:- 这是一个辅助函数,用于计算给定数字数组的 75th 百分位数。INP 的官方定义就是取所有交互持续时间的 75th 百分位数,而不是简单地取最大值,这能更好地反映大多数用户的体验。
-
setupINPMonitor(onINPChange)
函数:-
能力检测: 首先检查
PerformanceObserver
和event
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()
函数:- 在页面即将隐藏或卸载时调用(通过
pagehide
或visibilitychange
事件)。 - 遍历
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)或用户切换标签页等场景很有用。
-
注意事项和限制:
-
复杂性: 手动实现 INP 监测比看起来要复杂得多。上述代码是一个简化版本,用于理解核心原理。它没有处理所有边缘情况,例如:
- 异步任务: 如果一个交互触发了异步任务(如
fetch
请求),并且这些异步任务在事件回调结束后才导致最终的视觉更新,那么entry.renderTime
可能无法完全捕获到整个交互的持续时间。 - 长任务: 如果事件处理过程中有长任务阻塞主线程,
renderTime
应该反映出这个阻塞。PerformanceEventTiming
旨在包含这些,但实际情况可能复杂。 - 非交互事件: 准确区分哪些
event
条目是真正的用户交互,哪些是浏览器内部事件,需要更精细的过滤。 interactionId
的兼容性:interactionId
属性是实验性的,并非所有浏览器都完全支持。在不支持的浏览器中,我们的回退逻辑(使用startTime
)可能导致一些不准确的交互分组。
- 异步任务: 如果一个交互触发了异步任务(如
-
推荐使用
web-vitals
库:-
对于生产环境,强烈推荐使用 Google 官方提供的
web-vitals
JavaScript 库。 -
这个库由 Google 团队维护,它封装了所有复杂的逻辑,包括对各种边缘情况的处理、浏览器兼容性、以及精确的 INP 计算(包括对
renderTime
的高级处理)。 -
使用
web-vitals
库非常简单,只需几行代码即可:jsimport { onINP } from 'web-vitals'; onINP((metric) => { console.log('INP 报告:', metric); // 将 metric.value 发送到你的分析服务 });
-
它会为你处理所有的
PerformanceObserver
注册、事件过滤、交互分组、以及最终的 75th 百分位数计算和报告时机。
-
手动实现有助于深入理解 INP 的工作原理,但在实际项目中,为了准确性和维护性,请务必使用 web-vitals
库。