Core Web Vitals:INPが本当に測っているもの、そしてscheduler.yield()がメインスレッドを救う仕組み
Core Web Vitals:INPが本当に測っているもの、そしてscheduler.yield()がメインスレッドを救う仕組み
マイクロインタラクションの記事では、transformとopacityが非力なスマートフォンでもなめらかにアニメーションする理由を示しました——ブラウザがこれらをコンポジット段階だけで、レイアウトに触れることのない独立したGPUスレッド上で扱うからです。これは今も真実ですが、その裏には、今こそ暴いておく価値のある前提が隠れています。そのアニメーションがそもそも始まるためには、ブラウザはまずクリックイベントをメインスレッド上で処理しなければなりません——あなたのJavaScriptすべてが実行されているのと同じスレッドです。クリックの瞬間にメインスレッドが長い同期的なタスク——巨大な配列のフィルタリング、APIレスポンスのパース、複雑なコンポーネントツリーのレンダーなど——を実行中であれば、そのタスクが終わるより一瞬たりとも早くクリックイベントが処理されることはありません。アニメーション自体がどれだけ安く済むものであっても、それは関係ありません。誰もそれをまだ起動できていないのですから。
**Interaction to Next Paint(INP)**は、まさにこのギャップを測るCore Web Vitalsの指標です——最初のバイトまでの時間でも、ページの読み込み時間でもなく、ユーザーが何かを押してから、ブラウザがそのインタラクションの効果を実際に画面に描画するまでの時間です。2024年3月、旧来の指標FID(First Input Delay)を置き換えました。理由は一つ、重要なものでした。FIDはページ上の最初のインタラクションしか測っていなかったため、最初のクリックの後に続くすべてのクリックが1秒間固まっていたとしても、ページは良いスコアを取ることができてしまっていたのです。INPはページのライフサイクル全体にわたるすべてのインタラクションを考慮し、実務上は最悪の、再現性のあるケースを報告します——良い第一印象で「取り繕う」ことはできません。
なぜJavaScriptは途中で中断できないのか
INPが意味を持つために欠かせない鍵となる仕組みがあります。ブラウザ内のJavaScriptはrun-to-completionモデルで動作します——一度起動したタスクは、ユーザーがその間に何をしようとしていようと関係なく、中断されることなく最初から最後まで実行されます。そうしたタスクの実行中に発生したクリックは無視されるわけではありません——キューに入り、スレッドが空くまで待たされます。50ミリ秒を超えるタスクは正式にlong task(Long Tasks API)として分類されます——これは恣意的な数字ではなく、それを超えるとユーザーが主観的に何かが「固まっている」と感じ始める境界の近似値であり、精神としてはマイクロインタラクションの記事で紹介したドハティの閾値に近いものですが、こちらはネットワークではなく計算コストの側で測られています。
INPは三つの構成要素に分解され、長いタスクは多くの場合その最初の要素を膨張させます。
- 入力遅延(input delay) — クリックから、メインスレッドがそのイベントの処理を実際に開始するまでの時間。まさにここで長いタスクがすべてをブロックします。
- 処理時間(processing time) — あなたのイベントハンドラの実行そのものにかかる時間。
- 表示遅延(presentation delay) — ハンドラの終了から、効果を反映したフレームが実際に描画されるまでの時間。
Yielding:作業の途中でスレッドをブラウザに返す方法
タスクを外部から中断できない以上、唯一の手段は内部から意識的に中断することです——長い同期的な作業を小さな塊に分割し、その合間にブラウザへ制御を返すことで、次の作業に取り掛かる前に、溜まっている入力を処理し、フレームを描画する余裕を与えます。
javascript
async function processInChunks(items, processItem) {const results = [];for (let i = 0; i < items.length; i++) {results.push(processItem(items[i]));if (i % 50 === 0) {if ("scheduler" in window && "yield" in scheduler) {await scheduler.yield();} else {// フォールバック: setTimeout(fn, 0)。Promise.resolve().then() ではダメawait new Promise((resolve) => setTimeout(resolve, 0));}}}return results;}
見落としがちで、このテクニックの効果を実際に台無しにしてしまう細部があります。await Promise.resolve()やqueueMicrotask()はブラウザに制御を返しません。これらはマイクロタスクです——マイクロタスクキューは、ブラウザが溜まっている入力の処理やフレームの描画を検討する前に、すべて空にされます。非同期ループの中でマイクロタスクだけを使って作業を「分割」しているつもりでも、ブラウザから見ればそれは依然として一つの連続したタスクにすぎません——単に単純なコード行の代わりにthen()に分割されているだけです。実際にスレッドを手放すには、マクロタスクの境界が必要です——setTimeout、MessageChannel(ネストされたタイムアウトにブラウザが課す最小遅延を回避できるためsetTimeout(0)より速い)、あるいはScheduler APIに新しく用意された専用のscheduler.yield()です。後者はさらに、単純に通常のキューの末尾に並ぶsetTimeoutとは違い、他の待機中のタスクとの関係で継続処理を賢く優先度付けしてくれます。
isInputPending():誰も待っていないならスレッドを手放すな
ループの一巡ごとに制御を返すことにはコストが伴います——イベントループを一巡するたびにオーバーヘッドが発生するのです。もしその瞬間誰も何もクリックしていないなら、50回ごとに作業を中断するのは純粋な時間の無駄であり、ユーザーにとって何のメリットもないまま全体の処理時間を延ばすだけです。navigator.scheduling.isInputPending()はこれを、作業を中断すると決める前に、実際にキューの中に未処理の入力イベントが待っているかどうかを直接尋ねることで解決します。
javascript
function processQueue(tasks) {while (tasks.length > 0) {if (navigator.scheduling?.isInputPending()) {break; // 実際に誰かが待っている — 今すぐスレッドを返す}doExpensiveWork(tasks.shift());}if (tasks.length > 0) {setTimeout(() => processQueue(tasks), 0);}}
これは「50要素ごと」という固定のロジックを逆転させます——一回のyieldと次のyieldの間にどれだけの作業が「安全」かをあらかじめ推測する代わりに、その必要が今あるかどうかをその都度ブラウザに尋ねるのです。入力キューが空であれば、ループは50回のイテレーションよりもはるかに長く中断なく続けられます。処理中に実際のクリックが発生すれば、それが本当に意味を持つまさにその場所で、即座に制御を返します。
落とし穴とグッドプラクティス
- マイクロタスクはyieldとしてカウントされない。 これはINPを「修正」しようとする際に最もよくある間違いです。
awaitがあるのでコードは非同期に見えますが、そのawaitの先にあるのがPromise.resolve()だけなら、ブラウザは依然として全体を一つの中断不可能なタスクとして扱います。 scheduler.yield()にもフォールバックが必要。 サポートは主にChromiumベースのブラウザに限られています——CSS Houdiniの記事におけるPaint APIとまったく同じように、setTimeoutを安全なフォールバックとするプログレッシブエンハンスメントとして扱いましょう。- サードパーティのスクリプトによる長いタスクも、あなたのINPスコアに影響する。 アナリティクス、広告、チャットウィジェット——これらも同じメインスレッド上で動いており、あなた自身のコードとまったく同じようにそれをブロックします。
longtaskエントリを監視するPerformanceObserverは、誰が発生させたかに関わらずそれらすべてを表示します——キャッシュされた空のローカル開発環境でのテストだけでなく、実際の本番トラフィック(RUM)を計測する価値があります。 - INPはページのライフサイクル全体における最悪の再現性のあるケースを測るものであり、第一印象ではない。 クライアント側のstateにデータが溜まった、アプリを10分使った後になって初めて固まるインタラクションも、依然としてスコアに算入されます——新しく読み込まれたページで最初の数回のクリックだけをテストすることは、実際の問題を体系的に過小評価します。
まとめ
マイクロインタラクションの記事では、アニメーション自体をメインスレッドにとって無償にする方法を示しました。INPは同じ方程式のもう半分を示しています。ユーザーがクリックした瞬間にメインスレッドが長く中断不可能なJavaScriptタスクで占有されていれば、世界で最も安いアニメーションでさえ役に立ちません。run-to-completionモデルによって、レスポンシブ性を得る唯一の方法は、自分自身の重い作業を意識的に小さな塊に分割し、その合間に——マイクロタスクではなく本物のマクロタスクを通じて、できればisInputPending()が実際に誰かが待っていることを確認したタイミングで——ブラウザに制御を返すことになります。ドハティの閾値からINPに至るまで、体感パフォーマンス全体の連鎖は、その最も弱い環と同じ強さしか持ちません——そしてその環は、ネットワークやサーバーではなく、自分自身の、まだ十分に分割されていないJavaScriptであることがますます増えているのです。