昨天把 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) 慢的那次互動
代進去看:
| 互動次數 N | floor(N ÷ 50) | 回報的是第幾慢 |
|---|---|---|
| 1 – 49 | 0 | 第 1 慢(最慢的那次) |
| 50 – 99 | 1 | 第 2 慢 |
| 100 – 149 | 2 | 第 3 慢 |
| 137 | 2 | 第 3 慢 |
| 300 | 6 | 第 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(判為差) |
|---|---|---|---|---|
| 49 | 0 | ≥ 1 | 0.49 | 38.9% |
| 50 | 1 | ≥ 2 | 0.50 | 8.9% |
| 99 | 1 | ≥ 2 | 0.99 | 26.1% |
| 100 | 2 | ≥ 3 | 1.00 | 8.0% |
| 149 | 2 | ≥ 3 | 1.49 | 18.9% |
| 150 | 3 | ≥ 4 | 1.50 | 6.6% |
| 200 | 4 | ≥ 5 | 2.00 | 5.3% |
| 300 | 6 | ≥ 7 | 3.00 | 3.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 把這三個值直接給你,分別是 inputDelay、processingDuration、presentationDelay(v4 起)。
但這裡有一個實作細節必須講清楚,否則你會在報表上看到「三段加起來跟 duration 差 2 毫秒」而以為是浮點誤差。Event Timing 的 duration 欄位出於指紋防護,被量化到 8 毫秒的倍數。而三段是這樣算的:
inputDelay = processingStart − startTime
processingDuration = processingEnd − processingStart
presentationDelay = (startTime + duration) − processingEnd
看第三行:它用的是已經量化過的 duration。所以 presentationDelay 不是量出來的,是倒算出來的餘數——量化誤差、外加任何三段之外的縫隙,全部堆在它身上。
跑一次完整的數字。一次點擊:
startTime= 4772(使用者按下的時間戳)processingStart= 4980processingEnd= 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。
- 觸控輕點:
pointerdown→pointerup→click - 鍵盤按鍵:
keydown→keypress→keyup - 鍵盤觸發的 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
這是中文材料裡最常見的錯誤之一,而且它源自一個真實但被誤讀的數字。
事實有三層:
- 瀏覽器/規格的預設值是 104 毫秒。 web.dev 原文:duration 低於 104 ms 的
evententry 預設不會回報給 PerformanceObserver。104 = 13 × 8,剛好是量化單位的整數倍(這個「為什麼是 13」我沒有從一手文件確認到理由,已標存疑)。 - 可設定的最小值是 16 毫秒。 你不能設成 0 以外的任意小值——註冊 observer 時給的
durationThreshold有下限 16。(唯一例外是durationThreshold: 0,那是另一條路徑。) - 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 月進 stable、Chrome 148 於 2026 年 3 月進 stable、Chrome 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 的哲學講清楚了:在慣性捲動進行中輕點螢幕,pointerdown 與 pointerup 照樣觸發,但不會觸發 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。也就是「先量時間,最後才決定它算不算一次互動」。這對 pointerdown 與 keydown 特別糟——它們得等 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')、invokerType、sourceURL、sourceCharPosition、sourceFunctionName。invokerType 的六個值在除錯時是一張現成的分類表:
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(新排程) |
|---|---|---|---|---|
| Branch | 8/24 | 8/17 | 9/21 | 8/31 |
| Beta 推送 | 8/26 | 8/19 | 9/23 | 9/2 |
| Stable Cut | 9/8 | 8/25 | 10/6 | 9/8 |
| Early Stable | 9/9 | 8/26 | 10/7 | 9/9 |
| Stable Release | 9/22 | 9/8 | 10/20 | 9/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 藏在第三段。 - 一次互動的延遲取組內事件的最大值,不是加總。 輕點的
pointerdown40 /pointerup90 /click216 → 這次互動是 216,不是 346。同一次互動的事件共用同一個interactionId。 durationThreshold的瀏覽器預設是 104 ms(= 13 × 8),可設的最小值是 16,40 是web-vitals自己選的。 沒設門檻就算不出interactionCount。額外觀察first-inputentry,因為它保證可觀察、不受門檻限制。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.target→ null 的機率上升(元素被移除、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 覆蓋」的文章,我找不到一手依據,已標存疑)。傳統整頁生命週期那套先留著,好跟歷史資料銜接。
🔗 延伸學習
- Interaction to Next Paint (INP) — web.dev — 官方定義。「每 50 次互動忽略一次最慢的」的原文、三段拆解的圖、以及「指標與 API 的差異」一節(104 ms 預設門檻與 16 ms 下限、
first-input的必要性、bfcache 歸零、iframe、visibilitychange上報)。 - Find slow interactions in the field — web.dev — attribution build 的完整欄位表、LoAF 的七個關鍵欄位、
invokerType六個值各自代表什麼,以及用styleAndLayoutStart與renderStart反推 rAF 回呼與樣式佈局時長的算式。 - Interaction to Next Paint Changelog — Chromium metrics_changelog — 唯一可靠的版本與日期來源。Chrome 144 / 147 / 148 / 150 的 2026 年變更、每筆的「how does this affect a site’s metrics」與「when were users affected」,以及每個 CL 的 crrev 連結。
- Get features faster with Chrome’s two-week release cycle — Chrome for Developers — 2026-03-03 公告。M153 / M154 的新舊排程對照表,把 branch / beta / stable cut / early stable / stable release 五個日期分清楚。
💬 問 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 拉到多少。