了解 Interaction to Next Paint (INP)

1. 简介

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

描绘主线程上互动的图表。用户在阻塞任务运行时进行输入。输入会延迟到这些任务完成,之后 pointerup、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/

应用概览

网页顶部有一个 Score 计分器和一个 Increment 按钮。这是一个经典的反应和响应速度演示示例!

此 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();
});

保存文件。服务器会检测到更改并为您刷新页面。

请尝试再次与页面互动。现在,互动速度会明显变慢。

性能跟踪

在“Performance”面板中再录制一次,看看它在其中的显示效果。

“性能”面板中持续一秒的互动

原本短暂的互动现在需要整整一秒。

将鼠标悬停在互动上时,您会注意到时间几乎全部花在“处理时长”上,即执行事件监听器回调所需的时间。由于阻塞性 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 事件监听器替换为 pointerup 或 mouseup?

查看完整代码: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 事件确实调用了两个不同的函数。

正如预期,第一个任务(更新界面)运行速度极快,而第二个任务则需要整整一秒。不过,这些因素的共同作用会导致最终用户遇到相同的互动缓慢问题。

放大查看此示例中持续一秒的互动,显示第一个函数调用用时不到一毫秒便已完成

性能跟踪记录:不同的事件类型

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

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

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

此示例中一秒钟长的互动放大图,显示点击事件监听器在 pointerup 监听器之后完成所需的时间不到一毫秒

性能跟踪:界面未更新

查看完整代码:no_ui.html

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

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

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

通过“Performance”面板录制,可以看到该互动与之前的互动几乎完全相同。

“性能”面板中仍显示时长为 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 阻塞任务运行期间发生的按钮点击会导致长时间运行的互动,即使互动本身没有执行任何阻塞工作也是如此!

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

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

开发者工具“性能”面板显示了一个耗时 1 秒的阻塞任务、一个在该任务执行过程中发生的互动,以及一个耗时 642 毫秒的互动,其中大部分时间归因于输入延迟

请注意,它并不总是会影响互动!如果您在任务运行期间没有点击,则可能不会遇到问题。如果这些“随机”打喷嚏只是偶尔导致问题,那么调试起来可能会是一场噩梦。

一种方法是衡量长任务(或长动画帧)和总阻塞时间。

9. 演示文稿加载缓慢

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

那就是,使用开销高昂的效果更新页面!

即使页面更新很快,浏览器可能仍需要努力渲染它们!

在主线程中:

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

在主线程外:

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

Web 渲染的不同元素的草图

RenderingNG

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

  • 一个 SPA 网站,在点击链接后会重新构建整个 DOM,而不会暂停以提供初始视觉反馈。
  • 一个搜索页面,通过动态界面提供复杂的搜索过滤条件,但为此运行了开销高昂的监听器。
  • 一个深色模式切换开关,可触发整个页面的样式/布局

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);
  });
});

互动时长仍然是一秒,那么发生了什么?

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

“性能”面板中仍显示时长为 1 秒的互动

不过,请注意以下两点:

  • 将鼠标悬停在相应位置,您会看到所有互动时间现在都花在“呈现延迟”上,因为主线程阻塞发生在事件监听器返回之后。
  • 主线程活动的根不再是点击事件,而是“Animation Frame Fired”。

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);
});

现在,在跟踪记录中,与 1 秒长任务的 27 毫秒互动发生在较晚的时间

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

知识要点:如果无法移除,至少可以移动!

方法

我们能否做得比固定的 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 秒的任务排入队列,从而确保主线程在相当长的时间内处于阻塞状态。

主线程中有多个长任务,导致互动速度慢至 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()

不过,我们可以利用现代 async 和 await,轻松地向任何 JavaScript 函数添加“退让点”。

例如:

查看完整代码: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 和互动细分指标!

策略

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

了解详情