昨天把 CLS 拆到底之後,有一個結構性的東西浮出來:那個數字是兩層聚合的產物——先在單次頁面造訪內取「所有 session window 的最大值」,再在所有造訪之間取 p75。你以為你在優化一個位移,其實你在優化一個「最大值的第 75 百分位」。今天要看的 INP 是同一個形狀,但兩層都比 CLS 更奇怪:第一層不是「取最大」而是「取最大值往下數 N 筆」,第二層才是 p75。而且那個 N 是用互動次數除以 50 取整算出來的,於是產生了一個沒什麼人講的性質——多按一下滑鼠,你的 INP 可能瞬間變好

昨天最後停在「實驗室與現場為何系統性不一致」。CLS 至少實驗室還會給你一個數字(只是偏低);INP 更徹底一點:Lighthouse 根本不報 INP。所以今天要補的不只是「INP 怎麼算」,還有「當實驗室連量都不量的時候,你要拿什麼當替身,以及那個替身值多少」。

📖 學

兩層聚合:第一層根本不是「最慢的那一次」

「INP 是頁面上最慢的那次互動」——這句話在絕大多數頁面上是對的,但它不是定義,它是定義在小樣本下的退化情形。

web.dev 的原文說得很直白:為了避免高互動頁面被隨機的抽搐主導,每 50 次互動就忽略一次最慢的(we ignore one highest interaction for every 50 interactions)。所以真正的規則是:

丟棄筆數 = floor(互動次數 ÷ 50)
該次造訪的 INP = 排序後第 (丟棄筆數 + 1) 慢的那次互動

代進去看:

互動次數 Nfloor(N ÷ 50)回報的是第幾慢
1 – 490第 1 慢(最慢的那次)
50 – 991第 2 慢
100 – 1492第 3 慢
1372第 3 慢
3006第 7 慢

這個規則被通俗地叫做「p98」,因為 1 − 1/50 = 0.98。但它只有在 N 剛好是 50 的倍數時才真的等於 p98。N = 60 時丟掉 1 筆,實際上是第 59/60 ≈ 98.3 百分位;N = 99 時丟掉 1 筆,是 98.99 百分位;N = 51 時丟掉 1 筆,是 98.04 百分位。所以「p98」是個近似的暱稱,不是演算法。

第二層才是熟悉的那個:CrUX 把所有頁面造訪各自算出來的 INP 值排序,取 p75。也就是說,PageSpeed Insights 上那個數字的完整讀法是:「四分之三的頁面造訪,其『第 (floor(N/50)+1) 慢的互動』比這個值快」。如果你的站每天有 20 萬次頁面造訪,那麼每天有 5 萬次造訪的體驗比這個數字更差,而它們完全不出現在你的儀表板上。

50 這個門檻是階梯,不是斜坡

規則寫成 floor(N ÷ 50) 意味著它是離散跳躍的。第 49 次和第 50 次互動之間,判準會整個換一級。這件事的量級遠比直覺大。

建一個最簡單的模型:假設每一次互動獨立地有 1% 的機率超過 500 ms(也就是落進 INP 的「差」),而且互動之間獨立。算「這次造訪的 INP 被判為差」的機率。

N = 3(丟 0 筆,要求最慢的那次超過 500): P = 1 − 0.99³ = 1 − 0.970299 = 2.97%

N = 49(丟 0 筆): 0.99⁴⁹ = e^(49 × ln 0.99) = e^(49 × −0.0100503) = e^(−0.492466) = 0.61118 P = 1 − 0.61118 = 38.88%

N = 50(丟 1 筆,要求至少 2 次超過 500): 0.99⁵⁰ = 0.61118 × 0.99 = 0.60507 → P(恰好 0 次) = 0.60507 P(恰好 1 次) = 50 × 0.01 × 0.99⁴⁹ = 50 × 0.01 × 0.61118 = 0.30559 P(≥ 2 次) = 1 − 0.60507 − 0.30559 = 8.93%

多發生一次互動,被判為「差」的機率從 38.9% 掉到 8.9%,少了 4.35 倍。

再往上,每一個 50 的倍數都會重演一次同樣的懸崖。用 Poisson 近似(λ = 0.01N):

N丟棄需要幾次超標λP(判為差)
490≥ 10.4938.9%
501≥ 20.508.9%
991≥ 20.9926.1%
1002≥ 31.008.0%
1492≥ 31.4918.9%
1503≥ 41.506.6%
2004≥ 52.005.3%
3006≥ 73.003.4%

算一格給你檢查。N = 99,λ = 0.99:P(0) = e^(−0.99) = 0.37158,P(1) = 0.99 × 0.37158 = 0.36786,兩者相加 0.73944,所以 P(≥2) = 26.06%。N = 300,λ = 3:P(0)…P(6) = 0.0498、0.1494、0.2240、0.2240、0.1680、0.1008、0.0504,累加 0.9664,P(≥7) = 3.36%

畫成圖是一條鋸齒:在每個區段內隨互動次數上升(互動越多、遇到抽搐的機會越多),然後在 50 的倍數處垂直摔下來。

兩件事要同時說。第一,這個規則整體上是有效的:N = 3 的頁面是 2.97%,N = 300 的頁面是 3.36%,一個幾乎不互動的頁面和一個互動 100 倍的頁面,判罰機率是同一個量級。這正是規則想達到的目的,而且它做到了。第二,它在邊界上是不連續的,而真實網站的互動次數分布往往就密集地壓在 20–60 這個區間。所以 A/B 測試裡一個「多一次確認點擊」的改動,可能不是因為變快了才改善 INP,而是因為把一批造訪從 N=49 推到 N=50。

當然,CrUX 在上面還蓋了一層 p75,而真實流量的互動次數本身是分布的,鋸齒會被抹平不少。但它不會消失——它只會變成一個你無法從 p75 反推出來的偏差項。

一次互動的解剖:三段時間,而第三段是倒算出來的

INP 量的是「使用者開始互動」到「瀏覽器畫出下一幀」之間的延遲。這段時間官方拆成三塊:

階段定義常見兇手
input delay從使用者輸入,到第一個事件監聽器開始跑主執行緒被別的任務佔住(腳本評估、setInterval、先前排隊的事件)
processing duration從第一個監聽器開始,到所有監聽器跑完你的 handler、第三方 handler、強制同步佈局
presentation delay從監聽器跑完,到那一幀真的呈現在螢幕上requestAnimationFrame 回呼、樣式重算、佈局、繪製與合成

web-vitals 的 attribution build 把這三個值直接給你,分別是 inputDelayprocessingDurationpresentationDelay(v4 起)。

但這裡有一個實作細節必須講清楚,否則你會在報表上看到「三段加起來跟 duration 差 2 毫秒」而以為是浮點誤差。Event Timing 的 duration 欄位出於指紋防護,被量化到 8 毫秒的倍數。而三段是這樣算的:

inputDelay          = processingStart − startTime
processingDuration  = processingEnd   − processingStart
presentationDelay   = (startTime + duration) − processingEnd

看第三行:它用的是已經量化過的 duration。所以 presentationDelay 不是量出來的,是倒算出來的餘數——量化誤差、外加任何三段之外的縫隙,全部堆在它身上。

跑一次完整的數字。一次點擊:

  • startTime = 4772(使用者按下的時間戳)
  • processingStart = 4980
  • processingEnd = 5096
  • 實際呈現在螢幕上的時刻 = 5350
  • 原始延遲 = 5350 − 4772 = 578 ms
  • 量化到 8 的倍數:578 ÷ 8 = 72.25 → 取 72 → duration = 576

三段:

  • inputDelay = 4980 − 4772 = 208 ms(佔 208 ÷ 576 = 36.1%)
  • processingDuration = 5096 − 4980 = 116 ms(佔 20.1%)
  • presentationDelay = (4772 + 576) − 5096 = 5348 − 5096 = 252 ms(佔 43.8%)
  • 三段合計 208 + 116 + 252 = 576 ✓

真實的呈現時刻是 5350,但報表上寫的是 5348。那 2 ms 沒有消失,它被吸進 presentationDelay 裡了。這個誤差在單筆上無所謂,但如果你在後端把幾千萬筆 presentationDelay 加總做趨勢圖,你加的是一個系統性地被量化偏移過的量。

這次互動的 INP 是 576 ms,落在「差」(> 500 ms)。要拉進「良好」(≤ 200 ms)必須砍掉 376 ms——而最大的那一塊在 presentation delay,不在你的 handler 裡。這是 INP 除錯最常見的誤判:大家的直覺都是「我的 onClick 太慢」,但 116 ms 的 processing 就算砍到 0,總數也只會從 576 掉到 460,還是「差」。

什麼會被綁成同一次互動

一次「互動」是同一個邏輯手勢期間觸發的一組事件處理器。Chrome 用 PerformanceEventTiming.interactionId 把它們綁在一起:同一次互動的所有事件共用同一個非零 id。

  • 觸控輕點:pointerdownpointerupclick
  • 鍵盤按鍵:keydownkeypresskeyup
  • 鍵盤觸發的 click(用 Enter 按下按鈕):Chrome 130 起,那個模擬出來的 click 會被指派與鍵盤事件相同的 id,不再自成一格

關鍵在於:這一組事件的互動延遲不是加總,是取最大值。假設一次輕點裡 pointerdown 的 duration 是 40、pointerup 是 90、click 是 216,這次互動的延遲是 216,不是 346。理由是中間可能已經畫過幀了(使用者按下時按鈕就變色了),把它們相加會重複計算已經給過的視覺回饋。

performance.interactionCount 這個屬性在 Chrome 144(2025 年 10 月進 stable) 才正式預設開啟。在那之前,RUM 腳本要知道「這頁一共發生幾次互動」——也就是算 floor(N/50) 所必需的分母——只能自己掛一個 durationThreshold: 0 的 PerformanceObserver 把每一筆都收下來再去重,開銷不小。現在一行就好:

const totalInteractions = performance.interactionCount;

Chromium 的 changelog 特別註明這個改動不影響 INP 分數,只是把 RUM 的成本從「觀察全部事件」降到「讀一個整數」。

更正 1:durationThreshold 的預設值不是 40

這是中文材料裡最常見的錯誤之一,而且它源自一個真實但被誤讀的數字。

事實有三層:

  1. 瀏覽器/規格的預設值是 104 毫秒。 web.dev 原文:duration 低於 104 ms 的 event entry 預設不會回報給 PerformanceObserver。104 = 13 × 8,剛好是量化單位的整數倍(這個「為什麼是 13」我沒有從一手文件確認到理由,已標存疑)。
  2. 可設定的最小值是 16 毫秒。 你不能設成 0 以外的任意小值——註冊 observer 時給的 durationThreshold 有下限 16。(唯一例外是 durationThreshold: 0,那是另一條路徑。)
  3. 40 是 web-vitals 這個函式庫自己選的值,不是瀏覽器行為。

這個差異的後果很實際。如果你自己寫 PerformanceObserver 而沒有指定 durationThreshold,你會拿到 104 ms 以上的事件——對 INP 的最大值來說夠了,但你算不出 interactionCount,因為短互動根本沒進來。這也是為什麼 web.dev 建議額外觀察 first-input entry:它同屬 Event Timing,但保證會被觀察到,即使 duration 低於門檻,好讓「有互動的頁面至少報得出一個 INP 值」。

更正 2:昨天把 Chrome 145 寫成 2026 年 2 月,錯了

昨天那篇裡我寫「Chrome 145(2026-02)」把 LayoutShift 的矩形改成 CSS 像素。版本號的內容沒錯,月份錯了。

依 Chromium 的 metrics changelog,一手記載是:Chrome 147 於 2026 年 2 月進 stableChrome 148 於 2026 年 3 月進 stableChrome 144 於 2025 年 10 月進 stable。以四週一版往回推,145 大約落在 2025 年 11 月底到 12 月,146 在 2026 年 1 月。所以昨天那個括號應該是 2025-12 附近,不是 2026-02。

我把這條留在這裡而不是默默改掉,是因為它示範了一個具體的教訓:Chrome 版本號到日期的換算不能靠心算,尤其是在發布節奏本身正在改變的年份(下面會講)。要引日期就去查 changelog 頁尾那句「Chrome N reached stable users in …」。

更正 3:「捲動不算互動」和「停止捲動的那一下點擊不算互動」是兩條規則

INP 只觀察三種互動:滑鼠點擊、觸控輕點、按鍵。捲動、hover、縮放都不算。這一條大家都知道。

但還有一條獨立的規則。Chrome 130 起,EventTimingTapStopScrollNoInteractionId 預設開啟:用來中止慣性捲動(fling)的那一下輕點或點擊,不會被指派 interactionId。

Chromium 給的理由值得抄下來,因為它把 INP 的哲學講清楚了:在慣性捲動進行中輕點螢幕,pointerdownpointerup 照樣觸發,但不會觸發 click、也不會觸發元素的預設行為(連結不會被點開)。更重要的是,使用者收到的第一個視覺回饋是「捲動停了」,而那一幀完全是由合成執行緒驅動的,根本不是 INP 定義裡的「next paint」。把它算進去等於在量一個跟主執行緒無關的東西。

實測影響:主要打在 Android(觸控捲動盛行)。Chromium 觀察到記錄到的 INP 事件數略微下降——因為有一小部分頁面造訪的唯一互動就是「停止 fling」,這些造訪從此不再回報 INP;同時 p95 與 p99 的 INP 略微下降

所以這是一個「分數變好但覆蓋率變差」的改動。如果你在 2024 年底看到 CrUX 上 INP 的樣本數莫名縮水,這就是原因之一。Chrome 133 又補了一條同族的 EventTimingSelectionAutoScrollNoInteractionId(文字選取造成的自動捲動)。

2026 年的四個語義變更

到 2026 年 9 月為止,Chromium 的 INP changelog 上今年有四筆。它們都不改分數,但每一筆都改變你能歸因到什麼

Chrome 144(2025 年 10 月)——performance.interactionCount 進 stable。 上面講過了。它讓 floor(N/50) 的分母變得免費可得。

Chrome 147(2026 年 2 月)——interactionId 改為同步指派。 以前 interactionId 是非同步產生的:要等到下一次繪製的呈現時間出來、duration 定案之後,才回頭指派 id。也就是「先量時間,最後才決定它算不算一次互動」。這對 pointerdownkeydown 特別糟——它們得等 pointerup / keyup 到齊才能確認整次互動,等得毫無必要。Chrome 147 起,interactionId 在 processingStart、也就是事件開始在主執行緒派送的當下同步指派

同一版還拆掉了 renderer 端的 UKM 緩衝。以前 renderer 會把 Event Timing 資料緩衝起來,只挑「每次互動裡最長的那個事件」上報;現在 renderer 把每一個事件直接送給 browser process,由 PageLoadMetricsObserver 做聚合。這消除了複雜的 flush 計時器,而且——這一點對數據品質最重要——大幅降低了在頁面被放棄時丟失最後一筆互動資料的風險,典型情境是「使用者點了連結,頁面立刻導航走」。對只有單次互動的頁面尤其明顯。

官方寫的影響是:RUM 用 interactionId 分組時邊界情況變少;CrUX 的 INP 覆蓋率上升;個別互動的 duration 不變。

changelog 裡還有一句沒展開的話:「This work was needed to support interaction-contentful-paint。」

Chrome 148(2026 年 3 月)——entry.target 只回報「web 可見」的目標。 以前 Event Timing 在事件生命週期很早的時候就用內部的 RawTarget() 指標抓目標。這麼做讓 entry.target 很少是 null,但代價是會洩漏內部的、非 web 可見的目標——像 user-agent shadow root、瀏覽器內建元件的內部實作。這些東西在正常的 DOM 事件派送裡是不會出現在 event.target 上的。

Chrome 148 改成在事件派送結束時去查 event.target,對齊標準的 DOM 事件派送與 Shadow DOM 封裝規則。代價很直接:entry.target 變成 null 的機率上升,三種情況——事件派送在抵達目標前被跳過或取消、目標元素在事件執行過程中被移出 DOM、目標藏在 closed shadow boundary 後面。

這一條對 2026 年的前端影響非常大,因為它精準命中了現代框架最常見的模式:點一個按鈕,handler 把那個按鈕所在的整塊 UI 重繪掉。點擊 → React/Vue 重新渲染 → 原本的 DOM 節點被卸載 → 事件派送結束時 event.target 已經 detached。你的 RUM 拿到的 interactionTarget 就是 null,你會知道「有一次 576 ms 的互動」但不知道是哪個按鈕

Chrome 正在做一個實驗性的 targetSelector 屬性來補這個洞,Chrome 148 起改成用事件的傳播路徑(EventPath)來組出 CSS selector,而不是依賴 target()。因為 EventPath 在 entry.target 為 null 時仍然存在,所以還是能給出一個有效的選擇器字串。但官方明說:targetSelector 仍是實驗性功能,尚未在 stable 預設開啟。

Chrome 150——巢狀 click 指派 interactionId = 0。 典型情境是 <label> 的點擊被轉發到它關聯的表單控制項。以前這會產生兩次帶 id 的事件,現在轉發出去的那次拿到 id = 0,也就是不計為互動,以對齊 web 標準。

這一筆 changelog 只有一行,但把它跟上面那個「50 的階梯」放在一起,推論就有意思了:表單重的頁面,互動次數會下降。 想像一個問卷頁,使用者用 <label> 勾了 60 個選項。若改動前每勾一次算兩次互動(120),改動後算一次(60),那麼 floor(N/50) 就從 2 掉到 1——這頁少了一次「免費丟棄」的額度。如果它最慢的那兩次互動都很慢,INP 反而會變差。

我要說清楚:「改動前是否真的兩次都帶 id」是我的推論,不是 changelog 寫的,已標存疑。 changelog 只寫了「nested clicks 指派 0」與「對齊 web 標準」,沒有給互動計數的前後對照數據。但這條推論的方向值得你在自己的 RUM 上驗一下:把 performance.interactionCount 的分布圖拉出來,看 Chrome 150 前後有沒有整體左移。

已標存疑:interaction-contentful-paint,以及那些「2026 收緊了取樣」的說法

有兩件事我查不到一手依據,兩件都標存疑。

第一件:interaction-contentful-paint 這個名字出現在兩個地方:Chrome 147 的 INP changelog(「這項工作是為了支援 interaction-contentful-paint」),以及昨天查到的 soft navigation 文件裡那份「新增 navigationId 的 entry type 清單」。兩處都當它是既成事實在提。但我搜不到它的一手說明文件、規格草案、或 Chrome Platform Status 條目,不知道它是否已進 stable、是否會成為新的候選指標、也不知道它和 INP 的關係是取代還是互補。從名字猜,它像是「互動之後真正畫出有意義內容的時間」——也就是把 LCP 的「contentful」概念套到互動上,補足 INP 只量「下一幀」而不管那一幀有沒有內容的缺口。這是猜測,不要當事實用。

第二件:那些說「2026 更新收緊了 INP 的取樣方法、CrUX 擴大了 soft navigation 覆蓋」的文章。 這類說法在 SEO 內容農場上到處都是,措辭高度雷同(「tightened the INP measurement methodology」「expanded soft-navigation coverage in CrUX」)。我在 Chromium 的 metrics changelog、web.dev 部落格、Chrome for Developers 上都找不到對應的一手條目

而且它跟一手事實相牴觸。昨天從 Chrome for Developers 的 soft navigation 文件確認到的官方立場是:CrUX 要怎麼呈現 soft navigation「尚未決定,有消息會宣布」。今年 changelog 裡的四筆 INP 變更,官方全部明寫「不改變 INP 分數」(no change to INP scores),沒有任何一筆是在動取樣規則。

我的判斷是:這些文章八成把 Chrome 147 的「覆蓋率上升」誤讀成「取樣收緊」,或者根本是模型生成的。已標存疑,建議直接無視,以 changelog 為準。

LoAF:把 presentation delay 拆開,但拆不完

知道 presentation delay 是 252 ms 之後,下一個問題是那 252 ms 花在哪。Long Animation Frames API(Chrome 123 起 stable,Firefox 與 Safari 皆不支援)就是為此而生。web-vitals v4 起把相關的 LoAF entry 放在 attribution.longAnimationFrameEntries 陣列裡。

一個 LoAF entry 的時間軸長這樣:

startTime ────────────► renderStart ────────► styleAndLayoutStart ────► (startTime + duration)
   |<──── 腳本執行 ────>|<── rAF / ResizeObserver ──>|<── 樣式重算 + 佈局 ──>|

接續上面那次互動,假設對應的 LoAF entry 是:startTime = 4820、duration = 486、renderStart = 5102、styleAndLayoutStart = 5251、firstUIEventTimestamp = 4772。

  • 幀的結束(佈局做完)= 4820 + 486 = 5306
  • 腳本區段 = 5102 − 4820 = 282 ms(其中 116 ms 是我們那次互動的 handler,剩下 166 ms 是同一幀裡的其他腳本)
  • requestAnimationFrame / ResizeObserver 回呼 = 5251 − 5102 = 149 ms
  • 樣式重算 + 佈局 = 5306 − 5251 = 55 ms

現在把它跟 INP 的 presentation delay 對起來。監聽器在 5096 結束,幀在 5306 佈局完成:

  • 5102 − 5096 = 6 ms(監聽器結束到渲染開始的縫隙)
  • + 149(rAF)+ 55(樣式與佈局)= 210 ms
  • 但實際呈現在 5350,5350 − 5306 = 44 ms

那 44 ms 是什麼?是繪製與合成。LoAF 的 duration 官方定義寫得很清楚:「長動畫幀的持續時間,到佈局結束為止,但不包含繪製與合成」。

於是得到一個很重要的結論:你無法用 LoAF 把 presentation delay 拆乾淨。 這一例裡,真實的 presentation delay 是 6 + 149 + 55 + 44 = 254 ms(報表上是 252,差的 2 ms 就是前面那個量化誤差),其中 44 ms、也就是 17.3%,LoAF 完全看不見。如果你的頁面有大量圖層、大面積合成、或在低階 GPU 上跑,這一塊可以遠不只 17%。

在這一例裡該修什麼?149 ms 的 requestAnimationFrame 回呼是單一最大項,佔 presentation delay 的 59%(149 ÷ 252)。web.dev 對此的建議很直接:rAF 回呼裡只該放真的會更新 UI 的工作,任何不碰 DOM、不改樣式的計算放在那裡都只是純粹延後下一幀。

LoAF 還有兩個欄位在報表裡特別有用:

  • blockingDuration —— 這一幀裡瀏覽器因為長任務而無法快速回應的總時間。它同時涵蓋跑 JavaScript 的長任務該幀裡後續的長渲染任務。這是 TBT 概念在單幀層級的對應物。
  • forcedStyleAndLayoutDuration —— 強制佈局(在 handler 回呼裡讀取幾何屬性,逼瀏覽器提前跑樣式重算與佈局)的時長。注意它發生在 processing duration 階段,不在 presentation delay 階段。所以「佈局過重」這件事可能會顯示成 processing 變長而不是 presentation 變長,你只看三段拆解會誤判。

scripts 陣列則給你精確到字元位置的歸因:invoker(例如 'BUTTON#update.onclick')、invokerTypesourceURLsourceCharPositionsourceFunctionNameinvokerType 的六個值在除錯時是一張現成的分類表:

invokerType通常代表
'classic-script' / 'module-script'腳本評估阻塞主執行緒——多半發生在載入期的 input delay
'user-callback'setTimeout / setInterval / requestAnimationFrame 的回呼
'event-listener'稍早排隊、還在處理的另一次輸入
'resolve-promise' / 'reject-promise'更早啟動的非同步工作剛好在使用者互動時結算

判讀規則也很簡單:input delay 高就看第一筆 LoAF entry(longAnimationFrameEntries[0]),那是「卡住你的東西」;processing 或 presentation 高就看最後一筆(.at(-1)),那是「你自己這一幀」。

實驗室沒有 INP,TBT 是替身,而替身值多少

昨天講實驗室與現場不一致時,CLS 至少雙方都有數字。INP 這邊的落差更根本:Lighthouse 不報 INP,因為 Lighthouse 只載入頁面、不互動。web.dev 的原話是,在這種情況下 Total Blocking Time(TBT)可以當成一個合理的代理指標,但它不是 INP 的替代品

TBT 的定義是:FCP 之後,每一個超過 50 ms 的長任務,把超出 50 ms 的部分加總。算一次——三個長任務 120 / 80 / 210 ms:

TBT = (120 − 50) + (80 − 50) + (210 − 50) = 70 + 30 + 160 = 260 ms

在 Lighthouse 10 之後的 Performance 總分裡,TBT 佔 30%——是權重最大的單一指標,比 CLS 的 25% 還高。

但 TBT 和 INP 之間的鴻溝是結構性的,不是精度問題:

  • TBT 只量 FCP 之後到載入結束;INP 量的是整個頁面生命週期。Chrome 的使用資料是「使用者在一個頁面上的時間有 90% 花在載入之後」,而 TBT 對那 90% 一無所知。
  • TBT 不需要互動就能算;INP 沒有互動就沒有值。一個頁面可以 TBT = 0 而 INP = 600 ms(所有問題都在載入後的某個 handler 裡),也可以 TBT = 800 ms 而根本沒有 INP(使用者只是捲了一下就走了)。
  • TBT 只看主執行緒阻塞,完全看不到 presentation delay。 上面那個例子裡最大的一塊是 149 ms 的 rAF 回呼加上 44 ms 的合成——rAF 那段如果沒有單獨超過 50 ms 的任務,TBT 是零。
  • TBT 沒有互動次數的概念,自然也沒有那個 floor(N/50) 的階梯。

另外三條跟 CLS 共通、但在 INP 上同樣成立的系統性偏差,昨天已經拆過機制,這裡只記結論:

iframe。 Event Timing 不會回報 iframe 內互動的 event entry,但 INP 這個指標會算——使用者不知道也不在乎哪塊是 iframe,點嵌入影片的播放鍵就是一次互動。所以你的 JS RUM 與 CrUX 會有落差。官方給的緩解方式是:子框架自己用 API 收 event-timing entry,再回報給父框架。跨來源 iframe 沒辦法。

bfcache。 從上一頁 / 下一頁還原時,INP 應重設為 0,因為使用者把它當成一次獨立造訪。這一條和 CLS 一模一樣。

背景分頁與頁面卸載。 行動裝置的瀏覽器通常不會為背景分頁跑 unload 回呼,所以「最終值」很難拿到。官方的做法是:任何時候頁面被切到背景就上報一次(visibilitychange 同時涵蓋隱藏與卸載),然後由後端算最終的 INP。Chrome 130 起的 ReportEventTimingAtVisibilityChange 就是瀏覽器端的對應措施——在頁面關閉或隱藏前,把還在量測中的互動全部沖出來;因為 observer 回呼可能來不及跑,官方建議這時用 .takeRecords() 同步讀取。這個改動的效果是「INP 分數沒有統計上顯著的變化,但回報覆蓋率上升」。

兩週一版:量測語義的節奏從 2026 年 9 月改了

最後一件今天的現況。Chrome for Developers 在 2026 年 3 月 3 日宣布:從 2026 年 9 月起,Chrome 從四週一版改為兩週一版,起點是 Chrome 153 於 9 月 8 日的 stable release。桌機、Android、iOS 全平台適用,Dev 與 Canary 通道不變。Extended Stable 維持八週。

官方那張表把切換點寫得很精確:

階段M153(舊排程)M153(新排程)M154(舊排程)M154(新排程)
Branch8/248/179/218/31
Beta 推送8/268/199/239/2
Stable Cut9/88/2510/69/8
Early Stable9/98/2610/79/9
Stable Release9/229/810/209/22

更正 4:網路上有文章寫「Chrome 152 於 8 月 25 日發布,是最後一個四週版本」。依官方這張表,8 月 25 日是 M153 的 stable cut,M153 的 early stable 是 8/26、正式 stable release 是 9/8。所以以今天(9 月 6 日)來說,Chrome 153 正在 early stable 推送中,兩天後才是正式 stable。把「stable cut」「early stable」「stable release」三個日期混為一談,是引 Chrome 日期時最常見的錯誤——我昨天寫錯 145 的月份,根子也在這裡。

這件事對量測語義有一個具體後果,而且沒什麼人在講:CrUX 的 28 天滾動視窗,以前大致對應一個 Chrome 里程碑,現在會橫跨大約兩個。

算術很直白。四週一版時,一個 28 天視窗內幾乎所有樣本都來自同一個版本(加上尚未更新的舊版尾巴)。兩週一版之後,同一個 28 天視窗裡會混進兩個里程碑的樣本。如果 Chrome 154 改了某個 Event Timing 的行為,那麼那個月的 CrUX 數字是兩套語義的加權混合,而權重取決於使用者更新的速度——那是你觀察不到的。

實務上的意思是:「這個月 INP 掉了 8 ms」這種觀察,從 2026 年 9 月起會更難歸因。要判斷是自己的改動還是瀏覽器的改動,你需要在自己的 RUM 裡記下 Chrome 主版本號(從 UA-CH 的 Sec-CH-UA 拿),然後按版本切開來看。這件事以前是「有的話很好」,現在是必要的。

🧠 記

  • INP 有兩層聚合。 第一層:單次造訪內,丟掉 floor(互動次數 ÷ 50) 筆最慢的,回報第 (丟棄數 + 1) 慢的那次。第二層:CrUX 對所有造訪取 p75。「INP = 最慢的那次互動」只在互動數 < 50 時成立。
  • 「p98」只是暱稱。 1 − 1/50 = 0.98,但只有 N 是 50 的倍數時才剛好等於 p98;N = 60 時實際是 98.3 百分位。
  • 50 的門檻是階梯不是斜坡。 每次互動有 1% 機率超過 500 ms 的模型下,被判「差」的機率:N=49 → 38.9%,N=50 → 8.9%(掉 4.35 倍);N=99 → 26.1%,N=100 → 8.0%。整體上這個規則是有效的(N=3 是 2.97%、N=300 是 3.36%,同一量級),但邊界不連續。
  • 三段拆解:input delay + processing duration + presentation delay。 presentationDelay倒算的餘數(startTime + duration − processingEnd),而 duration 被量化到 8 ms 的倍數,所以所有量化誤差都堆在 presentation delay 身上。範例:原始 578 ms → duration 報 576 → 208 / 116 / 252,那 2 ms 藏在第三段。
  • 一次互動的延遲取組內事件的最大值,不是加總。 輕點的 pointerdown 40 / pointerup 90 / click 216 → 這次互動是 216,不是 346。同一次互動的事件共用同一個 interactionId
  • durationThreshold 的瀏覽器預設是 104 ms(= 13 × 8),可設的最小值是 16,40 是 web-vitals 自己選的。 沒設門檻就算不出 interactionCount。額外觀察 first-input entry,因為它保證可觀察、不受門檻限制。
  • performance.interactionCount 在 Chrome 144(2025-10)才進 stable。 在那之前算分母要掛 durationThreshold: 0 的 observer 自己去重。不影響分數。
  • 捲動不算互動;「停止 fling 捲動的那一下輕點」是另一條獨立規則(Chrome 130 起預設開啟)。理由:那一幀由合成執行緒驅動,不是 INP 定義的 next paint。效果是 p95/p99 略降但覆蓋率也降
  • Chrome 147(2026-02):interactionId 改為在 processingStart 同步指派;拿掉 renderer 端 UKM 緩衝,改由 browser process 聚合 → 降低頁面被放棄時丟失最後一筆互動的風險,CrUX 覆蓋率上升,分數不變。
  • Chrome 148(2026-03):entry.target 改為在派送結束時查 event.targetnull 的機率上升(元素被移除、closed shadow、派送被取消)。這精準命中「點按鈕後框架把該區塊整個重繪」的模式。實驗性的 targetSelector 改用 EventPath 建構,但尚未在 stable 預設開啟
  • Chrome 150:<label> 之類的巢狀 click 指派 interactionId = 0。推論(已標存疑):表單重的頁面互動數下降 → 可能少一次「免費丟棄」→ INP 反而變差。值得在自己的 RUM 上驗。
  • LoAF 拆不完 presentation delay。 LoAF 的 duration 到佈局結束為止,不含繪製與合成。範例裡 254 ms 的 presentation delay 有 44 ms(17.3%)LoAF 看不見forcedStyleAndLayoutDuration 落在 processing 階段而不是 presentation 階段。
  • LoAF 判讀規則:input delay 高 → 看 longAnimationFrameEntries[0];processing / presentation 高 → 看 .at(-1)invokerType'classic-script' / 'module-script' 代表腳本評估阻塞;'user-callback' 代表 timer 或 rAF;'event-listener' 代表稍早排隊的輸入。
  • Lighthouse 不報 INP,TBT 是替身(Lighthouse 10+ 佔總分 30%)。 TBT = Σ(長任務 − 50 ms);120 / 80 / 210 → 260 ms。但 TBT 只量載入期、不需要互動、看不到 presentation delay、沒有互動次數概念。90% 的頁面停留時間發生在載入之後,TBT 對此一無所知。
  • iframe 內的互動:API 不報,指標要算(與 CLS 同構)。bfcache 還原時 INP 歸零。頁面切到背景就要上報一次(visibilitychange),Chrome 130 起瀏覽器會在此時沖出待量測的互動,腳本端用 .takeRecords() 同步取。
  • Chrome 從 2026 年 9 月改成兩週一版,起點是 Chrome 153 於 9/8 的 stable release(8/25 是 stable cut、8/26 是 early stable——三者不要混談)。後果:CrUX 的 28 天視窗會橫跨約兩個里程碑,月度變化更難歸因,RUM 必須開始記錄 Chrome 主版本號。
  • 門檻未變:良好 ≤ 200 ms,差 > 500 ms,取 p75。 到 2026 年 9 月為止沒有動過。

✍️ 實踐

第一步,先確認你的 RUM 到底算對了沒有。 貼進 console 這段,它會同時給你互動次數、丟棄筆數、以及依規則挑出來的那一筆——順便讓你看到「最慢的那次」和「INP」不是同一個東西:

const list = [];
new PerformanceObserver((po) => {
  for (const e of po.getEntries()) {
    if (!e.interactionId) continue;                   // 0 = 不算互動
    const hit = list.find((i) => i.id === e.interactionId);
    if (hit) {
      // 同一次互動取最大值,不是加總
      if (e.duration > hit.duration) Object.assign(hit, pick(e));
    } else {
      list.push({id: e.interactionId, ...pick(e)});
    }
  }
  list.sort((a, b) => b.duration - a.duration);
  const n = performance.interactionCount;             // Chrome 144+
  const drop = Math.floor(n / 50);
  const inp = list[Math.min(drop, list.length - 1)];
  console.clear();
  console.log(`互動次數 ${n} → 丟棄 ${drop} 筆 → 取第 ${drop + 1} 慢`);
  console.log(`最慢的一次 ${list[0]?.duration} ms,INP = ${inp?.duration} ms`);
  console.table(list.slice(0, 12));
}).observe({type: 'event', buffered: true, durationThreshold: 16});  // 16 是下限
 
function pick(e) {
  return {
    duration: e.duration,                              // 已量化到 8 ms 倍數
    inputDelay: Math.round(e.processingStart - e.startTime),
    processing: Math.round(e.processingEnd - e.processingStart),
    presentation: Math.round(e.startTime + e.duration - e.processingEnd),
    type: e.name,
    target: e.target?.tagName ?? '(null — 見 Chrome 148)',
  };
}

注意 durationThreshold: 16——那是規格允許的最小值,不要留空(留空是 104,你會漏掉大部分互動、算錯次數)。跑完之後在頁面載入的那幾秒內就開始亂點,那是主執行緒最忙的時候,也是 Lighthouse 永遠模擬不到的時段。

第二步,把三段的比例貼在牆上,再決定要修什麼。 拿到 INP 那一筆之後先看比例,不要憑直覺。上面那個 576 ms 的例子是 36% / 20% / 44%——就算把 handler 砍到零也只能到 460 ms,還是「差」。三段各自對應完全不同的修法:input delay 高 → 拆 bundle、延後未使用的程式碼、檢查 setInterval;processing 高 → 在 handler 裡讓出主執行緒(把非視覺工作丟到 scheduler.yield()setTimeout 之後);presentation 高 → 減少 rAF 回呼裡的工作、縮小 DOM、簡化選擇器。

第三步,裝 attribution build,並且從今天起假設 interactionTarget 會是 null。 這是 Chrome 148 之後最實際的一個改動:

import {onINP} from 'web-vitals/attribution';
 
onINP(({value, rating, attribution}) => {
  const {interactionTarget, interactionType,
         inputDelay, processingDuration, presentationDelay} = attribution;
 
  // input delay 高 → 看第一筆;其餘 → 看最後一筆
  const loaf = inputDelay > Math.max(processingDuration, presentationDelay)
    ? attribution.longAnimationFrameEntries[0]
    : attribution.longAnimationFrameEntries.at(-1);
  const script = loaf?.scripts.toSorted((a, b) => b.duration - a.duration)[0];
 
  navigator.sendBeacon('/rum', JSON.stringify({
    value, rating, interactionType,
    target: interactionTarget || null,       // Chrome 148 起更常是 null
    phases: [inputDelay, processingDuration, presentationDelay],
    blocking: loaf?.blockingDuration,
    forcedLayout: loaf?.forcedStyleAndLayoutDuration,   // 落在 processing 階段
    raf: loaf ? loaf.styleAndLayoutStart - loaf.renderStart : null,
    invokerType: script?.invokerType,
    src: script && `${script.sourceURL}:${script.sourceCharPosition}`,
    chrome: navigator.userAgentData?.brands
      ?.find((b) => b.brand === 'Chromium')?.version,   // 兩週一版之後必記
    interactions: performance.interactionCount,          // 讓你能重建 floor(N/50)
  }));
});

四個欄位是今天新增的:chrome 版本號(兩週一版之後,月度趨勢必須按版本切開才有意義)、interactions(讓後端能重建丟棄邏輯、也能檢查你的流量是不是壓在 50 的邊界上)、forcedLayout(強制佈局藏在 processing 裡,不看這個會誤判)、raf(rAF 回呼時長,presentation delay 最常見的單一大戶)。

第四步,不要用 entry.target 當唯一的歸因鍵。 既然 Chrome 148 之後 null 會變多,現在就在自己的 handler 裡加一個穩定的識別:互動發生時立刻(在框架重繪之前)從事件的 currentTarget 讀一個 data-track 屬性存起來,再跟 INP 的上報接起來。等 targetSelector 進 stable 再切過去——但它現在還是實驗性的,不要押上去。

第五步,把 CrUX 覆蓋率當成一個獨立指標看。 INP 跟 LCP / CLS 不一樣:沒有互動就沒有值。所以「INP 樣本數」本身會動,而且會因為瀏覽器改動而動(fling stop 排除讓它降、Chrome 147 的早期 id 指派讓它升)。如果你只盯 p75 不盯樣本數,你會把覆蓋率變化誤讀成效能變化。

第六步,如果是 SPA,兩套都量。 跟昨天 CLS 的結論一樣:web-vitals v6 的 {reportSoftNavs: true} 會在每次 soft navigation 時把 INP 一起重設為 0 並重新開始;但 CrUX 怎麼呈現 soft navigation 官方仍未定案(那些說「2026 已擴大 CrUX soft navigation 覆蓋」的文章,我找不到一手依據,已標存疑)。傳統整頁生命週期那套先留著,好跟歷史資料銜接。

🔗 延伸學習

💬 問 AI

我在做 INP 的量測語義除錯,請用 2026 年 9 月的現行定義回答,並在每個結論標註來源(web.dev / Chrome for Developers / Chromium metrics_changelog / W3C Event Timing / LoAF 規格)。不確定的請直接說不確定,不要猜,尤其不要引 SEO 內容農場的說法。
 
背景:CrUX 的 INP p75 是 512 ms(行動裝置),Lighthouse 沒有 INP、TBT 是 260 ms。頁面是 React SPA,表單很重(大量 <label> 包住的 checkbox),有第三方廣告 iframe,而且使用者平均每次造訪互動約 45 次。
 
請依序回答:
 
1. 依「丟棄 floor(N ÷ 50) 筆最慢互動」的規則,幫我算出以下互動次數各自回報第幾慢的那筆:N = 12 / 49 / 50 / 87 / 137 / 300。並說明「p98」這個暱稱在什麼情況下才剛好等於 p98。
 
2. 我的使用者平均互動 45 次,壓在 50 的門檻下方。用「每次互動有 1% 機率超過 500 ms」的獨立模型,幫我算出 N = 45、N = 49、N = 50、N = 55 四種情況下,這次造訪被判為「差」的機率,並把計算過程列出來。這個階梯效應在經過 CrUX 的 p75 之後還剩下多少?
 
3. 我有一筆 INP 是 576 ms,三段是 inputDelay 208 / processingDuration 116 / presentationDelay 252。
   (a) 為什麼 presentationDelay 是倒算出來的?duration 被量化到 8 ms 倍數這件事,誤差會落在哪一段?
   (b) 如果我把 handler 完全優化掉(processing → 0),INP 會變成多少?足夠進「良好」嗎?
   (c) 對應的 LoAF entry 是 startTime 4820 / duration 486 / renderStart 5102 / styleAndLayoutStart 5251。請算出腳本、rAF、樣式佈局各自的時長,以及有多少毫秒是 LoAF 看不見的,並說明為什麼看不見。
 
4. Chrome 148 之後我的 RUM 有大約三成的 INP 上報 interactionTarget 是 null。請解釋這個改動的機制(RawTarget vs 事件派送結束時查 event.target),列出會導致 null 的三種情況,說明為什麼 React 的重繪模式特別容易踩到,並給我一個不依賴 entry.target 的歸因方案。targetSelector 現在能用嗎?
 
5. Chrome 150 把 <label> 的巢狀 click 指派為 interactionId = 0。請推論這對我這種表單重頁面的互動次數與 INP 有什麼影響,特別是跨過 50 這個門檻的可能性。請明確區分哪些是 changelog 寫的、哪些是推論。
 
6. 請把 TBT 260 ms 和 INP 512 ms 之間的落差拆成系統性成因(量測窗口、是否需要互動、看不看得到 presentation delay、iframe、bfcache、互動次數規則),說明每一項會讓落差朝哪個方向偏,並告訴我 TBT 在什麼情況下會系統性地低估 INP。
 
7. Chrome 從 2026 年 9 月改成兩週一版之後,CrUX 的 28 天視窗會橫跨約兩個里程碑。請說明這對「月度 INP 變化的歸因」有什麼影響,以及我的 RUM 需要記錄哪些額外欄位才能把瀏覽器變更和自己的改動分開。
 
8. 最後給我一份排序後的修復清單,依「對 INP 那一筆的三段拆解的貢獻」排序,而不是依修起來的難易度,並說明每一項預期能把 p75 從 512 ms 拉到多少。