この Codelab について
1. はじめに
Interaction to Next Paint(INP)について学べるインタラクティブなデモと Codelab です。
前提条件
- HTML と JavaScript の開発に関する知識。
- 推奨: INP のドキュメントを読む。
学習内容
- ユーザー操作と、その操作の処理がページの応答性にどのように影響するか。
- 遅延を減らしてスムーズなユーザー エクスペリエンスを実現する方法。
必要なもの
- GitHub からコードのクローンを作成し、npm コマンドを実行できるパソコン。
- テキスト エディタ。
- すべてのインタラクション測定が機能する Chrome の最新バージョン。
2. セットアップする
コードを取得して実行する
コードは web-vitals-codelabs
リポジトリにあります。
- ターミナルでリポジトリのクローンを作成します。
git clone https://github.com/GoogleChromeLabs/web-vitals-codelabs.git
- クローン作成したディレクトリに移動します。
cd web-vitals-codelabs/understanding-inp
- 依存関係をインストールします。
npm ci
- ウェブサーバーを起動します。
npm run start
- ブラウザで http://localhost:5173/understanding-inp/ にアクセスします。
アプリの概要
ページの上部には、スコア カウンタと [増やす] ボタンがあります。リアクティビティと応答性の古典的なデモです。
ボタンの下には、次の 4 つの測定値が表示されます。
- INP: 現在の INP スコア。通常は最も遅延の大きいインタラクションです。
- Interaction: 直近のインタラクションのスコア。
- FPS: ページのメインスレッドのフレーム / 秒。
- タイマー: ジャンクを視覚化するのに役立つ実行中のタイマー アニメーション。
FPS とタイマーのエントリは、インタラクションの測定にはまったく必要ありません。これらは、レスポンシブ性を視覚化しやすくするために追加されたものです。
試してみる
[Increment] ボタンをクリックして、スコアが増加するのを確認します。INP とインタラクションの値は、増分ごとに変化しますか?
INP は、ユーザーが操作してから、レンダリングされた更新が実際にユーザーに表示されるまでの時間を測定します。
3. Chrome DevTools でインタラクションを測定する
その他のツール > デベロッパー ツール メニューから DevTools を開くか、ページを右クリックして [検証] を選択するか、キーボード ショートカットを使用します。
[パフォーマンス] パネルに切り替えます。このパネルを使用してインタラクションを測定します。
次に、[パフォーマンス] パネルでインタラクションをキャプチャします。
- 録画ボタンを押します。
- ページを操作します([Increment] ボタンを押します)。
- 録画を停止します。
結果のタイムラインに [Interactions] トラックが表示されます。左側の三角形をクリックして展開します。
2 つのインタラクションが表示されます。スクロールするか W キーを押して、2 つ目の画像を拡大します。
インタラクションにカーソルを合わせると、インタラクションが高速で、処理時間がゼロで、入力遅延と表示遅延が最小限であることがわかります。正確な長さはマシンの速度によって異なります。
4. 長時間実行されるイベント リスナー
index.js
ファイルを開き、イベント リスナー内の blockFor
関数のコメントを解除します。
コード全体を見る: click_block.html
button.addEventListener('click', () => {
blockFor(1000);
score.incrementAndUpdateUI();
});
ファイルを保存します。サーバーが変更を検知し、ページを更新します。
ページをもう一度操作してみてください。操作が明らかに遅くなります。
パフォーマンス トレース
パフォーマンス パネルで別の記録を作成して、その様子を確認します。
以前は短いインタラクションだったものが、今では 1 秒かかるようになっています。
インタラクションにカーソルを合わせると、時間のほとんどが「処理時間」に費やされていることがわかります。これは、イベント リスナー コールバックの実行にかかった時間です。ブロッキング blockFor
呼び出しはイベント リスナー内に完全に含まれているため、時間がかかるのはこの部分です。
5. テスト: 処理期間
イベント リスナーの作業を再配置して、INP への影響を確認します。
UI を先に更新する
js 呼び出しの順序を入れ替えて、最初に UI を更新してからブロックするとどうなりますか?
コード全体: ui_first.html
button.addEventListener('click', () => {
score.incrementAndUpdateUI();
blockFor(1000);
});
以前に UI が表示されたことはありますか?順序は INP スコアに影響しますか?
トレースを取得してインタラクションを調べ、違いがないか確認してみてください。
リスナーを分離する
作業を別のイベント リスナーに移動するとどうなるでしょうか?1 つのイベント リスナーで UI を更新し、別のリスナーでページをブロックします。
コード全体: two_click.html
button.addEventListener('click', () => {
score.incrementAndUpdateUI();
});
button.addEventListener('click', () => {
blockFor(1000);
});
[パフォーマンス] パネルではどのように表示されますか?
さまざまなイベントタイプ
ほとんどのインタラクションでは、ポインタ イベントやキーイベントから、ホバー、フォーカス/ブラー、beforechange や beforeinput などの合成イベントまで、さまざまな種類のイベントが発生します。
実際の多くのページには、さまざまなイベントのリスナーがあります。
イベント リスナーのイベントタイプを変更するとどうなりますか?たとえば、click
イベント リスナーの 1 つを pointerup
または mouseup
に置き換えることはできますか?
コード全体を見る: diff_handlers.html
button.addEventListener('click', () => {
score.incrementAndUpdateUI();
});
button.addEventListener('pointerup', () => {
blockFor(1000);
});
UI の更新なし
イベント リスナーから UI を更新する呼び出しを削除するとどうなりますか?
コード全体を見る: no_ui.html
button.addEventListener('click', () => {
blockFor(1000);
// score.incrementAndUpdateUI();
});
6. 処理期間のテスト結果
パフォーマンス トレース: UI を最初に更新する
コード全体: ui_first.html
button.addEventListener('click', () => {
score.incrementAndUpdateUI();
blockFor(1000);
});
ボタンをクリックしたパフォーマンス パネルの記録を見ると、結果が変わっていないことがわかります。UI の更新はブロック コードの前にトリガーされましたが、ブラウザはイベント リスナーが完了するまで画面に描画された内容を更新しませんでした。つまり、インタラクションの完了には 1 秒強かかりました。
パフォーマンス トレース: リスナーを分離
コード全体: two_click.html
button.addEventListener('click', () => {
score.incrementAndUpdateUI();
});
button.addEventListener('click', () => {
blockFor(1000);
});
機能的には違いはありません。インタラクションには 1 秒かかります。
クリック操作を拡大すると、click
イベントの結果として 2 つの異なる関数が呼び出されていることがわかります。
予想どおり、UI の更新は非常に高速で実行されますが、2 番目の処理には 1 秒かかります。ただし、これらの効果を合計すると、エンドユーザーに対するインタラクションの遅延は同じになります。
パフォーマンス トレース: さまざまなイベントタイプ
button.addEventListener('click', () => {
score.incrementAndUpdateUI();
});
button.addEventListener('pointerup', () => {
blockFor(1000);
});
これらの結果は非常に似ています。インタラクションは 1 秒のままです。違いは、短い UI 更新のみの click
リスナーが、ブロックする pointerup
リスナーの後に実行されるようになったことだけです。
パフォーマンス トレース: UI の更新なし
コード全体を見る: no_ui.html
button.addEventListener('click', () => {
blockFor(1000);
// score.incrementAndUpdateUI();
});
- スコアは更新されませんが、ページは更新されます。
- アニメーション、CSS 効果、デフォルトのウェブ コンポーネント アクション(フォーム入力)、テキスト入力、テキストのハイライト表示はすべて引き続き更新されます。
この場合、ボタンはクリックされるとアクティブな状態になり、すぐに戻ります。これにはブラウザによるペイントが必要になるため、INP が発生します。
イベント リスナーがメインスレッドを 1 秒間ブロックしてページが描画されないようにしたため、インタラクションには 1 秒かかります。
パフォーマンス パネルの記録を撮ると、以前の操作とほぼ同じ操作が表示されます。
重要なポイント
任意のイベント リスナーで実行される任意のコードは、インタラクションを遅延させます。
- これには、さまざまなスクリプトから登録されたリスナーや、リスナーで実行されるフレームワークまたはライブラリ コード(コンポーネントのレンダリングをトリガーする状態の更新など)が含まれます。
- 独自のコードだけでなく、すべてのサードパーティ スクリプトも対象となります。
よくある問題です。
最後に、コードがペイントをトリガーしないからといって、ペイントが遅いイベント リスナーの完了を待機しないとは限りません。
7. Experiment: input delay
イベント リスナーの外部で実行される長時間実行コードはどうなりますか?次に例を示します。
- 読み込みが遅い
<script>
があり、読み込み中にページがランダムにブロックされる場合。 - ページを定期的にブロックする
setInterval
などの API 呼び出しですか?
イベント リスナーから 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 つは、長いタスク(または長いアニメーション フレーム)と合計ブロック時間を測定することです。
9. プレゼンテーションが遅い
これまで、入力遅延やイベント リスナーを通じて JavaScript のパフォーマンスを見てきましたが、次のペイントのレンダリングに影響を与えるものは他に何があるでしょうか?
高価なエフェクトでページを更新します。
ページの更新がすぐに反映されたとしても、ブラウザはレンダリングに多くの処理を必要とする可能性があります。
メインスレッドで:
- 状態の変化後に更新をレンダリングする必要がある UI フレームワーク
- DOM の変更や、コストの高い CSS クエリ セレクタの切り替えを多数行うと、スタイル、レイアウト、ペイントが大量にトリガーされる可能性があります。
メインスレッド以外:
- CSS を使用して GPU エフェクトを強化する
- 非常に大きな高解像度画像を追加する
- SVG/Canvas を使用して複雑なシーンを描画する
ウェブ上でよく見られる例を以下に示します。
- リンクをクリックした後、最初の視覚的なフィードバックを提供するために一時停止することなく、DOM 全体を再構築する SPA サイト。
- 動的なユーザー インターフェースで複雑な検索フィルタを提供する検索ページ。ただし、そのために高コストのリスナーを実行している。
- ページ全体のスタイル/レイアウトをトリガーするダークモードの切り替え
10. Experiment: presentation delay
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 秒間ブロックし続けます。
ただし、次の 2 点に注意してください。
- ホバーすると、メインスレッドのブロックがイベント リスナーの戻り後に発生しているため、すべてのインタラクション時間が「プレゼンテーションの遅延」に費やされていることがわかります。
- メインスレッド アクティビティのルートは、クリック イベントではなく「アニメーション フレームが発火」になりました。
12. インタラクションの診断
このテストページでは、スコア、タイマー、カウンター UI など、レスポンシブ性が非常に視覚的に表現されていますが、平均的なページをテストする場合は、より微妙な違いになります。
インタラクションが長時間実行される場合、原因が必ずしも明確ではありません。原因は次のいずれかです。
- 入力遅延?
- イベント処理の期間はどのくらいですか?
- 表示の遅延
任意のページで、DevTools を使用してレスポンシブ性を測定できます。習慣にするには、次のフローを試してください。
- 通常どおりウェブを閲覧します。
- DevTools の [Performance] パネルのライブ指標ビューで、[Interactions] ログを確認します。
- パフォーマンスの低いインタラクションが表示された場合は、そのインタラクションを繰り返してみてください。
- 再現できない場合は、インタラクション ログを使用して分析情報を取得します。
- 再現できる場合は、[パフォーマンス] パネルでトレースを記録します。
すべての遅延
これらの問題の一部をページに追加してみましょう。
コード全体を見る: all_the_things.html
setInterval(() => {
blockFor(1000);
}, 3000);
button.addEventListener('click', () => {
blockFor(1000);
score.incrementAndUpdateUI();
requestAnimationFrame(() => {
blockFor(1000);
});
});
次に、コンソールとパフォーマンス パネルを使用して問題を診断します。
13. 試験運用: 非同期処理
インタラクション内でネットワーク リクエストの作成、タイマーの開始、グローバル状態の更新などの非視覚効果を開始できるため、それらの効果が最終的にページを更新するとどうなるでしょうか?
ブラウザが新しいレンダリング更新を実際には必要ないと判断した場合でも、インタラクション後の次のペイントがレンダリングを許可されている限り、インタラクションの測定は停止します。
これを試すには、クリック リスナーから UI の更新を続行し、タイムアウトからブロッキング作業を実行します。
コード全体を見る: 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);
});
UI の更新後すぐにメインスレッドが利用可能になるため、インタラクションは短くなります。長いブロッキング タスクは引き続き実行されますが、ペイントの後に実行されるため、ユーザーは UI のフィードバックをすぐに受け取ることができます。
教訓: 削除できないなら、せめて移動させよう。
メソッド
固定の 100 ミリ秒の setTimeout
よりも優れた方法はないでしょうか?コードはできるだけ早く実行したいはずです。そうでなければ、削除すればよいのです。
目標:
- インタラクションで
incrementAndUpdateUI()
が実行されます。 blockFor()
はできるだけ早く実行されますが、次のペイントはブロックされません。- これにより、「魔法のタイムアウト」なしで予測可能な動作が実現します。
この目的を達成する方法としては、次のようなものがあります。
setTimeout(0)
Promise.then()
requestAnimationFrame
requestIdleCallback
scheduler.postTask()
"requestPostAnimationFrame"
requestAnimationFrame
単体(次のペイントの前に実行しようとし、通常はインタラクションが遅くなります)とは異なり、requestAnimationFrame
+ setTimeout
は requestPostAnimationFrame
のシンプルなポリフィルとなり、次のペイントの後にコールバックを実行します。
コード全体: 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. 複数の操作(および rage click)
長時間ブロックする作業を回避することはできますが、長時間実行されるタスクはページをブロックし、今後のインタラクションや他の多くのページ アニメーションや更新にも影響します。
ページの非同期ブロッキング版をもう一度試してください(または、最後のステップで独自の遅延処理のバリエーションを考案した場合は、そのバリエーションを試してください)。
コード全体を見る: timeout_100.html
button.addEventListener('click', () => {
score.incrementAndUpdateUI();
setTimeout(() => {
blockFor(1000);
}, 100);
});
複数回クリックするとどうなりますか?
パフォーマンス トレース
クリックごとに 1 秒間のタスクがキューに登録され、メインスレッドがかなりの時間ブロックされます。
これらの長いタスクが新しいクリックと重複すると、イベント リスナー自体はほぼすぐに戻るにもかかわらず、インタラクションが遅くなります。入力遅延に関する以前の実験と同じ状況を作成しました。ただし、今回は入力遅延の原因は 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
タスクは 1 つだけです。クリックが 1 秒間なかったら実行されるまで待機します。テキスト入力や、複数のクリックが短時間で発生することが想定されるアイテム ターゲットなど、バースト的に発生するインタラクションには、この戦略をデフォルトで使用するのが理想的です。
17. 戦略 2: 長時間実行中の作業を中断する
ただし、デバウンス期間が経過した直後にクリックが発生し、その長いタスクの途中で処理されて、入力遅延により非常に遅いインタラクションになる可能性はあります。
理想的には、タスクの途中でインタラクションが発生した場合、忙しい作業を一時停止して、新しいインタラクションをすぐに処理できるようにします。どのようにすればよいですか?
isInputPending
などの API もありますが、一般的には長いタスクをチャンクに分割する方がよいでしょう。
多数の 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 関数に「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);
});
以前と同様に、作業のチャンクの後にメインスレッドが生成され、ブラウザは受信したインタラクションに応答できますが、必要なのは個別の setTimeout
ではなく await schedulerDotYield()
だけになりました。これにより、for
ループの途中でも使用できるほど人間工学的に優れています。
AbortContoller()
を利用できるようになりました
この方法は機能しましたが、新しいやり取りが発生して、実行する必要がある作業が変更された場合でも、各やり取りでより多くの作業がスケジュールされます。
デバウンス戦略では、新しいインタラクションごとに以前のタイムアウトをキャンセルしました。ここで同様のことはできますか?これを行う方法の 1 つは、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
ループが開始され、必要な処理が実行されます。同時に、メインスレッドが定期的に譲渡されるため、ブラウザは新しい操作に応答し続けます。
2 回目のクリックが入力されると、最初のループは AbortController
でキャンセルされたものとしてフラグが立てられ、新しい blockInPiecesYieldyAborty
ループが開始されます。最初のループが次に実行されるようにスケジュールされたとき、signal.aborted
が true
になっていることに気づき、それ以上の処理を行わずにすぐに戻ります。
18. まとめ
すべての長いタスクを分割すると、サイトが新しい操作に反応できるようになります。これにより、初期のフィードバックを迅速に提供できるほか、進行中の作業を中止するなどの判断も行えます。場合によっては、エントリ ポイントを個別のタスクとしてスケジュール設定することもあります。場合によっては、都合のよい場所に「yield」ポイントを追加することもあります。
重要
- INP はすべてのインタラクションを測定します。
- 各インタラクションは、入力から次のペイントまで測定されます。これは、ユーザーが応答性を認識する方法です。
- 入力遅延、イベント処理時間、プレゼンテーション遅延は、すべてインタラクションの応答性に影響します。
- DevTools を使用すると、INP とインタラクションの内訳を簡単に測定できます。
戦略
- ページに長時間実行されるコード(長いタスク)がないようにします。
- 不要なコードをイベント リスナーから次のペイントまで移動します。
- レンダリングの更新自体がブラウザにとって効率的であることを確認します。