1. 简介
这是一个互动 Codelab,用于学习如何使用 web-vitals 库衡量 Interaction to Next Paint (INP)。
前提条件
- 具备 HTML 和 JavaScript 开发方面的知识。
- 建议:阅读 web.dev INP 指标文档。
学习内容
- 如何将
web-vitals库添加到网页并使用其归因数据。 - 使用归因数据诊断从何处以及如何开始改进 INP。
所需条件
- 一台能够从 GitHub 克隆代码并运行 npm 命令的计算机。
- 文本编辑器。
- 较新版本的 Chrome,以便所有互动测量都能正常运行。
2. 进行设置
获取并运行代码
代码位于 the web-vitals-codelabs 代码库中。
- 在终端中克隆代码库:
git clone https://github.com/GoogleChromeLabs/web-vitals-codelabs.git。 - 进入克隆的目录:
cd web-vitals-codelabs/measuring-inp。 - 安装依赖项:
npm ci。 - 启动 Web 服务器:
npm run start。 - 在浏览器中访问 http://localhost:8080/。
试用网页
此 Codelab 使用 Gastropodicon(一个热门的蜗牛解剖结构参考网站)来探索 INP 的潜在问题。

尝试与网页互动,了解哪些互动速度较慢。
3. 熟悉 Chrome 开发者工具
打开开发者工具,方法是:从更多工具 > 开发者工具菜单中打开;右键点击页面并选择检查;或者使用键盘快捷键。
在此 Codelab 中,我们将同时使用性能 面板和控制台 。您可以随时在开发者工具顶部的标签页中在这两者之间切换。
- INP 问题最常发生在移动设备上,因此请切换到移动设备显示模拟。
- 如果您在桌面设备或笔记本电脑上进行测试,性能可能会比在真实的移动设备上好得多。如需更真实地了解性能,请点击性能 面板右上角的齿轮图标,然后选择 CPU 4 倍减速 。

4. 安装 web-vitals
web-vitals 是一个 JavaScript 库,用于衡量用户体验到的 Web Vitals 指标。您可以使用该库捕获这些值,然后将它们信标到分析端点以供日后分析,以便我们了解何时何地发生缓慢的互动。
您可以通过几种不同的方式将该库添加到网页。在您自己的网站上安装该库的方式取决于您管理依赖项、构建流程和其他因素的方式。请务必查看该库的文档,了解所有选项。
此 Codelab 将从 npm 安装并直接加载脚本,以避免深入了解特定的构建流程。
您可以使用两个版本的 web-vitals:
- 如果您想在网页加载时跟踪 Core Web Vitals 的指标值,应使用“标准”构建。
- “归因”构建会向每个指标添加额外的调试信息,以诊断指标最终获得该值的原因。
为了在此 Codelab 中衡量 INP,我们需要归因构建。
运行 npm install -D web-vitals,将 web-vitals 添加到项目的 devDependencies。
将 web-vitals 添加到网页:
将脚本的归因版本添加到 index.html 的底部,并将结果记录到控制台:
<script type="module">
import {onINP} from './node_modules/web-vitals/dist/web-vitals.attribution.js';
onINP(console.log);
</script>
试试看
尝试在控制台打开的情况下再次与网页互动。当您点击网页时,没有任何内容被记录!
INP 是在网页的整个生命周期内进行衡量的,因此默认情况下,web-vitals 不会报告 INP,直到用户离开或关闭网页。对于信标分析等内容,这是理想的行为,但对于以互动方式进行调试,则不太理想。
web-vitals 提供了一个 the reportAllChanges 选项,用于提供更详细的报告。启用后,系统不会报告每个互动,但每次有互动比之前的任何互动都慢时,系统都会报告。
尝试将该选项添加到脚本并再次与网页互动:
<script type="module">
import {onINP} from './node_modules/web-vitals/dist/web-vitals.attribution.js';
onINP(console.log, {reportAllChanges: true});
</script>
刷新网页,互动现在应会报告给控制台,并在每次出现新的最慢互动时更新。例如,尝试在搜索框中输入内容,然后删除输入的内容。

5. 归因中包含什么?
我们先从大多数用户与网页的首次互动(Cookie 意见征求对话框)开始。
许多网页都有脚本,需要在用户接受 Cookie 时同步触发 Cookie,导致点击成为缓慢的互动。这就是这里发生的情况。
点击是 以接受(演示)Cookie,并查看现在记录到开发者工具控制台的 INP 数据。

标准和归因 Web Vitals 构建中都提供了此顶级信息:
{
name: 'INP',
value: 344,
rating: 'needs-improvement',
entries: [...],
id: 'v4-1715732159298-8028729544485',
navigationType: 'reload',
attribution: {...},
}
从用户点击到下一次绘制的时间长度为 344 毫秒,这是一个 "需要改进"的 INP。entries 数组包含与此互动关联的所有 PerformanceEntry 值,在本例中,只有一个点击事件。
不过,如需了解此期间发生的情况,我们最感兴趣的是 attribution 属性。为了构建归因数据,web-vitals 会查找哪些 长动画帧 (LoAF) 与点击事件重叠。然后,LoAF 可以提供有关该帧期间花费的时间的详细数据,包括运行的脚本、在 requestAnimationFrame 回调中花费的时间、样式和布局。
展开 attribution 属性以查看更多信息。数据更加丰富。
attribution: {
interactionTargetElement: Element,
interactionTarget: '#confirm',
interactionType: 'pointer',
inputDelay: 27,
processingDuration: 295.6,
presentationDelay: 21.4,
processedEventEntries: [...],
longAnimationFrameEntries: [...],
}
首先,是有关互动内容的信息:
interactionTargetElement:对互动元素的实时引用(如果该元素尚未从 DOM 中移除)。interactionTarget:用于在网页中查找元素的选择器。
接下来,时间以概要方式分解:
inputDelay:用户开始互动(例如,点击鼠标)的时间与该互动的事件监听器开始运行的时间之间的时长。在本例中,即使启用了 CPU 限制,输入延迟也只有大约 27 毫秒。processingDuration:事件监听器运行到完成所需的时间。通常,网页会为单个事件(例如,pointerdown、pointerup和click)提供多个监听器。如果它们都在同一动画帧中运行,则会合并到此时间中。在本例中,处理时长为 295.6 毫秒,占 INP 时间的大部分。presentationDelay:从事件监听器完成到浏览器完成绘制下一帧的时间。在本例中,为 21.4 毫秒。
这些 INP 阶段可以作为诊断需要优化的内容的至关重要的信号。优化 INP 指南提供了有关此主题的更多信息。
深入了解一下,processedEventEntries 包含 5 个事件,而不是顶级 INP entries 数组中的单个事件。 两者有何差异?
processedEventEntries: [
{
name: 'mouseover',
entryType: 'event',
startTime: 1801.6,
duration: 344,
processingStart: 1825.3,
processingEnd: 1825.3,
cancelable: true
},
{
name: 'mousedown',
entryType: 'event',
startTime: 1801.6,
duration: 344,
processingStart: 1825.3,
processingEnd: 1825.3,
cancelable: true
},
{name: 'mousedown', ...},
{name: 'mouseup', ...},
{name: 'click', ...},
],
顶级条目是 INP 事件,在本例中为点击事件。归因 processedEventEntries 是在同一帧期间处理的所有事件。请注意,它包含其他事件,例如 mouseover 和 mousedown,而不仅仅是点击事件。了解这些其他事件非常重要,因为如果它们也很慢,则都会导致响应缓慢。
最后是 longAnimationFrameEntries 数组。这可能是一个条目,但在某些情况下,互动可能会跨多个帧。这里我们有一个最简单的示例,其中包含一个长动画帧。
longAnimationFrameEntries
展开 LoAF 条目:
longAnimationFrameEntries: [{
name: 'long-animation-frame',
startTime: 1823,
duration: 319,
renderStart: 2139.5,
styleAndLayoutStart: 2139.7,
firstUIEventTimestamp: 1801.6,
blockingDuration: 268,
scripts: [{...}]
}],
这里有很多有用的值,例如分解用于设置样式的时长。Long Animation Frames API 文章更深入地介绍了这些属性。现在,我们主要对 scripts 属性感兴趣,该属性包含提供有关负责长时间运行帧的脚本的详细信息的条目:
scripts: [{
name: 'script',
invoker: 'BUTTON#confirm.onclick',
invokerType: 'event-listener',
startTime: 1828.6,
executionStart: 1828.6,
duration: 294,
sourceURL: 'http://localhost:8080/third-party/cmp.js',
sourceFunctionName: '',
sourceCharPosition: 1144
}]
在本例中,我们可以看出时间主要花费在单个 event-listener 中,该监听器在 BUTTON#confirm.onclick 上调用。我们甚至可以看到脚本源网址和定义函数的位置的字符位置!
要点总结
从这些归因数据中可以确定此案例的哪些信息?
- 互动是由点击
button#confirm元素触发的(来自attribution.interactionTarget和脚本归因条目的invoker属性)。 - 时间主要用于执行事件监听器(来自
attribution.processingDuration与总指标value的比较)。 - 缓慢的事件监听器代码从
third-party/cmp.js中定义的点击监听器开始(来自scripts.sourceURL)。
这些数据足以让我们知道需要优化哪些内容!
6. 多个事件监听器
刷新网页,以便清除开发者工具控制台,并且 Cookie 意见征求互动不再是最长的互动。
开始在搜索框中输入内容。归因数据显示什么?您认为发生了什么?
归因数据
首先,对测试演示的一个示例进行概要扫描:
{
name: 'INP',
value: 1072,
rating: 'poor',
attribution: {
interactionTargetElement: Element,
interactionTarget: '#search-terms',
interactionType: 'keyboard',
inputDelay: 3.3,
processingDuration: 1060.6,
presentationDelay: 8.1,
processedEventEntries: [...],
longAnimationFrameEntries: [...],
}
}
这是一个较差的 INP 值(启用了 CPU 限制),来自与 input#search-terms 元素的键盘互动。大部分时间(1072 毫秒的总 INP 中有 1061 毫秒)都花费在处理时长上。
不过,scripts 条目更有趣。
布局抖动
scripts 数组的第一个条目为我们提供了一些有价值的上下文:
scripts: [{
name: 'script',
invoker: 'BUTTON#confirm.onclick',
invokerType: 'event-listener',
startTime: 4875.6,
executionStart: 4875.6,
duration: 497,
forcedStyleAndLayoutDuration: 388,
sourceURL: 'http://localhost:8080/js/index.js',
sourceFunctionName: 'handleSearch',
sourceCharPosition: 940
},
...]
大部分处理时长都发生在脚本执行期间,这是一个 input 监听器(调用方为 INPUT#search-terms.oninput)。系统会给出函数名称 (handleSearch),以及 index.js 源文件中的字符位置。
不过,有一个新属性:forcedStyleAndLayoutDuration。这是在此脚本调用中花费的时间,浏览器被迫重新布局网页。换句话说,执行此事件监听器所花费的时间中有 78%(497 毫秒中有 388 毫秒)实际上花费在布局抖动上。
这应是修复的首要任务。
重复监听器
就个人而言,接下来的两个脚本条目没有什么特别值得注意的地方:
scripts: [...,
{
name: 'script',
invoker: '#document.onkeyup',
invokerType: 'event-listener',
startTime: 5375.3,
executionStart: 5375.3,
duration: 124,
sourceURL: 'http://localhost:8080/js/index.js',
sourceFunctionName: '',
sourceCharPosition: 1526,
},
{
name: 'script',
invoker: '#document.onkeyup',
invokerType: 'event-listener',
startTime: 5673.9,
executionStart: 5673.9,
duration: 95,
sourceURL: 'http://localhost:8080/js/index.js',
sourceFunctionName: '',
sourceCharPosition: 1526
}]
这两个条目都是 keyup 监听器,它们会一个接一个地执行。监听器是匿名函数(因此 sourceFunctionName 属性中没有任何报告),但我们仍然有源文件和字符位置,因此可以找到代码的位置。
奇怪的是,它们都来自相同的源文件和字符位置。
浏览器最终在单个动画帧中处理了多个按键,导致此事件监听器在任何内容绘制之前运行了两次!
这种影响也可能会加剧,即事件监听器完成所需的时间越长,可能会传入的额外输入事件就越多,从而使缓慢的互动时间延长。
由于这是一个搜索/自动补全互动,因此对输入进行去抖动将是一个不错的策略,这样每个帧最多处理一个按键。
7. 输入延迟
输入延迟(从用户互动到事件监听器可以开始处理互动的时间)的典型原因是主线程繁忙。这可能有多种原因:
- 网页正在加载,主线程正忙于执行设置 DOM、布局和设置网页样式以及评估和运行脚本的初始工作。
- 网页通常很忙,例如运行计算、基于脚本的动画或广告。
- 之前的互动处理时间过长,导致延迟了未来的互动,这在最后一个示例中已经看到。
演示网页有一个隐藏功能,如果您点击网页顶部的蜗牛徽标,它将开始动画并执行一些繁重的主线程 JavaScript 工作。
- 点击蜗牛徽标以启动动画。
- 当蜗牛位于弹跳底部时,系统会触发 JavaScript 任务。尝试尽可能靠近跳出底部与网页互动,看看可以触发多高的 INP。
例如,即使您不触发任何其他事件监听器(例如,在蜗牛弹跳时点击并聚焦搜索框),主线程工作也会导致网页在明显的一段时间内无响应。
在许多网页上,繁重的主线程工作不会如此规范,但这是一个很好的演示,可以了解如何在 INP 归因数据中识别它。
以下是仅在蜗牛跳出期间聚焦搜索框的归因示例:
{
name: 'INP',
value: 728,
rating: 'poor',
attribution: {
interactionTargetElement: Element,
interactionTarget: '#search-terms',
interactionType: 'pointer',
inputDelay: 702.3,
processingDuration: 4.9,
presentationDelay: 20.8,
longAnimationFrameEntries: [{
name: 'long-animation-frame',
startTime: 2064.8,
duration: 790,
renderStart: 2065,
styleAndLayoutStart: 2854.2,
firstUIEventTimestamp: 0,
blockingDuration: 740,
scripts: [{...}]
}]
}
}
正如预测的那样,事件监听器执行速度很快,处理时长为 4.9 毫秒,而大部分较差的互动都花费在输入延迟上,在总共 728 毫秒中占用了 702.3 毫秒。
这种情况可能难以调试。即使我们知道用户与什么内容互动以及如何互动,我们也知道互动的这一部分很快就完成了,并且没有问题。相反,是网页上的其他内容延迟了互动开始处理的时间,但我们如何知道从哪里开始查找?
LoAF 脚本条目可以解决这个问题:
scripts: [{
name: 'script',
invoker: 'SPAN.onanimationiteration',
invokerType: 'event-listener',
startTime: 2065,
executionStart: 2065,
duration: 788,
sourceURL: 'http://localhost:8080/js/index.js',
sourceFunctionName: 'cryptodaphneCoinHandler',
sourceCharPosition: 1831
}]
即使此函数与互动无关,它也会减慢动画帧的速度,因此会包含在与互动事件联接的 LoAF 数据中。
由此,我们可以看到延迟互动处理的函数是如何触发的(通过 animationiteration 监听器),具体是哪个函数负责,以及它在我们的源文件中的位置。
8. 演示文稿延迟:更新无法绘制
呈现延迟是指从事件监听器完成运行到浏览器能够将新帧绘制到屏幕上并向用户显示可见反馈的时间。
刷新网页以再次重置 INP 值,然后打开汉堡菜单。打开时会明显卡顿。
它是什么样的?
{
name: 'INP',
value: 376,
rating: 'needs-improvement',
delta: 352,
attribution: {
interactionTarget: '#sidenav-button>svg',
interactionType: 'pointer',
inputDelay: 12.8,
processingDuration: 14.7,
presentationDelay: 348.5,
longAnimationFrameEntries: [{
name: 'long-animation-frame',
startTime: 651,
duration: 365,
renderStart: 673.2,
styleAndLayoutStart: 1004.3,
firstUIEventTimestamp: 138.6,
blockingDuration: 315,
scripts: [{...}]
}]
}
}
这次,是呈现延迟占缓慢互动的大部分时间。 这意味着,无论是什么阻止主线程,都会在事件监听器完成后发生。
scripts: [{
entryType: 'script',
invoker: 'FrameRequestCallback',
invokerType: 'user-callback',
startTime: 673.8,
executionStart: 673.8,
duration: 330,
sourceURL: 'http://localhost:8080/js/side-nav.js',
sourceFunctionName: '',
sourceCharPosition: 1193,
}]
查看 scripts 数组中的单个条目,我们看到时间花费在 FrameRequestCallback 中的 user-callback 中。这次,呈现延迟是由 requestAnimationFrame 回调引起的。
9. 总结
聚合字段数据
值得注意的是,当查看单个网页加载中的单个 INP 归因条目时,这一切都更容易。如何聚合这些数据以根据字段数据调试 INP?有用的详细信息量实际上使此操作更加困难。
例如,了解哪个页面元素是缓慢互动的常见来源非常有用。但是,如果您的网页具有从构建到构建都会更改的已编译 CSS 类名称,则来自同一元素的 web-vitals 选择器在不同构建中可能会有所不同。
相反,您必须考虑您的特定应用,以确定哪些内容最有用以及如何聚合数据。例如,在将归因数据信标回传之前,您可以根据目标所在的组件或目标满足的 ARIA 角色,将 web-vitals 选择器替换为您自己的标识符。
同样,scripts 条目的 sourceURL 路径中可能包含基于文件的哈希值,这使得它们难以组合,但您可以在将数据信标回传之前,根据已知的构建过程剥离哈希值。
遗憾的是,对于如此复杂的数据,没有简单的路径,但即使使用其中的一部分,也比在调试过程中完全没有归因数据更有价值。
随处归因!
基于 LoAF 的 INP 归因是一种强大的调试辅助工具。它提供了有关 INP 期间具体发生情况的精细数据。在许多情况下,它可以指向脚本中您应该开始优化工作的精确位置。
现在,您可以在任何网站上使用 INP 归因数据了!
即使您无权修改网页,也可以通过在开发者工具控制台中运行以下代码段来重现此 Codelab 中的过程,以查看可以找到哪些内容:
const script = document.createElement('script');
script.src = 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.iife.js';
script.onload = function () {
webVitals.onINP(console.log, {reportAllChanges: true});
};
document.head.appendChild(script);