昨天為了找出「誰在 requestAnimationFrame 裡跑掉那 149 ms」,寫了一段覆寫 window.requestAnimationFrame、拿 new Error().stack 第三行當鍵的 console hack,又手寫了五十行把 event entry 跟 LoAF entry 用時間區間 join 起來的程式碼。兩件事都不必做,而且兩件事都做錯了。

LoAF entry 上有一個 scripts 陣列。每一筆是一個 PerformanceScriptTiming,給你 invokerType('user-callback')、invoker('Window.requestAnimationFrame')、sourceURL、sourceFunctionName、sourceCharPosition,外加一個叫 forcedStyleAndLayoutDuration 的欄位。這套東西自 Chrome 123(2024-03-19 stable) 起就在,跟 LoAF 本體同一版出貨。昨天整篇在講「怎麼把 254 ms 拆成四段」,而拆完之後「這四段是誰造成的」這個問題,瀏覽器已經直接回答到字元位置這個精度。

第二件事更難看:那個 event↔LoAF 的 join,web-vitals 的 attribution build 從 v5.0.0(2025-05-07) 就做完了,而且額外給四個彙總桶 —— totalScriptDuration、totalStyleAndLayoutDuration、totalPaintDuration、totalUnattributedDuration。昨天手寫的那段 join,有一個 ring buffer 固定壓成 60 筆;官方那份對應的程式碼四週前(v6.2.2,2026-09-14)才剛修掉同一類 bug,issue 標題是「Memory leak in attribution/onINP.ts: pendingLoAFs accumulates unboundedly during long periods without user interaction」。手寫版之所以沒有這個 leak,純粹是因為那個 60 是隨手寫的,不是因為想過。

但今天最重要的一個數字不是上面任何一個。是 5。

scripts 陣列有一道門檻:Chrome 的文件寫「Script attribution is only provided for scripts longer than 5 milliseconds」。意思是,如果那 149 ms 的 pre-layout 是由 40 個各 3.7 ms 的 rAF 回呼組成的(一個動畫庫,四十個同時在跑的動畫,一個動畫一個 tick),那 scripts 陣列會是空的,149 ms 一毫秒都不會被歸因。昨天說「LoAF 只報 ≥ 50 ms 的幀,這是有偏的樣本」——那條偏差在幀的層級。今天這條在腳本的層級,而且它更毒:幀的偏差你看得出來(entry 不存在),腳本的偏差你看不出來(entry 存在,陣列是空的,看起來像「沒有 JS」)。

📖 學

一、昨天那段 monkeypatch 量錯了三件事

先把昨天實踐第二步那段東西拆掉,因為它不只是「有更好的做法」,它本身會給出錯的答案。

昨天的做法是:覆寫 window.requestAnimationFrame,在回呼前後各取一次 performance.now(),差值累加進一個以 new Error().stack 第三行為鍵的 Map,跑一分鐘後排序看前十名。

錯誤一:堆疊的第三行不是你要的那一行。 new Error().stack 的格式沒有跨引擎標準;在 Chrome 上第一行是 Error,第二行是覆寫函式本身,第三行才「通常」是呼叫端。但經過 bundler 之後,呼叫端很可能是 bundle 裡的一個 helper(__webpack_require__、Rollup 的 wrapper、或動畫庫自己的 _tick),而不是「誰排了這個 rAF」。更糟的是,註冊 rAF 的地方跟回呼裡真正花時間的地方往往不是同一處——動畫庫在初始化時註冊一次 ticker,之後每幀跑的是它內部的迴圈,堆疊永遠指向同一行,你只會知道「GSAP 在跑」,不會知道「GSAP 在跑哪個 tween」。

錯誤二:前後取 performance.now() 的差,量的是「這個回呼」,不是「這個回呼在這一幀裡的成本」。 同一幀裡若有 40 個 rAF 回呼,你會得到 40 筆各自的時間,加起來大致等於 pre-layout;但只要其中任何一個回呼讀了幾何屬性(offsetHeight、getBoundingClientRect()),它觸發的那次強制同步佈局會被算進它自己的那一段時間裡,而你的 Map 把它跟「純計算」混在同一個數字。昨天第四節花了整節講「layout thrashing 的成本落在你讀的地方」,然後在實踐裡給了一個沒辦法把這兩種成本分開的工具。

錯誤三:它只看得到 requestAnimationFrame。 昨天第二節自己列過 pre-layout 跑的是三樣東西:rAF 回呼、ResizeObserver 回呼、IntersectionObserver 回呼。覆寫 requestAnimationFrame 對後兩者完全無效。如果那 149 ms 其實是一個 lazy-load 的 IntersectionObserver 一次觸發了 80 個元素,昨天那個工具會回報「rAF 總共 3 ms」,然後你會得出「pre-layout 不是問題」這個剛好相反的結論。

三個錯誤的共同根源是同一個:用 JavaScript 去量 JavaScript,你只能量到你包得住的那一層。 而瀏覽器站在包不住的那一層,它知道每一段 script 是被什麼 invoke 的、來自哪個檔案、哪個函式、第幾個字元,以及其中有多少毫秒花在強制同步佈局上。

二、scripts 陣列:從「一段時間」到「一個字元位置」

PerformanceLongAnimationFrameTiming 上的 scripts 是一個 FrozenArray<PerformanceScriptTiming>。每一筆的欄位,依 MDN 的定義:

欄位MDN 定義你拿它做什麼
startTime「the time when the script execution started」判斷它落在 INP 的哪一段
duration「the elapsed time in milliseconds between the start and end of the script’s execution」這段 script 的總成本
executionStart「the time when the script compilation finished and execution started」executionStart − startTime = 編譯時間
invokerType「the type of feature that, when invoked, ran the script」分類:這是事件、rAF、promise 還是 script 標籤
invoker「the identity of the feature that, when invoked, ran the script」具體是哪一個(例:Window.requestAnimationFrame)
sourceURL「the URL of the script」第一方還是第三方
sourceFunctionName「the name of the function that contributed to the LoAF」哪個函式
sourceCharPosition「the script character position of the script feature that contributed to the LoAF」第幾個字元(規格:不明時為 -1)
forcedStyleAndLayoutDuration「the total time spent, in milliseconds, by the script processing forced layout/style」layout thrashing 的直接量測
pauseDuration「the total time spent by the script on “pausing” synchronous operations (for example, Window.alert() calls or synchronous XMLHttpRequests)」同步阻塞
window / windowAttribution執行所在的 container,以及它跟目前文件的關係同源 iframe 歸因

invokerType 的列舉值(規格):"classic-script"、"module-script"、"event-listener"、"user-callback"、"resolve-promise"、"reject-promise"。

invoker 是一個建構出來的字串,Chrome 的文件舉的例子包括 'Window.requestAnimationFrame'(對應 user-callback)、'click' / 'load' / 'keyup'(對應 event-listener)、'Response.json.then'(對應 resolve-promise)。

這裡有一個實務陷阱,今天特別標出來:invoker 的字串格式在 LoAF 的發展過程中改過。 Chrome 的舊版文件(origin trial 時期,Chrome 116–122)用的是 name / type 這組欄位名,而且我在不同的二手資料上看到 FrameRequestCallback、BUTTON#example3.onclick、DOMWindow.onresize 這幾種寫法,跟現行文件的 'Window.requestAnimationFrame' 不同。我查不到一份權威的、完整的 invoker 字串表。 所以規則是:不要在程式碼裡硬比對 invoker 的字串,先 console.log 出來看你自己的 Chrome 版本吐什麼。 要分類就比對 invokerType(那是規格定義的列舉,穩定),不要比對 invoker(那是實作建構的字串)。

於是昨天那個問題——「誰在 rAF 裡跑掉 149 ms」——的正解是:

new PerformanceObserver((list) => {
  for (const loaf of list.getEntries()) {
    const preLayout = loaf.styleAndLayoutStart
      ? loaf.styleAndLayoutStart - loaf.renderStart
      : 0;
    if (preLayout < 30) continue;                     // 只看 pre-layout 重的幀
 
    console.groupCollapsed(
      `pre-layout ${Math.round(preLayout)}ms / frame ${Math.round(loaf.duration)}ms`
    );
    console.table(
      loaf.scripts
        .filter((s) => s.startTime >= loaf.renderStart)   // 落在 render 階段的
        .map((s) => ({
          invokerType: s.invokerType,
          invoker: s.invoker,
          fn: s.sourceFunctionName || '(anonymous)',
          url: s.sourceURL || '(cross-origin / inline)',
          char: s.sourceCharPosition,
          ms: Math.round(s.duration),
          compile: Math.round(s.executionStart - s.startTime),
          forcedLayout: Math.round(s.forcedStyleAndLayoutDuration),
        }))
    );
    const attributed = loaf.scripts
      .filter((s) => s.startTime >= loaf.renderStart)
      .reduce((a, s) => a + s.duration, 0);
    console.log(
      `歸因 ${Math.round(attributed)} / ${Math.round(preLayout)} ms ` +
      `(${Math.round((attributed / preLayout) * 100)}%)`
    );
    console.groupEnd();
  }
}).observe({ type: 'long-animation-frame', buffered: true });

最後那個「歸因率」不是裝飾,是今天整篇的判讀關鍵,下一節就講它。

順帶補兩個限制,兩個都出自 Chrome 的一手文件:script attribution「only for scripts running in the main thread of a page, including same-origin iframes」——worker 裡的東西不在這裡,跨源 iframe 的也不在。另外跨源腳本在沒有 CORS 的情況下 sourceURL 會是空字串(規格的說法是 cross-origin opaque scripts 給空字串),而編譯時間也不暴露,所以 executionStart 會等於 startTime。SpeedCurve 與 DebugBear 兩家的文章都提到補上 crossorigin="anonymous" 常常能把 sourceURL 救回來——這兩份是廠商部落格不是一手文件,標二手。 但這條建議的成本極低,值得試。

三、5 ms 門檻:今天最重要的黑洞,附三種情境的算術

Chrome 的文件那句話要原樣記住:

“Script attribution is only provided for scripts longer than 5 milliseconds.”

這句話的後果,用昨天那 149 ms 的 pre-layout 算三次。

情境 A(集中型):一個動畫庫的 ticker,一次跑 149 ms。

scripts 陣列筆數:1
陣列內總時長:  149 ms
pre-layout:     149 ms
歸因率:         149 / 149 = 100%

sourceURL 直接指到那個庫,sourceFunctionName 直接指到那個 ticker 函式。五分鐘結案。

情境 B(分散型):40 個動畫,各自註冊一個 rAF 回呼,每個 3.725 ms。

40 × 3.725 = 149 ms
每一筆都 < 5 ms  →  沒有任何一筆進 scripts
scripts 陣列筆數:0
陣列內總時長:  0 ms
pre-layout:     149 ms
歸因率:         0 / 149 = 0%

LoAF entry 存在(整幀 578 ms,遠超 50 ms),renderStart 與 styleAndLayoutStart 都有值,pre-layout 明明白白是 149 ms,而 scripts 是空陣列。 一個不知道這條門檻的人看到這個,合理的結論會是「pre-layout 這 149 ms 不是 JavaScript,是瀏覽器自己的渲染成本」——剛好就是昨天犯的那個分類錯誤,只是這次是資料把你騙過去的。

情境 C(混合型):3 個 30 ms 的重回呼 + 20 個 2.95 ms 的輕回呼。

重:3 × 30    = 90 ms   → 3 筆進 scripts
輕:20 × 2.95 = 59 ms   → 0 筆進 scripts
合計 149 ms
歸因率:90 / 149 = 60.4%

這個最危險,因為它看起來成功了。你抓到三個 30 ms 的元凶,把它們修掉,pre-layout 從 149 掉到 59,你以為剩下的是雜訊。實際上那 59 ms 是二十個各自很小但數量很多的回呼,它的解法(降低同時在跑的觀察者/動畫數量)跟前三個(優化那個函式)完全不同,而資料不會告訴你。

把這條門檻推到極端,得到一個值得記住的事實。一幀要被回報成 LoAF,duration 必須超過 50 ms。假設這一幀完全由 4.9 ms 的回呼構成:

50 ÷ 4.9 = 10.2  →  需要 11 個回呼(53.9 ms)才會超過門檻
11 個回呼,沒有任何一個 ≥ 5 ms

所以「有 LoAF entry,但 scripts 是空陣列」是一個完全可能、而且有明確意義的狀態:很多小回呼,不是沒有 JavaScript。 這是一條可以直接寫進 runbook 的判準。

再往上推到「良好」的 200 ms 預算。用昨天第九節那一列:input 25 / processing 30 / 縫隙 6 / pre-layout 40 / style+layout 55 / paint+composite 44 = 原始和 200。其中不是 script 的部分是 6 + 55 + 44 = 105 ms,剩下 95 ms 全部是 JavaScript。如果這 95 ms 由 4.75 ms 的片段構成:

95 ÷ 4.75 = 20 個片段,每個都 < 5 ms
scripts 陣列:空

一筆量化後剛好 200 ms、評級「良好」的互動,裡面可以有 95 ms 的 JavaScript,而 scripts 陣列完全是空的。 這不是理論上的極端值——一個 React 應用在一次狀態更新後跑幾十個 useLayoutEffect、幾十個訂閱者回呼,單個都在 2–5 ms 之間,是非常普通的形狀。

最後一個極端:昨天那筆 578 ms。可能是 script 的部分是 input delay 的 208 + processing 的 116 + pre-layout 的 149 = 473 ms。

473 ÷ 4.99 ≈ 94.8  →  95 個各 4.99 ms 的片段
scripts 陣列:空
歸因率:0%

95 個片段在一幀裡,是不是現實? 一個同時跑 40 個動畫、30 個 IntersectionObserver 交叉、再加上框架 reconciliation 切出來的若干塊的頁面,量級上是對的。但我必須說清楚:這是我構造出來的最壞情況,不是實測。 我沒有找到任何公開資料告訴我「真實世界的 LoAF 裡,scripts 陣列為空的比例是多少」,也沒找到「5 ms 以下的 script 佔 LoAF 總時長的比例」這種統計。這個數字如果有人有,會是整個 INP 歸因工具鏈最重要的單一數字,而我查不到,標存疑。

web-vitals 的文件對這件事的處理方式很誠實,值得原樣引:totalUnattributedDuration 是

“the total unattributed time not included in any of the previous totals. This includes scripts less than 5 milliseconds and other timings not attributed by Long Animation Frame (including when a frame is less than 50ms and so has no Long Animation Frame).”

也就是說,官方把 5 ms 以下的腳本明確歸進「未歸因」這個桶。它沒有假裝那段時間不存在,也沒有假裝它是渲染成本。 這比昨天那個 hack 誠實得多。

四、更正 2:強制同步佈局早就可歸因了

昨天第四節的收尾是這句:

「DevTools 標的紅色三角形指的是『在哪裡觸發』,不是『成本歸屬到哪個 INP 子段』——這兩件事上一篇的三段拆解模型裡完全沒有區分。」

這句話的後半在 2024-03 之後就不對了。PerformanceScriptTiming.forcedStyleAndLayoutDuration 的 MDN 定義是「the total time spent, in milliseconds, by the script processing forced layout/style」——per script。也就是說:

  • 哪一段 script 觸發了強制同步佈局:sourceURL + sourceFunctionName + sourceCharPosition。
  • 觸發了多久:forcedStyleAndLayoutDuration。
  • 那段 script 落在 INP 的哪一子段:比對 startTime 與 renderStart / styleAndLayoutStart。

昨天的說法是「你搬了工作,processingDuration 掉下來而 INP 不動,因為那筆強制佈局只是從第二段搬到第四段,而你看不出來」。看得出來。 搬之前 forcedStyleAndLayoutDuration 在 handler 那筆 script 上,搬之後它在 rAF 那筆 script 上,數字幾乎不變——這個「幾乎不變」本身就是最有力的證據,證明你搬錯了方向。

判讀規則可以寫得很具體:

ratio = forcedStyleAndLayoutDuration / duration     (同一筆 script)

ratio > 0.5   這段 script 的一半以上在等佈局。它不是「慢」,是 thrashing。
              優化演算法沒用,要消滅讀取本身。
ratio ≈ 0     這段 script 是純計算 / 純 DOM 寫入。切塊、搬走、降頻有效。

DebugBear 的文章給過一個實例:某段 script 執行中「112 milliseconds are spent on forcedStyleAndLayoutDuration」。這是二手來源,但它示範的是量級——三位數毫秒的強制佈局在真實網站上不是罕見值。

把這條接回昨天第五節那個 40 ms 門檻(Lighthouse 13 的 Optimize DOM size insight:樣式重算或佈局超過 40 ms 才 fail),可以得到一個很乾淨的決策樹:

forcedStyleAndLayoutDurationLoAF 的 style+layout 段結論
高(> 40 ms)低thrashing。DOM 不大,是你在迴圈裡讀幾何。改讀寫順序。
低(≈ 0)高(> 40 ms)DOM / 選擇器成本。這時 containment 才是對的工具。
高高兩個問題都有。先修 thrashing,因為它便宜。
低低成本不在樣式佈局。回去看 scripts 的純執行時間。

昨天第四步叫人「加 contain: content,用 Recalculate Style 的 Elements affected 驗收」。那一步在第一列的情況下是純粹浪費時間,而昨天沒有任何機制可以先判斷你在哪一列。今天有了。

五、更正 3:昨天的 join 有兩個 bug,官方四週前才修掉其中一個

昨天第八節那段 join 程式碼,核心是這兩行:

const f = loafs.find((x) =>
  x.startTime <= e.processingStart &&
  e.processingEnd <= x.startTime + x.duration);

Bug 一:用 find(單數)。 一次互動可以橫跨多個 LoAF。最典型的形狀就是昨天那筆 578 ms:208 ms 的 input delay 意味著使用者按下去的時候,主執行緒上已經有一個長任務在跑——那個長任務自己就是一個 LoAF,而事件處理與渲染發生在下一個 LoAF 裡。昨天的條件式要求 processingStart 與 processingEnd 都落在同一個 LoAF 的區間內,於是它會找到處理與渲染那一幀,而完全漏掉造成 208 ms input delay 的那一幀——也就是六段模型裡最大的那一段的元凶。

web-vitals 的做法是 longAnimationFrameEntries:「array of intersecting LoAF entries」,複數,取所有與 INP 區間相交的 entry。

Bug 二:ring buffer 的 60 筆是憑感覺寫的。 巧合的是這個方向保守到剛好避開了官方踩過的坑,但理由不對。GoogleChrome/web-vitals 的 issue #795 標題是「Memory leak in attribution/onINP.ts: pendingLoAFs accumulates unboundedly during long periods without user interaction」,PR #796「Cap pending LoAFs to avoid memory leak」在 2026-09-14 merge,隨 v6.2.2 出貨。我查不到那個 cap 的具體數值(PR 頁面上看不到 diff 內容),標未查到。

這個 leak 的機制值得想一遍,因為它解釋了為什麼「保留最近 N 筆 LoAF 等互動來 join」這個模式天生危險:LoAF 是持續產生的(捲動、動畫、背景工作都會製造長幀),而互動是偶發的。一個開著不動的儀表板頁面,一小時可以產生數千筆 LoAF entry 而一次互動都沒有;如果你的 buffer 只在「有互動時」才清,它就永遠不清。這不是 INP 特有的 bug,是所有「等待配對」型的 observer 都會有的形狀。

所以今天的結論是:不要自己寫這個 join。 用 web-vitals/attribution:

import { onINP } from 'web-vitals/attribution';
 
onINP(({ value, attribution: a }) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    inp: value,
    // 三段(舊模型)
    inputDelay: a.inputDelay,
    processing: a.processingDuration,
    presentation: a.presentationDelay,
    // 四桶(今天的新模型)
    script: a.totalScriptDuration,
    styleLayout: a.totalStyleAndLayoutDuration,
    paint: a.totalPaintDuration,
    unattributed: a.totalUnattributedDuration,
    // 最長的那段 script,附它落在哪一段
    script_url: a.longestScript?.entry?.sourceURL,
    script_fn: a.longestScript?.entry?.sourceFunctionName,
    script_char: a.longestScript?.entry?.sourceCharPosition,
    script_forced: a.longestScript?.entry?.forcedStyleAndLayoutDuration,
    script_subpart: a.longestScript?.subpart,
    script_overlap: a.longestScript?.intersectingDuration,
    target: a.interactionTarget,
    type: a.interactionType,
    loadState: a.loadState,
  }));
});

longestScript 這個物件是 INPLongestScriptSummary,三個欄位:entry(最長的那筆 script)、subpart('input-delay' | 'processing-duration' | 'presentation-delay',它落在哪一段)、intersectingDuration(它跟 INP 區間重疊了多久)。

subpart 這個欄位是整個 RUM 設計的轉折點。 昨天的六段模型是「時間怎麼分配」,subpart 是「這一行程式碼造成了哪一段」。前者你只能做總體判斷(「pre-layout 最大,去看 rAF」),後者你可以直接在 BI 上做 GROUP BY script_url, script_fn 然後 ORDER BY SUM(script_overlap) DESC ——得到的是一張以函式為單位、按對 INP 的貢獻排序的清單。 這是昨天那個 console hack 想做而做不到的事。

intersectingDuration 跟 entry.duration 的區別也要講清楚,因為這是最容易誤用的地方:一段 300 ms 的長任務如果只有最後 180 ms 落在 INP 的 input delay 裡,entry.duration 是 300,intersectingDuration 是 180。做 RUM 聚合時要用 intersectingDuration(你想知道的是它對這筆 INP 的貢獻),做程式碼優化排序時要用 entry.duration(你想知道的是這個函式總共有多慢)。兩個都存,別只存一個。

六、更正 4:三段恆等式有第二個誤差源,而且它會讓 inputDelay 變成負數

這一條是今天最該更正的,因為它動到的是整個系列從 9 月 6 日以來一直在用的會計恆等式。

昨天說:三段模型唯一的誤差來源是量化,presentationDelay 作為餘數會吃掉 0–7 ms 的系統性低估。還有第二個誤差源,而且它可能大得多。

web-vitals 的 PR #789(v6.2.1,2026-08-26)修的是「negative inputDelay in INP attribution」。原因寫在 PR 描述裡:

“group.processingStart is the minimum across every event presented in the frame, which can predate the interaction when a non-interaction handler was still running.”

解法:

“Clamp the frame-wide group.processingStart to the interaction’s own startTime so inputDelay can never be reported as negative.”

拆開來看發生了什麼。web-vitals 不是拿單一個 PerformanceEventTiming 來算三段,它把同一幀裡呈現的所有 event 分成一組(這是必要的,因為一次點擊會產生 pointerdown / pointerup / click 等多筆 entry,而 INP 取的是整組的時間跨度)。processingStart 取的是整組的最小值。

問題是:同一幀裡可能有跟這次互動無關的 event。使用者按下去之前,一個 scroll、一個 message、一個 visibilitychange 的 handler 可能已經在跑了,而它們的 processingStart 早於這次互動的 startTime。於是:

inputDelay = group.processingStart − interaction.startTime  <  0

這不是浮點誤差,不是量化,是模型本身的定義衝突:「這一幀的最早處理時間」跟「這次互動的開始時間」是兩個不同座標系的東西。

後果有三層:

第一層,負數本身。 在 v6.2.1 之前,你的 RUM 裡會有 inputDelay < 0 的筆數。如果你的儀表板算的是平均值,這些負數會把平均拉低;如果你在 SQL 裡有 WHERE inputDelay >= 0 這種看起來無害的過濾,你會系統性地丟掉一整類互動——而丟掉的恰好是「互動發生時主執行緒上正有別的 handler 在跑」的那些,也就是最慢的那些。

第二層,clamp 之後誤差跑到哪去。 clamp 把 processingStart 往後拉到 startTime,於是 inputDelay 變成 0。但 processingDuration = processingEnd − processingStart 用的是哪個 processingStart?如果用 clamp 後的,processingDuration 就變短;如果用原始的,三段加起來就不等於 duration。PR 描述說修正的目的是讓「三段保持數學上一致」,所以應該是前者。但我沒有讀到原始碼,這是從描述推的,標推論。 無論哪一種,結論都一樣:這筆資料的三段拆解不可信,只有 value 本身可信。

第三層,也是最該記住的:昨天那句「presentation delay 只能從原始值倒算,而原始值你拿不到」還要再加一句——分子也可能是錯的。 昨天的規則是「接受 0–7 ms 的量化差,不要去湊」。今天的規則要改寫成:

三段拆解是一個有兩個獨立誤差源的估計:量化(0–7 ms,系統性低估 presentation delay)與跨事件分組(方向與大小都不定,可能讓 inputDelay 為負)。 單筆互動的三段數字只能當方向指示,不能當帳。要對帳,用四桶,因為四桶是從 LoAF 的實測時間戳直接算的,不是從餘數倒推的。

這也順帶解釋了一件事:為什麼官方會在 2025 年加四桶這套平行的拆法,而不是把三段做得更準。因為三段是從 Event Timing 的座標系來的,而 Event Timing 的座標系裡根本沒有「渲染」這個概念。 三段回答的是「誰的問題」(使用者等、你的 handler、還是瀏覽器),四桶回答的是「什麼類型的問題」(script、樣式佈局、繪製、不知道)。PR #592 的討論裡有一句把這件事講得很準:subparts 是主要視角,buckets 是另一種視角,官方刻意沒有做「每一段各自的四桶」,理由是那樣「overly complex to grok」。

七、四個桶,以及它跟六段模型的對帳

把兩套模型並排。四桶的定義依 web-vitals 的文件與 PR #592:

桶定義對應昨天六段模型的哪幾段
totalScriptDuration所有與 INP 相交的 script entry 的 duration 總和(> 5 ms 者)input delay 裡的阻塞腳本 + processing 的 handler + pre-layout 的 rAF/RO/IO 回呼
totalStyleAndLayoutDuration跨所有相交 LoAF 的樣式與佈局時間第五段(55 ms)
totalPaintDuration「Off-main-thread presentation delays (end of LoAF to end of INP)」第六段(44 ms)
totalUnattributedDuration上述三者未涵蓋的其餘時間,含所有 < 5 ms 的 script,以及沒有 LoAF 的幀縫隙 6 ms + 所有小片段

第三桶的定義要特別讀一遍:「LoAF 結束到 INP 結束」。這是一個很聰明的定義——它不需要 Chrome 144 的 paintTime / presentationTime,它從 LoAF 的 startTime + duration(佈局結束)算到 INP 自己的結束,兩者之間就是繪製、光柵化與合成。昨天花了一整節講「Chrome 144 才讓那 44 ms 看得見」,而 web-vitals 從 v5.0.0(2025-05)就用減法把同一段時間算出來了。

更正 2 的補充(接昨天的「更正 2」):昨天說「那 44 ms 在 Chrome 144 之前完全看不見」。精確地說,在 Chrome 144 之前看不見的是「繪製階段的開始時間」與「像素到達螢幕的時間」這兩個時間戳;而「佈局結束到下一幀呈現」這個區間長度,只要你有 LoAF 的 startTime + duration 跟 INP 的結束時間,一直都算得出來。「看不見」跟「沒有現成欄位」是兩回事,昨天把它們混為一談了。

第四桶的一個設計細節值得學:

“When no Long Animation Frames are present this will be undefined, rather than everything being unattributed to make it clearer when it’s expected to be small.”

沒有 LoAF 時回傳 undefined 而不是 duration 全額。 這是在資料層面區分「我量了,未歸因的部分很小」與「我根本沒量到」。昨天留下的那個未解問題——「LoAF 只報 ≥ 50 ms 的幀,這是有偏的樣本」——官方的答案不是消除偏差(消除不了,50 ms 寫死在規格裡),而是讓偏差在資料上顯形。 在你的 RUM 裡,unattributed IS NULL 的比例就是「這批互動快到連 LoAF 都沒產生」的比例,那是一個你應該追蹤的數字,不是一個應該填 0 的空欄。

現在對帳。昨天那筆 578 ms,六段是 208 / 116 / 6 / 149 / 55 / 44。假設所有 script 都 ≥ 5 ms(情境 A 的樂觀假設),四桶應該是:

totalScriptDuration        = 208 + 116 + 149 = 473
totalStyleAndLayoutDuration= 55
totalPaintDuration         = 44
totalUnattributedDuration  = 6
合計                        = 578  ✓

漂亮地對上。但這個漂亮是假設出來的,有兩個地方會破。

破法一:totalScriptDuration 用的是 duration 還是相交部分? PR #592 的描述寫的是「Combined duration of all intersecting scripts」——「所有相交腳本的 duration 總和」。如果字面照讀,那是 script 的完整 duration,不是相交部分。 承前例:造成 208 ms input delay 的那個長任務,如果它自己長 300 ms 而只有最後 208 ms 落在 INP 區間內,totalScriptDuration 會記 300 而不是 208:

totalScriptDuration        = 300 + 116 + 149 = 565
totalStyleAndLayoutDuration= 55
totalPaintDuration         = 44
三桶合計                    = 664  >  578
totalUnattributedDuration  = 578 − 664 = −86   ← 不可能

所以要嘛官方用的是相交部分,要嘛 totalUnattributedDuration 會被 clamp 到 0(或 undefined)。我沒讀原始碼,這裡標存疑,而且這是一個你可以自己五分鐘驗掉的問題:在你自己的網站上加一行斷言。

const sum = (a.totalScriptDuration ?? 0)
          + (a.totalStyleAndLayoutDuration ?? 0)
          + (a.totalPaintDuration ?? 0)
          + (a.totalUnattributedDuration ?? 0);
if (a.totalUnattributedDuration !== undefined && Math.abs(sum - value) > 8) {
  console.warn('四桶對不上 INP', { sum, value, a });   // 8 = 量化粒度
}

這條斷言應該長期留在你的 RUM 裡。 不是為了抓 bug,是為了在你升級 web-vitals 或 Chrome 改語義的時候,第一時間知道你的模型已經跟現實分家了。整個系列講了三週的「會計恆等式」,這是它唯一的實際用途:恆等式不是用來預測的,是用來偵測自己壞掉的。

破法二:5 ms 門檻(第三節)。 如果是情境 B,四桶會變成:

totalScriptDuration        = 208 + 116 + 0   = 324   ← 149 ms 的 rAF 全部消失
totalStyleAndLayoutDuration= 55
totalPaintDuration         = 44
totalUnattributedDuration  = 578 − 423 = 155         ← 6 ms 縫隙 + 149 ms 小回呼

totalUnattributedDuration 是 155 ms,佔 578 ms 的 26.8%。 這個比例本身就是訊號。判準可以寫成:

unattributed / value  <  10%    歸因健康,照 longestScript 去修
unattributed / value  10–30%    有「很多小片段」,先降數量再談優化單點
unattributed / value  >  30%    模型失效,去 Performance 面板錄實際 trace

這三條是我定的,不是官方門檻。 我沒找到任何官方或業界對 totalUnattributedDuration 佔比的建議值,標明是我的判準。但它的邏輯是可檢驗的:未歸因的部分,依定義只能是縫隙(通常個位數毫秒)加上 5 ms 以下的腳本加上沒有 LoAF 的幀;在一筆已經超過 200 ms 的互動裡,這三樣加起來超過三成,意味著時間被切得很碎,而「切得很碎」有它自己的解法。

八、把 200 ms 預算用四桶重寫

昨天第九節那張表是用六段算的。今天用四桶重算一次,因為四桶是你在 RUM 裡真正拿得到的東西,六段裡有一半你只能在 DevTools 裡看到。

量化規則不變:INP 的 duration 量化到 8 ms 的倍數,向下取整。四桶本身不量化(它們從 LoAF 的高精度時間戳算來),所以四桶加總會略大於回報的 INP —— 這正是上一節那條斷言要留 8 ms 容差的原因。

情境scriptstyle+layoutpaintunattributed原始和回報 INP評級
現況(情境 A,全部可歸因)47355446578576差
現況(情境 B,rAF 全 < 5ms)3245544155578576差
做完昨天的兩步(切塊 + 移出非視覺工作)20455446309304需改善
再把 pre-layout 的 script 壓到 409555446200200良好(卡邊)
再用 containment 把 style+layout 壓到 309530446175168良好
script 全歸零(絕對地板)055446105104良好

驗算(除法過程):

578 ÷ 8 = 72.25   → 72 → 576
309 ÷ 8 = 38.625  → 38 → 304
200 ÷ 8 = 25      → 25 → 200
175 ÷ 8 = 21.875  → 21 → 168
105 ÷ 8 = 13.125  → 13 → 104

第三列的 204 = 25(input delay 裡剩下的阻塞腳本)+ 30(視覺必要的 handler)+ 149(pre-layout 還沒動)。第四列的 95 = 25 + 30 + 40。

第一列跟第二列是同一筆互動、同一個 INP 值、同一個評級,而四桶完全不同。 這是四桶模型最重要的性質:它不改變分數,它改變你明天做什麼。 第一列的行動是「打開 longestScript.entry.sourceURL,去修那個函式」;第二列的行動是「去找出為什麼一幀裡有幾十個小回呼」。昨天的六段模型對這兩種情況給出完全一樣的數字(pre-layout 149 ms),因此給出完全一樣的建議,而那個建議對第二列是錯的。

再補一個昨天沒算過的:從「需改善」跨到「良好」,四桶各要減多少。

現況 576(需改善的上界是 500,良好是 200)

目標 200:總原始預算 200 ms
  非 script 的固定成本:style+layout 55 + paint 44 + 縫隙 6 = 105
  留給 script 的:200 − 105 = 95 ms

現況 script:473 ms(情境 A)
需砍:473 − 95 = 378 ms,即砍掉 79.9%

要進「良好」,你得砍掉八成的 JavaScript 執行時間。 這個數字比昨天第九節那幾列表格誠實得多,因為它把「不可動的 105 ms」先扣掉再算比例。昨天的表格讓人覺得「從 304 到 200 只差 104 ms」,聽起來像一個小改動;換算成 script 預算是「從 204 砍到 95」,砍掉 53.4%。同一件事,兩種講法,對排程的影響完全不同。

而且 105 ms 這個固定成本有一個很殘酷的性質:它是在一台特定裝置上量的。 下一節講這個。

九、更正 5:4× 是一個沒有單位的數字

昨天實踐第三步寫「DevTools Performance 面板 4× CPU 節流錄一段,展開 rAF 區塊的堆疊,可以交叉驗證」。

4× 相對於誰? Chrome 的官方部落格把這件事講得很直接:

“CPU throttling is relative to the device that you’re testing on. 4x throttling on a high-end laptop might still be a lot faster than a mid-range mobile device!”

而節流的實作機制也值得知道,因為它決定了哪些東西會被節流、哪些不會:

“When you turn on throttling for a tab, Chrome launches a separate throttling thread that interrupts and suspends the tab’s main thread for frequent short bursts.”

在 4× 下,「the main thread will end up suspended approximately 75% of the time」。它暫停的是主執行緒。 GPU 上的光柵化與合成、網路、以及 compositor thread 上的東西,不在這個機制的作用範圍內。

Chrome 134 起 DevTools 有節流校準:Performance 面板的 Environment settings → Settings → Calibrate,跑一次 benchmark,DevTools 為你這台機器生成 low-tier mobile 與 mid-tier mobile 兩個預設。官方建議是「we generally recommend the “mid-tier” preset as matching a “typical” mobile device seen on the web」。

官方文章裡有一組可以拿來算的數字:某台開發機校準出來的 mid-tier 是 3.7×,而在那個 trace 裡,一筆 67 ms 的互動被拉長成 211 ms。

這兩個數字放在一起,可以推出一件昨天完全沒提到的事。

觀察到的整體放慢倍率:211 ÷ 67 = 3.149×
設定的 CPU 節流倍率:              3.7×

整體放慢(3.15×)小於 CPU 倍率(3.7×)。 為什麼?因為互動裡有一部分不是主執行緒 CPU 工作,節流碰不到它。把它算出來:

設 C = 受節流影響的主執行緒時間,N = 不受影響的時間

未節流:C + N        = 67
節流後:3.7C + N     = 211

相減:  2.7C = 144
        C = 53.3 ms   (佔 67 ms 的 79.6%)
        N = 13.7 ms   (佔 67 ms 的 20.4%)

這筆 67 ms 的互動裡,約 80% 是主執行緒 CPU,約 20% 不是。 那 20%(13.7 ms)對應的就是六段模型裡的縫隙與繪製合成——也就是昨天說「在 M 系列 Mac 上你永遠看不到問題」的那一段,而它在節流下也看不到,因為節流也不模擬它。

必須標清楚:這是我從一篇部落格文章上的兩個數字反推的估算,不是實測。 它假設節流對主執行緒工作是精確的線性乘法、假設兩次量測之間沒有其他變數、也假設那 67 ms 與 211 ms 是同一筆互動的同一次量測。這三個假設都可能不成立。這個算式的價值不在那兩個百分比,在於它示範了一個你可以在自己網站上做的實驗:同一筆互動量兩次(節流開/關),兩個方程式兩個未知數,就能把「會被 CPU 放慢的部分」跟「不會的部分」分開。 這是我知道的、唯一不需要真實低階裝置就能估出第六段(繪製合成)相對佔比的方法。

再把昨天那筆 578 ms 反過來推一次:一個在 M 系列 Mac 上開發的人,看到的會是幾毫秒? 用 3.7× 當校準倍率,把六段裡受 CPU 影響的部分除以 3.7,不受影響的部分保留(繪製合成那 44 ms 我假設一半在 GPU):

input delay      208 ÷ 3.7 = 56.2
processing       116 ÷ 3.7 = 31.4
縫隙               6        =  6.0   (排程,不縮)
pre-layout       149 ÷ 3.7 = 40.3
style + layout    55 ÷ 3.7 = 14.9
paint + composite (22 ÷ 3.7) + 22 = 5.9 + 22 = 27.9
                              ─────
原始和                        176.7
176.7 ÷ 8 = 22.09 → 22 → 回報 176 ms

現場 576 ms(差),開發機 176 ms(良好)。 同一段程式碼,同一個使用者流程,兩個評級隔了整整兩級。這是「我在本機重現不出來」這句話的完整數學解釋。(同樣標明:這是估算,÷ 3.7 是把校準倍率當成線性縮放因子,paint 段的 50/50 拆分是我假設的,沒有依據。)

最後補一條 2026 年 9 月的新東西。**2026-09-22 發布的 DevTools 更新(涵蓋 Chrome 153 與 154)**把校準值接到了 Chrome DevTools Protocol 上:

“configuring, calculating, and dispatching calibrated CPU performance tier overrides over Chrome DevTools Protocol (Emulation.setCPUPerformanceOverride), enabling reproducible performance testing across hardware tiers.”

關鍵字是 reproducible。校準值現在可以派送,意思是 CI 機器上跑的節流可以跟你筆電上跑的節流代表同一個「mid-tier 手機」,而不是各自 4× 各自表述。這是把 INP 回歸測試放進 CI 的前提條件——昨天第四步建議「用元素數當驗收指標而不是 INP,因為樣本太少」,現在有了第二個理由的解方:樣本少的問題還在,但至少環境不一致這個更大的問題有工具了。

(順帶一提,Chrome 從 Chrome 153(2026-09-08) 起改成兩週一版。昨天引的 9 月 6 日那篇提過 CrUX 的 28 天視窗會橫跨多個里程碑——現在是橫跨兩到三個里程碑。RUM 裡記 Chrome 主版本號這件事,從「建議」變成「必須」。)

十、昨天留下的三個未解問題,各給一個答案

昨天結尾留了三個洞。逐一處理。

問題一:content-visibility: auto 的 CLS 暴增「無規格解」。

這個說法要修正成「有提案,六年沒動」。

WICG/layout-instability 除了 #89(「content-visibility: auto; makes CLS go bananas」,2021 年 1 月開)之外,還有一條更早、更根本的 #86:「Exclude elements that transition from hidden to visible」,2020-12-09 開,至今仍 open,並關聯到 Chromium 的 bug 追蹤。提案的核心論證是:

“A visible shift requires visibility in both the previous and current states. Transitioning from hidden to visible is more like inserting new content, which CLS intends to allow.”

這個論證直接命中 content-visibility: auto 的病理。 auto 的內容在視窗外時不被繪製(規格上等同於拿到 size containment 並「stops painting and hit-testing its contents」),捲進視窗時從「不可見」變成「可見」——按 #86 的判準,那根本不該算位移,因為 CLS 的設計意圖本來就允許「插入新內容」。

所以昨天那句「無規格解」不準確。準確的說法是:規格側有一條乾淨的、論證充分的提案,它從 2020 年 12 月躺到 2026 年 9 月,六年沒有進展。 這個差別在工程上是有意義的——「沒人想過」跟「想過了、有共識形狀、但沒人去寫」是兩種不同的風險,後者的實務結論是:別等。 昨天那條「上 content-visibility: auto 的同一次發版必須同時監看 CLS,而且必須給 contain-intrinsic-size」完全成立,而且今天可以加一句理由:這條提案六年沒動,不要把它排進任何一個有期限的計畫裡。

問題二:LoAF 只報 ≥ 50 ms 的幀,樣本有偏。

三件事。

第一,門檻寫死在規格裡,不可設定。 W3C 的 long-animation-frames 規格:「A long animation frame refers to any of the following occurrences whose duration exceeds 50ms」。規格本身沒有提供任何調整機制。

第二,durationThreshold 對 long-animation-frame 是無效的。 durationThreshold 是 Event Timing 的 observer 選項,Blink 只在處理 PerformanceEventTiming 時讀它;傳給 observe({ type: 'long-animation-frame', durationThreshold: 16 }) 不會報錯,但也不會有任何作用。這條的來源是社群文章不是一手文件,標存疑——但規格既然只定義了 50 ms 且 PerformanceObserverInit 的 durationThreshold 在 LoAF 規格裡沒有被引用,行為上是一致的。順帶,昨天那段 join 程式碼裡 observe({ type: 'event', durationThreshold: 16 }) 這行是對的,那個 observer 觀察的是 event,durationThreshold 在那裡有效。

第三,也是今天真正的答案:你消除不了這個偏差,但官方示範了怎麼讓它顯形。 就是第七節那條 —— 沒有 LoAF 時,totalUnattributedDuration 回傳 undefined 而不是全額。把「沒量到」跟「量到是零」在型別層面分開。 這是一個可以直接抄進你自己所有量測程式碼的設計決定,跟 INP 沒有關係。昨天那段 join 程式碼裡寫的是 if (!f) continue;(找不到就跳過),那是靜默丟棄——你的 p75 會因此變快,而你不會知道丟了多少。正確的做法是記一筆 loaf: null,讓下游能算出丟棄率。

問題三:event 與 LoAF 必須自行 join。

已回答:不必,官方做好了,v5.0.0(2025-05-07)起。而且官方版本處理了兩件手寫版沒處理的事——多個相交 LoAF(複數)、以及 pending buffer 的上限(v6.2.2,2026-09-14)。

十一、這一整套目前只有 Chromium 有

最後把三引擎的現實講清楚,因為今天整篇建立在一個 Chromium-only 的 API 上。

PerformanceScriptTiming 在 MDN 上掛著「not Baseline」與「does not work in some of the most widely-used browsers」。具體狀態:

引擎LoAF / scripts 支援標準立場
Chromium(Chrome / Edge)自 123(2024-03)起 stable,origin trial 為 116–122實作者
Firefox不支援Mozilla positive(standards-positions #929,2023-11-22 開),實作 issue 仍 open
Safari / iOS不支援WebKit standards-positions #283 仍 open,無正式回應

昨天說「iOS 流量佔四成」的那個情境,今天要補一句更難聽的:那四成流量不但沒有 INP 資料(Safari 不支援 Event Timing 的 interactionId),連 LoAF 歸因也沒有。 你今天做的所有歸因工作,樣本是「用 Chromium 的那六成使用者」。這不代表不該做——瓶頸多半是同一段程式碼——但它代表你不能宣稱「我的 INP 已經好了」,你只能宣稱「Chromium 使用者的 INP 已經好了」。

不過有一個 2026 年 9 月的新進展:LoAF 被提名為 Interop 2027 的 focus area(web-platform-tests/interop issue #1372,提交於 2026-09-05,狀態 Todo)。Interop 2027 的提案窗是 2026-09-03 到 2026-09-23(已關閉),最終名單預計 2027 年 2 月初公布。

這件事該怎麼期待,要看 Interop 的入選條件。官方對 focus area 的兩個基本要求是「testability and web standards」——要有成熟的規格、要有測試套件。LoAF 有 W3C 的規格草案,有 wpt 測試,Mozilla 立場正面。但 Interop 的實務前提是「至少兩家實作或正在實作」,而 LoAF 目前是一家實作、一家正面但沒動工、一家沒回應。 我不知道這會不會成為否決理由(Interop 的選擇過程沒有公開的硬性門檻表),標存疑。

昨天說「Interop 2026 沒有 containment,也沒有 content-visibility,你得自己在三個引擎各測一次」。今天可以把這句補完:Interop 2027 的名單要到 2027 年 2 月才知道,而就算 LoAF 入選,「入選」意味著三家在那一年會努力對齊測試通過率,不意味著年內出貨。 從 Interop 2026 的 focus area 到真正三引擎可用,歷史上要一到兩年。實務結論:未來 18 個月內,LoAF 歸因仍然是一個 Chromium-only 的診斷工具,不是一個跨瀏覽器的量測基礎。 拿它找 bug,不要拿它當 KPI 的分母。

🧠 記

  • 昨天那兩段程式碼都不必寫。 覆寫 requestAnimationFrame 的 console hack,正解是 LoAF entry 上的 scripts 陣列(PerformanceScriptTiming),Chrome 123(2024-03-19 stable) 起就有;手寫的 event↔LoAF join,web-vitals/attribution 從 v5.0.0(2025-05-07) 起就做完了。
  • 更正 1:那段 monkeypatch 會給出錯的答案,錯三處。 ①new Error().stack 第三行經 bundler 後指向 wrapper 不指向呼叫端,而且註冊 rAF 的位置與每幀耗時的位置通常不同;②前後取 performance.now() 的差把「純計算」與「強制同步佈局」混成同一個數字,剛好毀掉昨天第四節整節的論點;③它只看得到 rAF,看不到 ResizeObserver 與 IntersectionObserver ——而昨天自己列過 pre-layout 跑的是這三樣。用 JS 量 JS,只量得到你包得住的那一層。
  • 今天最重要的數字是 5,不是 149。 Chrome 文件:「Script attribution is only provided for scripts longer than 5 milliseconds」。149 ms 若來自 40 個各 3.725 ms 的回呼,scripts 陣列是空的,歸因率 0%。混合型(3×30ms + 20×2.95ms)歸因率 60.4%,而它最危險,因為看起來像成功了。
  • 「有 LoAF entry、scripts 陣列為空」是一個有明確意義的狀態,不是 bug。 一幀要過 50 ms 門檻,全由 4.9 ms 的回呼構成只需 11 個(53.9 ms)。意思是「很多小回呼」,不是「沒有 JavaScript」。 極端:一筆剛好 200 ms、評級「良好」的互動,可以含 95 ms 的 JS(20 × 4.75 ms)而 scripts 全空。(95 個 4.99 ms 片段湊出 473 ms 的那個算例是我構造的最壞情況,不是實測;真實世界「scripts 為空的 LoAF 佔比」我查不到,標存疑。)
  • 更正 2:強制同步佈局早就可歸因了。 昨天說「DevTools 只告訴你在哪觸發,不告訴你成本歸到哪一子段,三段模型裡沒有區分」——PerformanceScriptTiming.forcedStyleAndLayoutDuration 就是這個欄位,per script,2024-03 就有。判準:forcedStyleAndLayoutDuration / duration > 0.5 → 這段不是慢,是 thrashing,優化演算法沒用,要消滅讀取。配上 Lighthouse 13 的 40 ms 門檻可得四格決策表:thrashing 高而 style+layout 低 → 別去加 containment,那一步是純浪費。
  • 更正 3:昨天的 join 有兩個 bug。 ①用 find(單數),而一次互動可以橫跨多個 LoAF——208 ms 的 input delay 意味著元凶那一幀是前一幀,昨天的條件式剛好把它漏掉;官方用的是 longAnimationFrameEntries(複數,取所有 intersecting)。②ring buffer 壓 60 筆是憑感覺;官方對應處的 leak(issue #795「pendingLoAFs accumulates unboundedly during long periods without user interaction」)2026-09-14 才隨 v6.2.2 修掉。形狀教訓:LoAF 持續產生而互動偶發,任何「等待配對」的 buffer 只在配對時清就等於不清。
  • longestScript.subpart 是 RUM 設計的轉折點。 三個欄位:entry / subpart('input-delay'|'processing-duration'|'presentation-delay')/ intersectingDuration。有了它,BI 上可以 GROUP BY sourceURL, sourceFunctionName ORDER BY SUM(intersectingDuration) ——以函式為單位、按對 INP 的貢獻排序。 注意兩個時長別混:聚合用 intersectingDuration(對這筆 INP 的貢獻),排優化順序用 entry.duration(這個函式總共多慢),兩個都存。
  • 更正 4:三段恆等式有第二個誤差源,比量化大得多。 web-vitals PR #789(v6.2.1,2026-08-26):「group.processingStart is the minimum across every event presented in the frame, which can predate the interaction when a non-interaction handler was still running」→ inputDelay 可以是負數。 這不是浮點誤差,是「這一幀最早的處理時間」與「這次互動的開始時間」兩個座標系的定義衝突。後果:RUM 裡 WHERE inputDelay >= 0 這種看似無害的過濾,會系統性丟掉最慢的那一類互動。新規則:單筆的三段只能當方向指示,要對帳用四桶,因為四桶從 LoAF 實測時間戳算來,不是從餘數倒推。
  • 四桶 = totalScriptDuration / totalStyleAndLayoutDuration / totalPaintDuration / totalUnattributedDuration。 三段答「誰的問題」,四桶答「什麼類型的問題」;官方刻意不做「每段各自四桶」,理由是「overly complex to grok」。totalPaintDuration 的定義是「end of LoAF to end of INP」——用減法,不需要 Chrome 144 的 paintTime。 所以昨天「那 44 ms 在 144 之前完全看不見」要再修一次:看不見的是時間戳,區間長度一直算得出來。
  • totalUnattributedDuration 在沒有 LoAF 時回傳 undefined 而非全額 ——官方對「50 ms 樣本偏差」的答案不是消除它(規格寫死,不可設定),而是讓它在資料上顯形。對照:昨天的 join 寫 if (!f) continue; 是靜默丟棄,p75 會假性變快而你不知道丟了多少。記一筆 loaf: null,讓下游算得出丟棄率。
  • 四桶對帳斷言應該長期留在 RUM 裡。 四桶加總 vs metric.value,容差 8 ms(量化粒度)。不是為了抓 bug,是為了在你升級 web-vitals 或 Chrome 改語義時第一時間知道模型跟現實分家了。會計恆等式不是用來預測的,是用來偵測自己壞掉的。(totalScriptDuration 到底用 duration 還是相交部分,官方描述字面是前者但那會算出負的未歸因,我沒讀原始碼,標存疑,這條斷言五分鐘就能自己驗掉。)
  • 未歸因佔比的三檔判準(我定的,非官方):< 10% 歸因健康,照 longestScript 去修;10–30% 有很多小片段,先降數量;> 30% 模型失效,回 Performance 面板錄 trace。
  • 更正 5:4× 是一個沒有單位的數字。 官方原話:「CPU throttling is relative to the device that you’re testing on.」機制是「launches a separate throttling thread that interrupts and suspends the tab’s main thread」,4× 下主執行緒被暫停約 75% 的時間——它只節流主執行緒,GPU 與 compositor 不在內。 Chrome 134 起有校準,生成 low-tier / mid-tier mobile 兩個預設,官方建議用 mid-tier。
  • 從官方的兩個數字反推(估算,非實測):校準 mid-tier = 3.7×,一筆 67 ms 互動被拉成 211 ms。整體放慢只有 211 ÷ 67 = 3.149×,小於 3.7×。解 C + N = 67、3.7C + N = 211 → 2.7C = 144 → C = 53.3 ms(79.6%)、N = 13.7 ms(20.4%)。同一筆互動量兩次(節流開/關),兩個方程式兩個未知數,就能估出「繪製合成」那一段的相對佔比,不需要真實低階裝置。
  • 「我在本機重現不出來」的完整數學解(估算):把 578 ms 的六段中受 CPU 影響的部分 ÷ 3.7(paint 段假設一半在 GPU),得 56.2 + 31.4 + 6 + 40.3 + 14.9 + 27.9 = 176.7 → 176.7 ÷ 8 = 22.09 → 22 → **176 ms**。現場 576(差),開發機 176(良好),隔兩級。
  • 2026-09-22 的 DevTools 更新(Chrome 153/154)把校準值接上了 CDP Emulation.setCPUPerformanceOverride,關鍵字是 reproducible ——CI 上的節流終於能跟你筆電上的代表同一個「mid-tier 手機」。順帶:Chrome 自 153(2026-09-08) 起兩週一版,CrUX 的 28 天視窗現在橫跨兩到三個里程碑,RUM 記 Chrome 主版本號從建議變成必須。
  • 昨天的「content-visibility CLS 無規格解」要改成「有提案,六年沒動」。 WICG/layout-instability #86(2020-12-09 開,至今 open):「A visible shift requires visibility in both the previous and current states. Transitioning from hidden to visible is more like inserting new content, which CLS intends to allow.」這個論證直接命中 content-visibility: auto 的病理。 實務結論不變(同版監看 CLS + 必給 contain-intrinsic-size),但多一條理由:躺了六年的提案,不要排進任何有期限的計畫。
  • 這一整套目前只有 Chromium 有。 PerformanceScriptTiming 在 MDN 上是 not Baseline。Firefox 不支援但 Mozilla 立場 positive(standards-positions #929,2023-11-22),Safari 不支援且 WebKit #283 無正式回應。LoAF 已被提名為 Interop 2027 focus area(interop issue #1372,2026-09-05 提交,狀態 Todo);提案窗 2026-09-03 → 2026-09-23 已關閉,名單 2027-02 初公布。但目前是一家實作、一家正面沒動工、一家沒回應——能不能過我不知道,標存疑。 實務結論:未來 18 個月 LoAF 歸因是 Chromium-only 的診斷工具,拿它找 bug,不要拿它當 KPI 的分母。

✍️ 實踐

第一步(三分鐘):把昨天那段 monkeypatch 刪掉。 不是重構,是刪掉。它量到的東西是錯的,留著比沒有更糟,因為它會讓你以為自己有資料。

第二步(五分鐘):在 console 貼第二節那段 scripts dump,操作十次你最慢的互動。 先只看一個數字——歸因率(scripts 裡落在 render 階段的 duration 總和 ÷ pre-layout)。

歸因率 > 80%   → 照 sourceURL / sourceFunctionName 去修那個函式。今天就能修。
歸因率 20–80% → 混合型。修得掉大的,但剩下的不會是雜訊,是第三步的事。
歸因率 < 20%  → 分散型。你面對的是「數量」問題,不是「某個函式很慢」問題。
scripts 為空  → 同上,而且更純粹。不要因此判斷「pre-layout 不是 JS」。

這一步的唯一目的是分流。 昨天所有的建議(搬走、關掉、降頻)都預設你找得到那個函式;歸因率低於 20% 的時候,那些建議一條都不適用。

第三步(十分鐘,只在歸因率低時做):找「數量」。 分散型的解法不是優化單點,是減少同時在跑的回呼數量。三個地方依序看:

  1. 動畫庫同時在跑幾個 tween。 GSAP 的 gsap.globalTimeline.getChildren().length、Framer Motion 的進行中 animation 數。互動期間暫停非必要的那些(昨天第三節的「關掉」)。
  2. IntersectionObserver 一次回呼帶幾個 entry。 在 callback 裡 console.log(entries.length)。如果是幾十,提高 rootMargin 讓它提早且分散觸發。
  3. ResizeObserver 的 target 數。 一個 observer 觀察 200 個元素,一次尺寸變動就是 200 個 entry 進同一個回呼——這一筆通常 > 5 ms 所以看得見;真正看不見的是「200 個各觀察一個元素的 observer」。

第四步(十分鐘):把 forcedStyleAndLayoutDuration 加進第二步的表,算比值。 對每一筆 script 算 forcedStyleAndLayoutDuration / duration:

  • 有任何一筆 > 0.5 → 先修 thrashing,別碰 containment。修法是昨天第四節那條(讀一次、寫全部;或用 ResizeObserver 的 contentRect 取代 getBoundingClientRect())。修完回來重量,這一筆的 duration 應該掉,而 forcedStyleAndLayoutDuration 應該接近 0;如果只是換了一筆 script 承擔同樣的數字,你搬錯方向了。
  • 全部 ≈ 0,而 LoAF 的 style+layout 段 > 40 ms → 這才是 containment 與 DOM 大小的問題,照昨天第六步做。

第五步(半小時,一次性):換掉你的 RUM 上報。 用第五節那段 onINP 的 payload。三個非做不可的細節:

  1. 四桶與三段都存。 三段是歷史相容(你的 dashboard 現在長這樣),四桶是新的決策依據。
  2. totalUnattributedDuration 是 undefined 時,存 null 不要存 0。 這是官方刻意的型別設計,把它壓成 0 就等於把「沒量到」偽裝成「未歸因很少」。
  3. 加上四桶對帳斷言,容差 8 ms。 並且把 web-vitals 的版本號一起上報——今天整篇的行為在 v5.0.0、v6.2.1、v6.2.2 各變過一次,沒有版本號的歷史資料無法解釋。

第六步(上線前):升到 v6.2.2 以上。 理由兩個都在今天的文章裡:v6.2.1(2026-08-26)修掉負的 inputDelay,v6.2.2(2026-09-14)修掉 pendingLoAFs 的 memory leak。如果你現在跑的是 v5.x,你的 inputDelay 分布裡有一批負數,而你的 dashboard 可能已經靜默過濾掉它們一年了。 升級之後 p75 可能會變差——那不是回歸,那是你第一次看到真實值。

第七步(排進下一季,不是這週):校準 CPU 節流,並把它接進 CI。 DevTools 的 Performance 面板 → Environment settings → Settings → Calibrate,拿到你這台機器的 mid-tier mobile 倍率。記下來,寫進 repo 的文件裡。CI 那端用 CDP 的 Emulation.setCPUPerformanceOverride 派送同一個值(Chrome 153/154 起)。在那之前,團隊裡每個人講的「4× 之下是 300 ms」都是不同的 300 ms。

🔗 延伸學習

  • Long Animation Frames API | Chrome for Developers — 「Script attribution is only provided for scripts longer than 5 milliseconds」與「only for scripts running in the main thread of a page, including same-origin iframes」兩條限制的一手出處,以及把一幀拆成四段的官方公式表與 invokerType 的例子。
  • PerformanceScriptTiming — MDN — forcedStyleAndLayoutDuration、pauseDuration、executionStart、sourceCharPosition 等每個欄位的定義所在,也是「not Baseline」那條警語與三引擎支援表的出處。
  • GoogleChrome/web-vitals — INPAttribution 的四個桶、longestScript(entry / subpart / intersectingDuration)的定義,以及 CHANGELOG 裡 v6.2.1(負 inputDelay)與 v6.2.2(pendingLoAFs leak)這兩筆你需要知道的修正。
  • More accurate DevTools performance debugging using real-world data | Chrome for Developers — CPU 節流校準(Chrome 134)的一手說明,「CPU throttling is relative to the device that you’re testing on」、節流執行緒的機制、4× 下主執行緒被暫停約 75%,以及那組 3.7× / 67 ms → 211 ms 的數字。

💬 問 AI

我在做 INP 的「歸因」(不是量測、不是分段),目標是從一筆慢互動定位到具體的函式。請用 2026 年 9 月的現行 API 與文件狀態回答。每個結論標註來源類別(web.dev / Chrome for Developers / MDN / W3C 規格 / Chromium Intent / GitHub issue 或 PR / 你的推論),嚴格區分「來源明確寫了的」與「你推論的」,查不到就說查不到,不要引 SEO 內容站。
 
背景:React SPA,行動 CrUX INP p75 = 512 ms。代表性慢互動原始 duration 578 ms(回報 576),六段拆解為 inputDelay 208 / processingDuration 116 / 縫隙 6 / pre-layout 149 / 樣式佈局 55 / 繪製合成 44。iOS 流量佔四成。RUM 目前跑 web-vitals v5.x。
 
1. PerformanceLongAnimationFrameTiming 的 scripts 陣列:請給出 PerformanceScriptTiming 每一個欄位的官方定義,以及 invokerType 的完整列舉值。特別確認「只有超過 N 毫秒的 script 才會出現」這條門檻的 N 是多少、寫在哪份文件裡,以及它是規格規定還是 Chromium 實作選擇。跨源腳本、worker、跨源 iframe 分別是什麼行為?
 
2. 承上,請計算:如果 149 ms 的 pre-layout 由 40 個各 3.725 ms 的 rAF 回呼組成,scripts 陣列裡會有幾筆、總時長多少、歸因率多少?再算「一幀剛好超過 50 ms 且 scripts 為空」最少需要幾個 4.9 ms 的回呼。然後回答:看到「LoAF entry 存在但 scripts 為空」,正確的解讀是什麼?錯誤的解讀是什麼?
 
3. forcedStyleAndLayoutDuration 是 per-script 的嗎?它能不能用來判斷「把工作從 event handler 搬到 rAF 回呼之後,強制同步佈局的成本只是換了地方」?請給一段程式碼與一組判準(含比值門檻),並說明它跟 Lighthouse 13「Optimize DOM size」那個 40 ms 門檻怎麼組合成決策表。
 
4. web-vitals 的 INPAttribution:請列出 totalScriptDuration / totalStyleAndLayoutDuration / totalPaintDuration / totalUnattributedDuration 四個桶的精確定義。特別確認 totalScriptDuration 用的是 script 的完整 duration 還是與 INP 相交的部分——如果是前者,四桶加總可能超過 INP value,請算一個例子並說明官方怎麼處理。另外解釋 totalUnattributedDuration 在沒有 LoAF 時回傳 undefined(而非全額)這個設計決定的意義。
 
5. longestScript 的 subpart 與 intersectingDuration:兩者的定義是什麼?做 RUM 聚合時該用 intersectingDuration 還是 entry.duration,為什麼?請寫出一段 SQL(或 BI 查詢)示範怎麼得到「以函式為單位、按對 INP 總貢獻排序」的清單。
 
6. web-vitals PR #789 修的「negative inputDelay」:請解釋 group.processingStart 為什麼可能早於互動的 startTime,clamp 之後 processingDuration 怎麼處理才能維持三段加總等於 duration。然後回答:這對「三段會計恆等式」這個心智模型代表什麼?單筆互動的三段拆解還能不能當帳用?
 
7. LoAF 的 50 ms 門檻是規格寫死的嗎?durationThreshold 這個 PerformanceObserver 選項對 type: 'long-animation-frame' 有沒有作用(查不到就說查不到)?既然門檻消除不了,工程上有哪些方式可以讓這個樣本偏差在資料上顯形而不是被靜默吞掉?
 
8. 手寫 event↔LoAF join 常見的兩個 bug:(a) 用單數 find 而非取所有 intersecting entries,(b) pending buffer 無上限或上限憑感覺。請說明 (a) 在什麼形狀的互動上會漏掉最重要的那一幀,以及 (b) 為什麼「LoAF 持續產生而互動偶發」這個非對稱性讓所有等待配對型 observer 天生有 leak 風險。
 
9. DevTools 的 CPU 節流:4× 相對於什麼?節流的實作機制是什麼、它節流哪些執行緒、不節流哪些?Chrome 哪一版開始有校準、生成哪兩個預設?已知某機器校準 mid-tier = 3.7×,一筆 67 ms 互動變成 211 ms,請解聯立求出「受節流影響」與「不受影響」的時間各是多少,並說清楚這個估算依賴哪些假設、哪一個假設最可能不成立。
 
10. WICG/layout-instability #86 與 #89 的關係:#86 的論證能不能解掉 #89 的 content-visibility CLS 問題?兩者目前狀態如何、最近一次有實質進展是什麼時候?如果它躺了很多年,工程上的正確態度是什麼?
 
11. 最後給我最強的反方論證:主張「LoAF 腳本層歸因不值得投入」。要求涵蓋 5 ms 門檻造成的系統性盲區、Safari 與 Firefox 都沒有實作因此樣本只涵蓋部分使用者、Interop 2027 就算入選也不代表年內出貨、以及「歸因到函式」可能只是把注意力導向局部優化而忽略架構問題。請認真論證,不要寫成稻草人。