瞭解與下一個顯示的內容互動 (INP)

1. 簡介

本程式碼實驗室會以互動示範的方式,介紹 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. 啟動網路伺服器:npm run start
  5. 在瀏覽器中前往 http://localhost:5173/understanding-inp/

應用程式總覽

頁面頂端有「Score」計數器和「Increment」按鈕。這是反應性和回應性的經典示範!

本程式碼實驗室的試用版應用程式螢幕截圖

按鈕下方有四個測量值:

  • INP:目前的 INP 分數,通常是最差的互動。
  • 互動:最近一次互動的分數。
  • FPS:網頁主執行緒的每秒影格數。
  • 計時器:執行中的計時器動畫,可協助以視覺化方式呈現卡頓情形。

測量互動時,完全不需要 FPS 和計時器項目。只是為了方便您查看回應能力。

試試看

嘗試與「Increment」按鈕互動,並觀察分數增加。INP 和互動值是否會隨著每次遞增而變更?

INP 測量的是從使用者互動到網頁實際向使用者顯示算繪更新所需的時間。

3. 使用 Chrome 開發人員工具評估互動情形

開啟開發人員工具:依序點選「更多工具」>「開發人員工具」選單、在頁面上按一下滑鼠右鍵並選取「檢查」,或使用鍵盤快速鍵。

切換至「Performance」面板,用於評估互動。

開發人員工具「Performance」面板和應用程式的螢幕截圖

接著,在「Performance」面板中擷取互動。

  1. 按下 [錄製]。
  2. 與網頁互動 (按下「Increment」按鈕)。
  3. 停止錄製。

在產生的時間軸中,您會看到「互動」軌。按一下左側的三角形即可展開。

動畫示範:使用開發人員工具效能面板記錄互動

系統會顯示兩項互動。捲動或按住 W 鍵,放大第二個。

開發人員工具「Performance」面板的螢幕截圖,游標懸停在面板中的互動上,工具提示列出互動的短時間

將滑鼠游標懸停在互動上,您會發現互動速度很快,處理時間為零,輸入延遲和呈現延遲的時間極短,確切長度取決於機器速度。

4. 長時間執行的事件監聽器

開啟 index.js 檔案,並取消註解事件監聽器中的 blockFor 函式。

查看完整程式碼:click_block.html

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

儲存檔案。伺服器會偵測到變更,並為您重新整理頁面。

請再次嘗試與網頁互動。現在,互動速度會明顯變慢。

效能追蹤記錄

在「Performance」面板中再次錄製,看看結果如何。

「效能」面板中一秒長的互動

原本短暫的互動現在需要一整秒。

將滑鼠游標懸停在互動上時,您會發現時間幾乎都花在「處理時間」上,也就是執行事件監聽器回呼所需的時間。由於封鎖 blockFor 呼叫完全在事件監聽器內,因此時間會花在該處。

5. 實驗:處理時間

嘗試重新安排事件監聽器工作,看看對 INP 的影響。

先更新 UI

如果交換 JS 呼叫的順序 (先更新 UI,再封鎖),會發生什麼事?

查看完整程式碼:ui_first.html

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

您是否注意到 UI 提早出現?順序會影響 INP 分數嗎?

請嘗試追蹤並檢查互動,看看是否有任何差異。

區分聽眾

如果將工作移至個別事件監聽器,會發生什麼情況?在一個事件監聽器中更新 UI,並透過另一個監聽器封鎖頁面。

查看完整程式碼: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);
});

不更新 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 更新是在阻斷程式碼之前觸發,但瀏覽器實際上是在事件監聽器完成後,才更新畫面上的內容,也就是說,互動仍需要一秒多才能完成。

「效能」面板中一秒長的靜態互動

效能追蹤:獨立監聽器

查看完整程式碼:two_click.html

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

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

同樣地,兩者在功能上沒有差異。互動仍需要整整一秒。

如果放大點擊互動,就會發現 click 事件確實呼叫了兩個不同的函式。

如預期,第一個工作 (更新 UI) 執行速度非常快,而第二個工作則需要整整一秒。不過,這些影響加總起來,會導致使用者遇到相同的互動緩慢問題。

放大查看本例中一秒長的互動,顯示第一個函式呼叫在不到一毫秒內完成

效能追蹤:不同事件類型

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

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

這些結果非常相似。互動仍會持續一整秒,唯一的差別在於,只更新 UI 的較短 click 事件監聽器現在會在封鎖 pointerup 事件監聽器「之後」執行。

這個範例放大檢視一秒長的互動,顯示點擊事件監聽器在 pointerup 監聽器之後,花費不到一毫秒的時間完成

效能追蹤:沒有 UI 更新

查看完整程式碼:no_ui.html

button.addEventListener('click', () => {
  blockFor(1000);
  // score.incrementAndUpdateUI();
});
  • 分數不會更新,但頁面仍會更新!
  • 動畫、CSS 效果、預設網頁元件動作 (表單輸入)、文字輸入、文字醒目顯示都會持續更新。

在這種情況下,按鈕會在點擊時進入有效狀態,然後返回,這需要瀏覽器繪製,這表示仍有 INP。

由於事件監聽器封鎖主執行緒一秒,導致網頁無法繪製,因此互動仍需一秒才能完成。

透過「Performance」面板錄製的互動,與先前的互動幾乎完全相同。

「效能」面板中一秒長的靜態互動

重點

在「任何」事件監聽器中執行的「任何」程式碼都會延遲互動。

  • 包括從不同指令碼註冊的事件監聽器,以及在事件監聽器中執行的架構或程式庫程式碼,例如觸發元件轉譯的狀態更新。
  • 除了自己的程式碼,所有第三方指令碼也應一併納入。

這是常見的問題!

最後,即使程式碼不會觸發繪製,也不代表繪製不會等待緩慢的事件監聽器完成。

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 封鎖工作執行時發生的按鈕點擊事件,會導致互動時間過長,即使互動本身並未執行封鎖工作也一樣!

這類長時間執行的工作通常稱為「長時間任務」。

在開發人員工具中,將滑鼠游標懸停在互動上,您會發現互動時間現在主要歸因於輸入延遲,而非處理時間長度。

開發人員工具「效能」面板顯示一項耗時一秒的封鎖工作、在該工作執行期間發生的互動,以及耗時 642 毫秒的互動 (主要歸因於輸入延遲)

請注意,這不一定會影響互動!如果工作執行時沒有點選,或許能順利完成。這類「隨機」噴嚏有時會造成問題,因此偵錯時可能會很棘手。

其中一個方法是測量長時間任務 (或長動畫影格) 和 Total Blocking Time。

9. 簡報速度緩慢

到目前為止,我們已透過輸入延遲或事件監聽器,瞭解 JavaScript 的效能,但還有哪些因素會影響 INP?

那就是使用昂貴的效果更新網頁!

即使網頁更新速度很快,瀏覽器仍可能需要耗費大量資源才能轉譯!

在主執行緒上:

  • 狀態變更後需要算繪更新的 UI 架構
  • DOM 變更或切換多個昂貴的 CSS 查詢選取器,可能會觸發許多樣式、版面配置和繪製。

主執行緒以外:

  • 使用 CSS 強化 GPU 效果
  • 新增超大高解析度圖片
  • 使用 SVG/Canvas 繪製複雜場景

網頁上不同算繪元素的草圖

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) 會繼續封鎖下一個繪製一整秒。

「效能」面板中一秒長的靜態互動

不過,請注意以下兩點:

  • 將滑鼠游標懸停在上方時,您會看到所有互動時間都花在「呈現延遲」上,因為主執行緒封鎖發生在事件監聽器傳回之後。
  • 主要執行緒活動的根層級不再是點擊事件,而是「Animation Frame Fired」。

12. 診斷互動

在這個測試頁面上,回應速度非常直觀,有分數、計時器和計數器 UI...但在測試一般網頁時,回應速度會更細微。

當互動時間過長時,不一定能清楚找出原因。可能是:

  • 輸入延遲?
  • 事件處理時間長度?
  • 簡報顯示延遲?

您可以使用開發人員工具,評估任何網頁的回應速度。如要養成習慣,請嘗試以下流程:

  1. 照常瀏覽網頁。
  2. 請留意開發人員工具「Performance」面板即時指標檢視畫面中的「Interactions」記錄。
  3. 如果發現互動成效不佳,請嘗試重複執行:
  • 如果無法重現,請使用互動記錄取得深入分析。
  • 如果可以重現,請在「效能」面板中記錄追蹤。

所有延遲

請嘗試在網頁中加入以下問題:

查看完整程式碼: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);
});

27 毫秒的互動,以及 1 秒的長時間任務,現在會在追蹤記錄中較晚發生

現在,UI 更新後,主執行緒會立即釋出,因此互動時間很短。長時間封鎖工作仍會執行,只是會在繪製後執行,因此使用者會立即收到 UI 回饋。

結論:如果無法移除,至少要移動!

方法

除了固定的 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);
});

如果快速點選多次,會發生什麼事?

效能追蹤記錄

每次點擊都會排入一項一秒長的任務,確保主執行緒會遭到封鎖一段時間。

主執行緒中有多項長時間任務,導致互動速度慢至 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:中斷長時間執行的工作

不過,如果使用者在防震期過後才點擊,點擊事件仍有可能落在長時間任務的中間,並因輸入延遲而導致互動速度變慢。

理想情況下,如果互動在工作期間發生,我們希望暫停忙碌的工作,以便立即處理任何新的互動。該怎麼做?

雖然有 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);
  });
});

這種做法可讓瀏覽器個別排定每項工作,並優先處理輸入內容!

多次互動,但所有排定的工作都已拆分為多項較小的任務

我們又回到點擊五次、工作五秒的狀態,但每次點擊的一秒工作已拆分為十個 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. 結語

拆解「所有」長時間任務,可讓網站回應新的互動。這樣一來,您就能快速提供初步意見,並做出決定,例如中止進行中的工作。有時這代表要將進入點排定為個別工作。有時這代表在方便的地方加入「yield」點。

記住

  • INP 則會評估所有互動。
  • 系統會測量每次互動從輸入到下一個顯示的內容之間的時間,也就是使用者看到的回應速度。
  • 輸入延遲、事件處理時間和呈現延遲都會影響互動回應速度。
  • 您可以使用開發人員工具輕鬆評估 INP 和互動細目!

策略

  • 網頁上沒有長時間執行的程式碼 (長時間任務)。
  • 將不必要的程式碼移出事件監聽器,直到下次繪製後再執行。
  • 確保轉譯更新本身對瀏覽器有效率。

瞭解詳情