了解 Interaction to Next Paint (INP)

1. 简介

用于学习 Interaction to Next Paint (INP) 的互动式演示和 Codelab。

描述主线程上的交互的示意图。用户在阻塞任务运行时进行输入。输入会延迟到这些任务完成,之后指针向上、mouseup 和点击事件监听器运行,然后开始渲染和绘制工作,直到呈现下一帧

前提条件

  • 具备 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 按钮互动,你应该会看到得分增加。INPInteraction 值是否每次递增都发生变化?

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

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

更多工具 > 打开开发者工具开发者工具菜单,右键点击页面,然后选择检查,或使用键盘快捷键

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

旁边是“开发者工具性能”面板的屏幕截图

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

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

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

动画演示:使用开发者工具性能面板记录互动

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

DevTools 的“Performance”面板的屏幕截图、光标悬停在面板中的交互上,以及列出较短交互时间的提示

将鼠标悬停在互动上,您可以看到互动速度很快,没有在处理时长上花费时间,输入延迟和呈现延迟中也有最短时间,其确切时长将取决于机器的速度。

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

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

查看完整代码:click_block.html

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

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

再次尝试与网页互动。现在,互动速度明显变慢了。

性能跟踪记录

在“性能”面板中再录制一次,看看效果是什么样的。

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

曾经短暂的互动,现在只需一秒。

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

5. 实验:处理时长

尝试使用各种方式重新安排事件监听器工作,以查看对 INP 的影响。

请先更新界面

如果交换 js 调用的顺序,先更新界面,然后屏蔽,会出现什么情况?

查看完整代码: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 秒的互动的放大视图,显示了在指针悬停监听器之后完成点击事件监听器所用的时间不到 1 毫秒

性能跟踪记录:无界面更新

查看完整代码:no_ui.html

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

在这种情况下,按钮会变为活动状态,点击后则会恢复,这需要浏览器进行绘制,这意味着仍有 INP。

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

对效果面板进行录制时,系统显示的互动与之前几乎完全相同。

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

外卖

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

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

这是一个常见问题!

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

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 Performance”面板

请注意,它并不总是会影响互动!如果在任务运行时没有点击,您可能很幸运。这种“随机”打喷嚏只是偶尔引发问题时,却是一件令人厌烦的苦恼。

跟踪这些问题的一种方法是测量耗时较长的任务(或长动画帧)和总阻塞时间

9. 展示速度缓慢

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

哇,使用昂贵的效果更新页面!

即使网页更新得很快,浏览器可能仍然需要努力呈现它们!

在主线程上:

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

在主线程外:

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

Web 上呈现的不同元素草图

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. 可选:让开发者工具控制台保持打开状态,同时 Web Vitals 扩展程序会记录互动情况。
  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()

&quot;requestPostAnimationFrame&quot;

requestAnimationFrame 本身(它会在下次绘制之前尝试运行,并且通常仍会造成缓慢互动)本身不同,requestAnimationFrame + setTimeoutrequestPostAnimationFrame 提供了简单的 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);
});

如果快速点击多次,会出现什么情况?

性能跟踪记录

对于每次点击,系统都会排队等待一秒钟的任务,确保主线程在相当长的时间内处于阻塞状态。

主线程中存在多个耗时一秒的任务,导致交互慢至 800 毫秒

当这些耗时较长的任务与传入的新点击重叠时,会导致互动速度变慢,即使事件监听器本身几乎会立即返回结果。我们创造了与先前的输入延迟实验相同的情况。只有这一次,输入延迟不是来自 setInterval,而是来自早期事件监听器触发的工作。

策略

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

  • 完全移除不必要的代码,尤其是脚本。
  • 优化代码以避免运行耗时较长的任务。
  • 发生新的互动时,中止过时的工作。

16. 策略 1:去抖动

经典策略。如果快速连续发生多个互动,并且处理过程或网络影响成本高昂,请故意延迟启动作业,以便取消和重启。此模式对于自动补全字段等界面很有用。

  • 使用 setTimeout 延迟启动开销较大的工作,并设置一个计时器(可能是 500 到 1,000 毫秒)。
  • 执行此操作时,请保存计时器 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 次,但每次点击的每个时长为 1 秒钟的任务分成了 10 项 100 毫秒的任务。因此,即使多次互动与这些任务重叠,没有任何互动的输入延迟超过 100 毫秒!浏览器会优先处理传入事件监听器,而非 setTimeout 工作,因此互动会保持响应迅速。

在调度单独的入口点(例如,您需要在应用加载时调用大量独立功能)时,此策略尤为有效。默认情况下,如果只是加载脚本并在脚本评估时运行所有内容,就可能在一项超长的任务中运行所有内容。

但是,此策略不太适用于分解紧密耦合的代码,例如使用共享状态的 for 循环。

现由yield()提供

不过,我们可以利用新型 asyncawait,以便轻松添加“收益点”任何 JavaScript 函数。

例如:

查看完整代码:returny.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 和交互细分!

策略

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

了解详情