了解 Interaction to Next Paint (INP)

了解 Interaction to Next Paint (INP)

Over dit codelab

subjectLaatst geüpdatet jan 9, 2025
account_circleGeschreven door Michal Mocny, Brendan Kenny

1. 简介

这是一个互动演示 Codelab,讲解了 Interaction to Next Paint (INP)

一张图表,描绘了主线程上的互动。用户在阻塞任务运行时进行输入。输入会延迟到这些任务完成,之后 pointerup、mouseup 和 click 事件监听器会运行,然后渲染和绘制工作会启动,直到呈现下一帧

前提条件

  • 了解 HTML 和 JavaScript 开发。
  • 建议:阅读 INP 文档

学习内容

  • 用户互动与您对这些互动的处理方式如何共同影响网页响应速度。
  • 如何减少和消除延迟,以实现顺畅的用户体验。

所需条件

  • 一台能够从 GitHub 克隆代码并运行 npm 命令的计算机。
  • 文本编辑器。
  • 较新版本的 Chrome,以便所有互动衡量功能都能正常运行。

2. 进行设置

获取并运行代码

该代码位于 web-vitals-codelabs 代码库中。

  1. 在终端中克隆代码库:git clone https://github.com/GoogleChromeLabs/web-vitals-codelabs.git
  2. 进入克隆的目录:cd web-vitals-codelabs/understanding-inp
  3. 安装依赖项:npm ci
  4. 启动 Web 服务器:npm run start
  5. 在浏览器中访问 http://localhost:5173/understanding-inp/

应用概览

页面顶部有一个得分计数器和一个递增按钮。这是一个经典的反应和响应速度演示示例!

此 Codelab 的演示应用的屏幕截图

按钮下方有四项指标:

  • INP:当前 INP 得分,通常是最糟糕的互动。
  • Interaction:最近一次互动的得分。
  • FPS:网页的主线程每秒帧数。
  • Timer:一个正在运行的计时器动画,有助于更直观地反映卡顿。

FPS 和 Timer 条目对衡量互动完全不必要。添加它们只是为了更便于直观呈现响应速度。

试试看

尝试与 Increment 按钮互动,你应该会看到得分增加。INP互动值是否会随着每次增量而变化?

INP 衡量的是从用户发起互动到网页向用户实际显示渲染的更新内容之间的用时。

3. 使用 Chrome 开发者工具衡量互动情况

通过以下方式打开开发者工具更多工具 > 开发者工具菜单右键点击页面并选择检查,或使用键盘快捷键

切换到效果面板,您将使用该面板来衡量互动。

开发者工具“性能”面板与应用并排显示的屏幕截图

接下来,在“性能”面板中捕获互动。

  1. 按住录制按钮开始录制。
  2. 与网页互动(按 Increment 按钮)。
  3. 停止录制。

在生成的时间轴中,您会看到一个互动轨道。点击左侧的三角形即可展开。

演示如何使用开发者工具的“性能”面板记录互动的动画

系统会显示两次互动。滚动或按住 W 键可放大第二个。

开发者工具“性能”面板的屏幕截图,光标悬停在面板中的互动上,并显示列出互动简短时间的提示

将鼠标悬停在互动上,您会看到互动速度很快,在处理时长中没有花费时间,在输入延迟呈现延迟中花费的时间最少,具体时长取决于机器的速度。

4. 长时间运行的事件监听器

打开 index.js 文件,然后取消注释事件监听器内的 blockFor 函数。

查看完整代码:click_block.html

button.addEventListener('click', () => {
  blockFor
(1000);
  score
.incrementAndUpdateUI();
});

保存文件。服务器会看到相应更改,并为您刷新页面。

请尝试再次与网页互动。互动现在会明显变慢。

性能轨迹

在“性能”面板中再次录制,看看效果如何。

“效果”面板中的 1 秒互动

曾经只需短暂互动,现在却需要整整一秒。

当您将鼠标悬停在互动上时,会发现时间几乎全部花费在“处理时长”上,这是执行事件监听器回调所花费的时间。由于阻塞 blockFor 调用完全在事件监听器内,因此时间都花在了这里。

5. 实验:处理时长

尝试重新安排事件监听器工作,看看对 INP 有何影响。

先更新界面

如果您交换 JavaScript 调用的顺序,先更新界面,然后再阻塞,会发生什么情况?

查看完整代码:ui_first.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
  blockFor
(1000);
});

您是否注意到界面更早显示了?顺序会影响 INP 分数吗?

尝试获取轨迹并检查互动,看看是否有任何差异。

分离监听器

如果将工作移至单独的事件监听器会怎样?在一个事件监听器中更新界面,并阻止来自单独监听器的网页。

查看完整代码:two_click.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
});

button
.addEventListener('click', () => {
  blockFor
(1000);
});

现在在“效果”面板中是什么样的?

不同事件类型

大多数互动都会触发多种类型的事件,从指针或按键事件到悬停、聚焦/模糊事件,再到 beforechange 和 beforeinput 等合成事件。

许多实际网页都有针对多种不同事件的监听器。

如果您更改事件监听器的事件类型,会发生什么情况?例如,将某个 click 事件监听器替换为 pointerupmouseup

查看完整代码:diff_handlers.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
});

button
.addEventListener('pointerup', () => {
  blockFor
(1000);
});

无界面更新

如果您从事件监听器中移除更新界面的调用,会发生什么情况?

查看完整代码:no_ui.html

button.addEventListener('click', () => {
  blockFor
(1000);
 
// score.incrementAndUpdateUI();
});

6. 处理时长实验结果

性能轨迹:先更新界面

查看完整代码:ui_first.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
  blockFor
(1000);
});

查看点击按钮的效果面板记录,您会发现结果并未发生变化。虽然在阻塞代码之前触发了界面更新,但浏览器实际上直到事件监听器完成之后才更新屏幕上绘制的内容,这意味着互动仍然需要花费一秒多的时间才能完成。

“性能”面板中持续时间为 1 秒的静态互动

性能轨迹:分离的监听器

查看完整代码:two_click.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
});

button
.addEventListener('click', () => {
  blockFor
(1000);
});

同样,在功能上没有任何区别。互动仍需要整整一秒。

如果您将点击互动放大,就会发现 click 事件确实导致了两个不同的函数被调用。

正如预期的那样,第一个(更新界面)运行速度非常快,而第二个则需要整整一秒。不过,这些因素的综合影响会导致最终用户体验到相同的缓慢互动。

此示例中时长为 1 秒的互动放大视图,显示第一个函数调用在不到 1 毫秒的时间内完成

性能轨迹:不同的事件类型

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
});

button
.addEventListener('pointerup', () => {
  blockFor
(1000);
});

这些结果非常相似。互动时间仍为整整一秒;唯一的区别是,较短的仅限界面更新的 click 监听器现在在阻塞的 pointerup 监听器之后运行。

此示例中时长为 1 秒的互动放大视图,显示了在 pointerup 监听器之后,点击事件监听器在不到 1 毫秒的时间内完成

性能轨迹:界面未更新

查看完整代码:no_ui.html

button.addEventListener('click', () => {
  blockFor
(1000);
 
// score.incrementAndUpdateUI();
});
  • 得分没更新,但网页仍会更新!
  • 动画、CSS 效果、默认网页组件操作(表单输入)、文本输入、文本突出显示等功能仍在不断更新。

在这种情况下,按钮在点击时会进入活动状态,然后返回,这需要浏览器进行绘制,这意味着仍然存在 INP。

由于事件监听器阻塞了主线程一秒钟,导致网页无法绘制,因此互动仍需要整整一秒钟。

录制性能面板时,显示的互动与之前的互动几乎完全相同。

“性能”面板中持续时间为 1 秒的静态互动

要点总结

任何事件监听器中运行的任何代码都会延迟互动。

  • 包括在不同脚本中注册的监听器,以及在监听器中运行的框架或库代码,例如触发组件渲染的状态更新。
  • 不仅限于您自己的代码,还包括所有第三方脚本。

这是一个常见问题!

最后:即使您的代码不会触发绘制,也不意味着绘制不会等待缓慢的事件监听器完成。

7. 实验:输入延迟

如果长时间运行的代码位于事件监听器之外,会怎么样?例如:

  • 如果您有一个在加载期间随机阻止网页的延迟加载 <script>
  • 定期阻止网页的 API 调用(例如 setInterval)?

尝试从事件监听器中移除 blockFor,并将其添加到 setInterval()

查看完整代码:input_delay.html

setInterval(() => {
  blockFor
(1000);
}, 3000);


button
.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
});

说明

8. 输入延迟实验结果

查看完整代码:input_delay.html

setInterval(() => {
  blockFor
(1000);
}, 3000);


button
.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
});

如果在 setInterval 阻塞任务运行时恰好发生按钮点击,即使互动本身没有阻塞工作,也会导致长时间运行的互动!

这些长时间运行的周期通常称为长任务。

将鼠标悬停在 DevTools 中的互动上,您会发现互动时间现在主要归因于输入延迟,而不是处理时长。

开发者工具的“性能”面板,显示了一个阻塞任务(持续时间为 1 秒)、在该任务进行到一半时发生的互动,以及一个持续时间为 642 毫秒的互动(主要归因于输入延迟)

请注意,它并不总是会影响互动!如果您在任务运行时不点击,可能会侥幸成功。如果这种“随机”喷嚏只是偶尔会导致问题,那么调试起来会非常棘手。

一种找出这些问题的方法是衡量长任务(或长动画帧)和总阻塞时间

9. 演示速度缓慢

到目前为止,我们已经通过输入延迟或事件监听器了解了 JavaScript 的性能,但还有哪些因素会影响下一次绘制的渲染?

使用昂贵的效果更新网页!

即使网页更新很快,浏览器可能仍需花费大量时间来渲染这些网页!

在主线程中:

  • 界面框架需要在状态更改后渲染更新
  • 发生 DOM 更改或切换许多复杂的 CSS 查询选择器时,可能会触发大量的样式、布局和绘制操作。

在主线程外:

  • 使用 CSS 增强 GPU 效果
  • 添加超大高分辨率图像
  • 使用 SVG/Canvas 绘制复杂场景

网页上不同渲染元素的草图

RenderingNG

以下是一些常见的网络示例:

  • 在点击链接后重建整个 DOM,但不会暂停以提供初始视觉反馈的 SPA 网站。
  • 一个搜索页面,提供具有动态用户界面的复杂搜索过滤器,但运行昂贵的监听器来实现此目的。
  • 用于触发整个网页的样式/布局的深色模式切换开关

10. 实验:呈现延迟

requestAnimationFrame运行缓慢

我们来使用 requestAnimationFrame() API 模拟较长的展示延迟。

blockFor 调用移至 requestAnimationFrame 回调中,以便在事件监听器返回运行:

查看完整代码:presentation_delay.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
  requestAnimationFrame
(() => {
    blockFor
(1000);
 
});
});

说明

11. 展示延迟实验结果

查看完整代码:presentation_delay.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();
  requestAnimationFrame
(() => {
    blockFor
(1000);
 
});
});

互动时长仍为 1 秒,那么发生了什么?

requestAnimationFrame 请求在下一次绘制之前执行回调。由于 INP 衡量的是从互动到下一次绘制的时间,因此 requestAnimationFrame 中的 blockFor(1000) 会继续阻止下一次绘制整整一秒。

“性能”面板中持续时间为 1 秒的静态互动

不过,请注意以下两点:

  • 悬停时,您会看到所有互动时间现在都花费在“呈现延迟”上,因为主线程阻塞发生在事件监听器返回之后。
  • 主线程 activity 的根不再是点击事件,而是“动画帧触发”。

12. 诊断互动

在此测试网页上,响应能力非常直观,有分数、计时器和计数器界面...但在测试普通网页时,响应能力就比较细微。

当互动持续时间较长时,我们并不总是清楚问题出在哪里。原因可能是:

  • 输入延迟?
  • 活动处理时长?
  • 演示延迟?

在任何所需的网页上,您都可以使用开发者工具来帮助衡量响应速度。如需养成此习惯,请尝试以下流程:

  1. 像平常一样浏览网页。
  2. 密切关注开发者工具“性能”面板的实时指标视图中的“互动”日志。
  3. 如果发现某项互动表现不佳,请尝试重复该互动:
  • 如果无法重现,请使用互动日志获取数据洞见。
  • 如果可以重复,请在“性能”面板中录制跟踪记录。

所有延误

尝试在网页中添加一些上述所有问题:

查看完整代码:all_the_things.html

setInterval(() => {
  blockFor
(1000);
}, 3000);

button
.addEventListener('click', () => {
  blockFor
(1000);
  score
.incrementAndUpdateUI();

  requestAnimationFrame
(() => {
    blockFor
(1000);
 
});
});

然后使用控制台和性能面板诊断问题!

13. 实验:异步工作

由于您可以在互动中启动非视觉效果(例如发出网络请求、启动计时器或仅更新全局状态),那么当这些效果最终更新网页时会发生什么情况?

只要互动后的下一次绘制可以进行渲染,即使浏览器确定实际上不需要对更新内容进行渲染,衡量互动的操作即会终止。

如需尝试此方法,请继续从点击监听器更新界面,但从超时中运行阻塞工作。

查看完整代码:timeout_100.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();

  setTimeout
(() => {
    blockFor
(1000);
 
}, 100);
});

现在怎么办?

14. 异步工作实验结果

查看完整代码:timeout_100.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();

  setTimeout
(() => {
    blockFor
(1000);
 
}, 100);
});

一次 27 毫秒的互动,其中包含一个 1 秒长的任务,现在发生在轨迹的后期

由于主线程在界面更新后立即可用,因此交互时间现在很短。长时间阻塞的任务仍在运行,只是在绘制之后运行,因此用户会立即获得界面反馈。

知识要点:如果无法移除,那就移动!

方法

我们能否做得比固定的 100 毫秒 setTimeout 更好?我们可能仍然希望代码尽可能快地运行,否则我们应该直接将其移除!

目标:

  • 互动将运行 incrementAndUpdateUI()
  • blockFor() 将尽快运行,但不会阻塞下一次绘制。
  • 这样可实现可预测的行为,而不会出现“神奇的超时”。

您可以通过以下几种方式实现此目标:

  • setTimeout(0)
  • Promise.then()
  • requestAnimationFrame
  • requestIdleCallback
  • scheduler.postTask()

“requestPostAnimationFrame”

与单独使用 requestAnimationFrame 不同(它会尝试在下一次绘制之前运行,但通常仍会导致互动缓慢),requestAnimationFrame + setTimeout 可为 requestPostAnimationFrame 提供简单的 Polyfill,在下一次绘制之后运行回调。

查看完整代码:raf+task.html

function afterNextPaint(callback) {
  requestAnimationFrame
(() => {
    setTimeout
(callback, 0);
 
});
}

button
.addEventListener('click', () => {
  score
.incrementAndUpdateUI();

  afterNextPaint
(() => {
    blockFor
(1000);
 
});
});

为了提高人体工程学效果,您甚至可以将其封装在 promise 中:

查看完整代码:raf+task2.html

async function nextPaint() {
 
return new Promise(resolve => afterNextPaint(resolve));
}

button
.addEventListener('click', async () => {
  score
.incrementAndUpdateUI();

  await nextPaint
();
  blockFor
(1000);
});

15. 多次互动(和愤怒点击)

将长时间阻塞网页的作业移动位置会有所帮助,但这无法解决网页阻塞问题,而且会影响后续互动和很多其他网页动画和更新。

再次尝试异步阻塞工作版本的网页(或者您在最后一步中想出的延迟工作变体):

查看完整代码:timeout_100.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();

  setTimeout
(() => {
    blockFor
(1000);
 
}, 100);
});

如果快速点击多次会发生什么情况?

性能轨迹

每次点击都会将一个时长为 1 秒的任务排入队列,确保主线程被阻塞相当长的时间。

主线程中有多个持续时间长达 1 秒的任务,导致互动延迟时间长达 800 毫秒

当这些长时间运行的任务与传入的新点击重叠时,即使事件监听器本身几乎立即返回,也会导致互动缓慢。我们创建了与之前存在输入延迟的实验相同的情况。不过,这次的输入延迟并非来自 setInterval,而是来自由之前的事件监听器触发的工作。

策略

理想情况下,我们希望彻底移除长时间运行的任务!

  • 彻底移除不必要的代码(尤其是脚本)。
  • 优化代码,避免运行长时间的任务。
  • 发生新的互动时,中止过时的作业。

16. 策略 1:防抖

一种经典策略。如果快速连续发生多个互动,而且处理过程开销很大,或者会对网络造成较大影响,这时就可以故意延迟开始工作,以便取消并重新开始。此模式适用于自动补全字段等界面。

  • 使用 setTimeout 延迟执行开销较大的作业,并可将计时器设置为 500 到 1000 毫秒。
  • 执行上述操作时,请保存计时器 ID。
  • 如果发生了新的互动,请使用 clearTimeout 取消之前的计时器。

查看完整代码:debounce.html

let timer;
button
.addEventListener('click', () => {
  score
.incrementAndUpdateUI();

 
if (timer) {
    clearTimeout
(timer);
 
}
  timer
= setTimeout(() => {
    blockFor
(1000);
 
}, 1000);
});

性能轨迹

多次互动,但最终只产生一个长时间运行的任务

尽管点击了多次,但最终只运行了一个 blockFor 任务,该任务会等待一整秒没有任何点击操作后才运行。对于突发性互动(例如在文本输入框中输入内容或预计会获得多次快速点击的商品目标),这是默认情况下使用的理想策略。

17. 策略 2:中断长时间运行的工作

不过,在去抖动时间段结束后,仍有可能出现另一次点击,这次点击会落在该长时间任务的中间,并因输入延迟而导致互动非常缓慢。

理想情况下,如果互动发生在任务执行过程中,我们希望暂停繁忙的工作,以便立即处理任何新的互动。我们该怎么做?

有一些 API(例如 isInputPending)可以实现此目的,但通常最好将长时间运行的任务拆分为多个部分

大量 setTimeout

首次尝试:做一些简单的事情。

查看完整代码:small_tasks.html

button.addEventListener('click', () => {
  score
.incrementAndUpdateUI();

  requestAnimationFrame
(() => {
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
    setTimeout
(() => blockFor(100), 0);
 
});
});

这种方式允许浏览器单独调度每项任务,并且输入可以具有更高的优先级!

多次互动,但所有预定工作都已分解为许多较小的任务

我们又回到了点击 5 次,总共工作 5 秒的情况,但每次点击的 1 秒任务已分解为 10 个 100 毫秒的任务。因此,即使有多个互动与这些任务重叠,也没有任何互动的输入延迟超过 100 毫秒!浏览器会优先处理传入的事件监听器,而不是 setTimeout 工作,因此互动仍能保持响应。

当您需要调度单独的入口点时,此策略的效果尤其好,例如,您需要在应用加载时调用一系列独立的功能。仅在脚本评估时加载脚本并运行所有内容可能会默认在巨大的长时间任务中运行所有内容。

不过,对于分离紧密耦合的代码(例如使用共享状态的 for 循环),此策略的效果并不理想。

现在,您可以使用 yield()

不过,我们可以利用现代 asyncawait 轻松地向任何 JavaScript 函数添加“yield 点”。

例如:

查看完整代码:yieldy.html

// Polyfill for scheduler.yield()
async function schedulerDotYield() {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

async function blockInPiecesYieldy(ms) {
  const ms_per_part = 10;
  const parts = ms / ms_per_part;
  for (let i = 0; i < parts; i++) {
    await schedulerDotYield();

    blockFor(ms_per_part);
  }
}

button.addEventListener('click', async () => {
  score.incrementAndUpdateUI();
  await blockInPiecesYieldy(1000);
});

与之前一样,在完成一部分工作后,主线程会暂停,浏览器能够响应任何传入的互动,但现在只需要一个 await schedulerDotYield(),而无需单独的 setTimeout,因此即使在 for 循环中间使用,也足够符合人体工程学。

现在,您可以使用 AbortContoller()

这种方法可行,但每次互动都会安排更多工作,即使有新的互动进来,可能会改变需要完成的工作也是如此。

借助去抖动策略,我们会在每次新互动时取消之前的超时。我们可以在这里做类似的事情吗?一种方法是使用 AbortController()

查看完整代码:aborty.html

// Polyfill for scheduler.yield()
async function schedulerDotYield() {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

async function blockInPiecesYieldyAborty(ms, signal) {
  const parts = ms / 10;
  for (let i = 0; i < parts; i++) {
    // If AbortController has been asked to stop, abandon the current loop.
    if (signal.aborted) return;

    await schedulerDotYield();

    blockFor(10);
  }
}

let abortController = new AbortController();

button.addEventListener('click', async () => {
  score.incrementAndUpdateUI();

  abortController.abort();
  abortController = new AbortController();

  await blockInPiecesYieldyAborty(1000, abortController.signal);
});

当点击事件发生时,它会启动 blockInPiecesYieldyAborty for 循环,执行需要完成的任何工作,同时定期让出主线程,以便浏览器对新的互动保持响应。

当第二次点击到来时,第一个循环会被标记为已取消 (AbortController),并启动新的 blockInPiecesYieldyAborty 循环。当第一个循环下次预定再次运行时,它会注意到 signal.aborted 现在为 true,并立即返回,不再执行进一步的工作。

主线程工作现在分为许多小块,互动时间很短,工作时间仅持续所需的时间

18. 总结

拆分所有长时间运行的任务,以便网站对新的互动做出响应。这样就可以快速提供初始反馈,还能做出中止正在进行的作业等决定。有时,这意味着将入口点作为单独的任务进行调度。有时,这意味着在适当的位置添加“退让”点。

注意事项

  • INP 会衡量所有互动。
  • INP 针对每一次互动衡量从输入到下一次绘制之间的用时(即用户感受到的响应速度)。
  • 输入延迟、事件处理时长和呈现延迟会影响互动的响应速度。
  • 您可以轻松使用开发者工具来衡量 INP 和互动细分指标!

策略

  • 不要在网页上嵌入需要长时间运行的代码(长任务)。
  • 将不必要的代码移出事件监听器,等到下一次绘制之后再运行。
  • 确保浏览器可以高效地渲染更新内容。

了解详情