上一篇把 576 ms 那筆互動的工程帳算到底,結論是「presentation delay 是一道硬地板,scheduler.yield() 一毫秒都碰不到」,絕對極限 248 ms,連「需改善」都跳不出去。今天把那道地板鑿開,結果是:那個 248 ms 是錯的,真正的絕對地板是 104 ms,而 104 ms 屬於「良好」。

錯在哪裡:presentation delay 那 254 ms 裡,有 149 ms(58.7%)是 requestAnimationFrame 回呼,也就是你自己寫的 JavaScript。它在 LoAF 的帳上被歸在「render」區段裡,於是上一篇把整塊 254 ms 當成不可動的渲染成本。實際上 presentation delay 不是一種東西,是三種東西疊在一起:你的 JS(pre-layout 回呼)、瀏覽器的樣式與佈局、瀏覽器的繪製與合成,成本歸屬與可動性完全不同。

第二個更正更直接:上一篇(與 9 月 6 日那篇)都說「LoAF 的 duration 到佈局結束為止,不含繪製與合成,那 44 ms 完全看不見」。2026-01-13 出貨的 Chrome 144 已經把它變成看得見了。 W3C 的 long-animation-frames 規格寫著 PerformanceLongAnimationFrameTiming includes PaintTimingMixin,而 Chromium 的 Intent to Ship 把 paintTime 與 presentationTime 排在 144 出貨(desktop / Android / WebView 同步)。那 44 ms 現在是 presentationTime − paintTime,可以直接讀。

代價是:同一份 Intent 明寫「This feature entry omits event timing, which would be done separately」——PerformanceEventTiming 上沒有這兩個欄位,你不能從 event entry 直接讀出繪製時間,必須自己把它跟同一幀的 LoAF entry join 起來。

📖 學

一、先修一個 2 ms 的錯,因為它示範了上一篇自己犯的錯

上一篇把 576 ms 拆成 208 / 116 / 252,又在第七節把 252 拆成「6 ms 縫隙 + 149 ms rAF 回呼 + 55 ms 樣式佈局 + 44 ms 繪製合成」。

把後面四個加起來:6 + 149 + 55 + 44 = 254,不是 252。

這 2 ms 不是筆誤,而是量化污染:

原始 duration        578 ms
量化後回報            576 ms  (578 ÷ 8 = 72.25 → 72 → 576)
inputDelay           208 ms  (實測)
processingDuration   116 ms  (實測)

上一篇的算法:576 − 208 − 116 = 252   ← 從量化後的值倒算
正確的算法:  578 − 208 − 116 = 254   ← 從原始值倒算

上一篇的「更正 1」整段就是在講這件事——不能拿量化後的數字做算術然後假裝結果還是量化後的數字——然後它在同一篇的第七節又犯了一次。這不是巧合,是一個結構性的陷阱:event entry 上只有 duration 被量化,startTime / processingStart / processingEnd 都是未量化的高精度時間戳,所以只要你的公式裡出現 startTime + duration,誤差就進來了。

實務規則:presentation delay 只能從原始值倒算,而原始值你拿不到。 你能拿到的最接近值是 startTime + duration − processingEnd,它帶著最多 7 ms 的系統性低估(量化是往下取整)。如果你要跟 LoAF 的實測值對帳,先接受這 0–7 ms 的差,不要去湊。

今天以後的計算一律用 254,四段是 6 / 149 / 55 / 44。

二、把「presentation delay」這個名字拆掉

LoAF 的官方拆解公式裡,renderStart 到 styleAndLayoutStart 之間那一段有個獨立的名字,叫 pre-layout(官方表格寫成 styleAndLayoutStart - renderStart)。

MDN 對 renderStart 的定義寫得更白:「the start time of the rendering cycle, which includes Window.requestAnimationFrame() callbacks, style and layout calculation, ResizeObserver callbacks, and IntersectionObserver callbacks」。

也就是說,pre-layout 這一段跑的是:

  • requestAnimationFrame 回呼
  • ResizeObserver 回呼
  • IntersectionObserver 回呼

這三樣全部是你(或你用的函式庫)寫的 JavaScript。 它們被排在「render」這個名字底下,純粹是因為它們發生在渲染循環裡,不是因為瀏覽器在畫東西。

所以 254 ms 的正確分類是:

段時長佔 presentation delay誰在跑排程手段碰得到嗎
事件結束到渲染開始的縫隙6 ms2.4%瀏覽器排程幾乎不可動
pre-layout(rAF / RO / IO 回呼)149 ms58.7%你的 JavaScript可以
樣式計算 + 佈局55 ms21.7%瀏覽器只能改輸入(DOM、選擇器、containment)
繪製 + 光柵化 + 合成44 ms17.3%瀏覽器 / GPU只能改輸入(圖層、繪製面積)

更正 1:上一篇那句「presentation delay 是硬地板,排程手段一毫秒都碰不到」,對 6 + 55 + 44 = 105 ms 成立,對 149 ms 那一段不成立。 真正的硬地板是 105 ms,量化後 104 ms,而 104 ms 是「良好」。

這個分類錯誤的代價不是算術上的,是決策上的:上一篇的結論是「從需改善到良好需要重構等級的工作」,而正確的結論是「先去看誰在 rAF 回呼裡跑,那可能是一個函式庫設定的問題」。你會因為一個名字把三週的工排錯順序。

三、149 ms 的 rAF 回呼:為什麼它不能用 yield 解

先講清楚為什麼這一段不能照搬上一篇的 yielding。

await scheduler.yield() 的語義是「把續段排到之後的任務」。在事件 handler 裡 yield,代價是「剩下的工作晚一點跑」,而這一幀照樣畫。在 rAF 回呼裡 yield,代價是「這一幀畫不出你要的東西」——rAF 回呼的整個存在理由就是「在這一幀的樣式與佈局之前做最後的視覺修改」。你 yield 掉,這一幀就是舊畫面,互動沒有得到回饋,INP 反而更糟。

所以 pre-layout 的手段不是 yield,是三選一:搬走、關掉、降頻。

常見的 149 ms 來自哪裡:

來源典型成本處理方式
動畫庫的每幀 tick(GSAP、Framer Motion、Lottie)跟同時在跑的動畫數量成正比互動期間暫停非必要動畫
虛擬清單的量測迴圈跟可見項目數成正比快取高度、改用 contain-intrinsic-size
IntersectionObserver 的 lazy-load 觸發跟同時交叉的元素數成正比提高 rootMargin 讓它提早、分散觸發
ResizeObserver 驅動的佈局同步每次回呼都可能連鎖改用 container queries / aspect-ratio
自己寫的「每幀同步兩個捲動容器」固定但持續改 CSS scroll-timeline 或 sticky

搬走的官方寫法就是上一篇提過的雙層延後——web.dev 的〈Optimize INP〉明寫用 requestAnimationFrame() 裡再包一層 setTimeout(),確保續段落在渲染之後:requestAnimationFrame(() => setTimeout(fn, 0))。

關掉有一個正規做法,而且跟第七節的 content-visibility 是同一套機制:contentvisibilityautostatechange 事件。MDN 的用途說明是「allows apps to start or stop rendering processes (e.g., drawing on a <canvas>) when they are not needed」。

chart.addEventListener('contentvisibilityautostatechange', (e) => {
  if (e.skipped) stopRenderLoop();   // 捲出視窗,rAF 迴圈整個停掉
  else startRenderLoop();
});

一個捲出視窗的圖表如果還在每幀重繪,它就是在吃你所有互動的 presentation delay,而在 Performance 面板上它只是一條看不出所以然的 rAF 條。

四、layout thrashing 的成本會落在你讀的地方,不是落在渲染

這一條是上面那節的必要補充,而且它是一個很常被搞錯的歸屬問題。

強制同步佈局(forced synchronous layout)的機制:你改了樣式,然後在同一個任務裡讀幾何屬性(offsetHeight、getBoundingClientRect()、scrollTop…),瀏覽器沒辦法把佈局延後,只好當場算完。web.dev 的經典反例:

// 每一圈都強迫一次完整佈局
for (let i = 0; i < paragraphs.length; i++) {
  paragraphs[i].style.width = `${box.offsetWidth}px`;
}
 
// 修法:讀一次,寫全部
const width = box.offsetWidth;
for (let i = 0; i < paragraphs.length; i++) {
  paragraphs[i].style.width = `${width}px`;
}

重點來了:這筆佈局成本會算在觸發它的那段程式碼的時間裡,不是算在渲染階段。

  • 在事件 handler 裡讀 → 算進 processingDuration。
  • 在 rAF 回呼裡讀 → 算進 pre-layout。
  • 在 ResizeObserver 回呼裡讀 → 也算進 pre-layout(而且 RO 回呼本來就跑在 layout 之後,連鎖風險最高)。

所以會出現一種很惱人的現象:你把 handler 裡的工作全部搬到 rAF 回呼,processingDuration 漂亮地掉下來,INP 一毫秒都沒動,因為那筆強制佈局只是從第二段搬到第四段。DevTools 標的紅色三角形指的是「在哪裡觸發」,不是「成本歸屬到哪個 INP 子段」——這兩件事上一篇的三段拆解模型裡完全沒有區分。

判準:搬工作之前先確認它是不是 layout-reading。 如果是,搬到哪裡都一樣貴,要先消滅讀取本身(快取、用 ResizeObserver 的 contentRect 取代 getBoundingClientRect()、或用 CSS 解掉)。

五、55 ms 的樣式與佈局:官方在 2025-10 換掉了驗收標準

這一段的好消息是,官方終於給了一個基於實測時長而不是基於靜態計數的門檻。

舊的 Lighthouse 審核叫「Avoid an excessive DOM size」,門檻是「body element has more than ~800 nodes」警告、「more than ~1,400 nodes」失敗,另外報告最大 DOM 深度與單一父節點子元素數。網路上流傳的「1,400 / 32 層 / 60 個子元素」就是從這裡來的,但後兩者從來只是報告欄位,不是判定條件。

那一頁現在掛著一句話:「This audit has moved into the Optimize DOM size insight as of Lighthouse 13.」(Lighthouse 13 於 2025-10 發布,整批把舊 audit 換成與 DevTools 一致的 insight。)新的判定條件是:

“This insight only fails if there is a large layout or style recalculation exceeding a duration of 40ms.” “A large layout update involves over 100 layout objects (which are roughly elements).” “A large style recalculation affects more than 300 elements.”

這次轉向值得講清楚。1,400 從來沒有物理意義:一個有 3,000 個 <li> 但選擇器都是單一 class 的清單,可能比一個 600 個元素卻用了十層後代選擇器加 :has() 的頁面便宜得多,靜態計數把兩者判成一樣。40 ms 有物理意義:它是上一篇那個 50 ms 預算(RAIL 的單一任務預算、Long Tasks 門檻、TBT 扣除基準、LoAF 的長動畫幀門檻)扣掉一點餘裕。

代進 200 ms 的「良好」預算看它有多貴:input delay 25(上一篇切成 50 ms 塊的期望值)+ processing 30(只留視覺必要的)+ 縫隙 6 + pre-layout 0(假設完全乾淨)+ style/layout 40(剛好踩線)+ paint/composite 44 = 原始和 145 ms → 145 ÷ 8 = 18.125 → 18 → 回報 144 ms。

還有 56 ms 餘裕,但那是在 pre-layout 為零、processing 只有 30 ms 的理想狀態下。40 ms 的樣式或佈局會吃掉「良好」預算的 20%。

樣式計算的成本,web.dev 給的最壞情況公式是「the number of elements multiplied by the selector count」,以及一條關於成本結構的關鍵事實:

“Roughly half of the time used in Blink to calculate the computed style for an element is used to match selectors, and the other half is used to construct the computed style representation.”

推論(標明是推論):只優化選擇器複雜度,報酬上限是 50%。 另一半是「建構 computed style」,跟選擇器長什麼樣無關,只跟「有幾個元素要建」有關。這正好解釋了為什麼官方門檻寫的是「影響超過 300 個元素」而不是「選擇器超過 N 條」——元素數是兩半都吃的那個變數。

同一篇給了一個實測錨點:「a long-running style recalculation taking just over 25 ms」影響「just over 900 elements」,反推 25 ÷ 900 ≈ 27.8 µs/元素。往外推:300 元素 ≈ 8.3 ms、1,400 元素 ≈ 38.9 ms、5,000 元素 ≈ 139 ms。

1,400 元素推出來離新門檻 40 ms 只差 1.1 ms。可能不是巧合——新舊兩套門檻在一台「典型裝置」上大致等價,官方只是把判準從代理量換成了本體量;但沒有官方說明支持這個因果,標明是推論。 這個係數只能當數量級:它從單一張 DevTools 截圖反推,裝置 CPU、選擇器複雜度、屬性種類都會把它改掉一個量級。這正是上一篇「更正 1」的同一條紀律:會計恆等式不是因果模型。

至於 DOM 到底有多大,Web Almanac 的資料要小心引:2025 年版沒有 Markup 章節(almanac.httparchive.org/en/2025/markup 回 404,我實際試過)。流傳的「中位數 594 個元素、p90 是 1,716」出自 2024 年版,我只從二手轉述看到,標存疑——若為真,意思是每十個網站就有一個的 DOM 超過舊 Lighthouse 的失敗門檻。

最後一條機制,它決定了下一節為什麼非做不可(web.dev 原文):「Layout is almost always scoped to the entire document.」佈局預設的作用域是整份文件——你改一個卡片的高度,瀏覽器在沒有額外資訊時必須假設整頁都可能移動。這就是為什麼 containment 不是微優化,是唯一能改變複雜度階級的手段。

六、containment:把「整份文件」縮成「這個盒子」

contain 的五個值與各自的代價:

值效果風險
layout內部佈局不外溢、外部佈局不影響內部;建立獨立格式化脈絡會改變 margin 摺疊與 float 行為
paint子孫不繪製到邊界外;離開視窗可整段跳過繪製溢出的內容被裁掉(tooltip、dropdown 會消失)
size元素尺寸不看內容不給 contain-intrinsic-size 就是 0×0,幾乎一定出事
style把 counter 與 quotes 的作用域限制在子樹不是 scoped CSS,這是最常見的誤解
inline-size單軸尺寸 containment(container queries 的基礎)同 size 但只鎖行內軸

兩個簡寫:contain: content = layout paint style(不含 size,所以安全);contain: strict = layout paint style size(含 size,危險)。

MDN 對 style 那條的措辭值得原樣記住:「Despite the name, style containment does not provide scoped styles such as you would get with the Shadow DOM or @scope.」它唯一的實際作用是讓 counter-increment / counter-set 在子樹內建立獨立計數器。以為加了 contain: style 就能隔離 CSS,等於加了一個零收益的屬性。

支援度:contain 自 2022-03 起跨瀏覽器可用(widely available),不需要降級。

實務判準:清單裡重複出現的卡片、留言、商品項,沒有跨列依賴的 <tr>,側欄 widget,固定尺寸的圖表容器,以及對話框、抽屜這類本來就自成一格的面板——這些天生適合 contain: content。反過來,任何需要溢出的元件(下拉選單、tooltip、position: sticky 的表頭)會被 paint containment 裁掉;高度由內容撐開而外部佈局依賴它的容器,會被 layout containment 建立的 BFC 改掉 margin 摺疊。

這一節最重要的一句話:containment 的風險是視覺回歸,不是效能回歸。 加錯了不會變慢,會變醜或變不見。所以驗收方式是截圖比對,不是效能量測。

七、content-visibility:那個 7 倍的數字,與它沒告訴你的事

web.dev 的一手數字要原樣引,因為它經常被誤用:「rendering times going from 232ms to 30ms on initial page load」,以及「expected reduction of 50% or more from the rendering cost of loading」。

232 → 30 是 7.7 倍,但兩件事必須先講:

第一,那是「initial page load」的 rendering time,不是 INP。 把「rendering 快了 87%」換算成「INP 會改善 87%」是錯的。它對 INP 的幫助是間接的——互動後的樣式重算與佈局,作用範圍從「整份文件」縮成「視窗附近那幾段」,於是第五節那個「受影響元素數」變小。

第二,content-visibility: auto 只對視窗外的內容省工。 如果慢互動發生在視窗內(絕大多數情況),它對那一次互動的直接貢獻接近零。單屏應用、沒有長捲軸的頁面收益接近零,而且可能因為 CLS 惡化而淨虧。

機制上(MDN 原文):auto 「turns on layout containment, style containment, and paint containment」;當元素「not relevant to the user」時額外獲得 size containment 並「stops painting and hit-testing its contents」。跟 hidden 的差別在可近性:auto 的跳過內容仍然「must still be available to user-agent features such as find-in-page, tab order navigation, and must be focusable and selectable as normal」,hidden 則完全不可近。支援度是 Baseline 2024 Newly available(2024-09,Safari 18.0 補上最後一塊)。

兩份官方文件互相矛盾的地方(存疑)

  • Chromium 的 Intent(2023-07-14 提出,Chrome 117 出貨,依 CSSWG 決議 #8407):標題就是「content-visibility: auto implies contain-intrinsic-size: auto」。Gecko 當時已 shipped,WebKit 無訊號。
  • MDN 的 content-visibility 頁(2026-09 查):仍寫「it’s recommended to use them together」,範例是 contain-intrinsic-size: auto 500px。

我抓不到 css-contain-2 §4.3 的完整正文,這條矛盾沒有定論,標存疑。 但實務結論明確:照樣自己寫 contain-intrinsic-size: auto <估計值>,兩個理由——(a) WebKit 訊號是「no signal」,隱含行為未必三引擎一致;(b) 即使 auto 關鍵字是隱含的,那個 <length> fallback 不是隱含的。auto <length> 的語義是「用記住的尺寸,沒記過就用這個長度」,從未渲染過的元素沒有記憶,只能吃你寫的長度,沒寫就是 0。

css-sizing-4(最新 W3C Working Draft 發布日 2026-09-04)對 last remembered size 的定義機制上很有意思:它是在 ResizeObserver 事件被決定與派送的時機記錄的——條件是元素有 auto 關鍵字、可以當 ResizeObserver target、而且目前沒有 size containment,此時記下它主要盒子的當前內部尺寸。跟第二節那個 pre-layout 階段是同一個時鐘。兩個推論:元素必須曾經在沒有 size containment 的狀態下被佈局過才有記憶;而 contain: strict 的元素永遠有 size containment,永遠記不住(這正是 csswg-drafts #7807)。

CLS 的副作用:接 9 月 5 日那篇的具體反例

GoogleChrome/web-vitals #108 的標題直接是「content-visibility: auto; makes CLS go bananas」,WICG/layout-instability #89 是同一件事的規格側 issue。現象:捲動時量到的位移持續累加,即使位移發生在視窗外,CLS 可以輕易破 1.0。

為什麼特別毒,要接 9 月 5 日那篇的三條來看:更正一——hadRecentInput 只是提示,而且捲動、拖曳、捏合都不算輸入,所以這些位移拿不到任何豁免;核心——CLS 取的是最壞的那個 5 秒視窗(相鄰間隔 < 1 秒、距首筆 < 5 秒),連續捲動時每一段內容進場的位移會落進同一個 session window 疊加;更正三——sources 歸因的是被推走的受害者不是兇手,你在 RUM 上只會看到「那一段位移了」,不會有欄位告訴你「因為 content-visibility 的尺寸估錯」。

實務規則:上 content-visibility: auto 的同一次發版必須同時監看 CLS,而且必須給 contain-intrinsic-size。 沒給就是拿 INP 換 CLS,而 CLS 那邊的懲罰你看不到歸因。

其他已知坑,一併記著:捲軸長度亂跳(跳過的內容被當成 0 高,解法就是 contain-intrinsic-size: auto <估計值>)、錨點連結跳到錯位置(同因,而且錨點目標本身別包在 auto 裡)、Ctrl+F 搜得到但畫面不動(各引擎在 find-in-page 的揭露路徑上有差異,三個引擎各測一次)、以及離開視窗後 canvas 仍在重繪——rAF 迴圈跟 content-visibility 沒有任何關係,要自己接 contentvisibilityautostatechange 把它停掉。

最後那一項就是第三節那 149 ms 的正規解法——也是今天唯一一個同時壓低 pre-layout、樣式佈局與繪製三段的手法。

八、44 ms 的繪製與合成:從「看不見」到「看得見」(更正 2)

上一篇與 9 月 6 日那篇的說法是:「LoAF 的 duration 到佈局結束為止,不含繪製與合成,那 44 ms 完全看不見。」前半句對,後半句在 2026-01-13 之後錯了。

規格側:W3C 的 long-animation-frames 規格裡有一行 PerformanceLongAnimationFrameTiming includes PaintTimingMixin;,而這個 mixin 同時被混進 PerformanceElementTiming、LargestContentfulPaint、PerformanceEventTiming、PerformancePaintTiming 與 LoAF。

實作側:Chromium 的 Intent to Ship「Add presentationTime/paintTime to performance entries」目標里程碑是 Chrome 144(desktop / Android / WebView 同步),Chrome 144 的 stable 發布日是 2026-01-13。Gecko 訊號 Positive,WebKit 是 in development。定義(Intent 原文):

paintTime:「the time when the rendering phase ended and the browser started the paint phase」 presentationTime:「the time when the ‘pixels reached the screen’, which is somewhat implementation-defined」

Intent 還解釋了動機:Chromium 以前暴露的是「the approximated VSync time from the compositor」而不是規格定義的 paint time,造成「confusion and incompatibility」。所以這不只是新增欄位,是修正舊欄位的語義——你的 RUM 如果有在存 LCP 的時間戳,Chrome 144 前後的數字含義可能不同。

關鍵的順序關係是 startTime + duration < paintTime < presentationTime——兩個時間戳落在 duration 之外。所以 Chrome 文件那句「duration … (not including presentation time)」仍然成立,上一篇對 duration 的描述沒錯,錯的是「完全看不見」五個字。

三個必須先講清楚的限制,否則你會算出負數。

一、presentationTime 是 optional 的(MDN:「some browsers may choose always to return 0 or not to expose the value at all」,而且 implementation-dependent),因為把畫面送顯已經交給作業系統了。跨瀏覽器比較這個數字沒有意義,只能跟自己比。 二、沒畫或沒送顯的幀,兩個時間戳都是 0;不先擋掉,presentationTime − paintTime 會變成很大的負數,安靜地污染你的 p75。

三、PerformanceEventTiming 上沒有這兩個欄位。 Chromium 的 Intent 明寫「This feature entry omits event timing, which would be done separately」,MDN 的屬性清單到 2026-09 為止仍然只有 name / entryType / startTime / duration / cancelable / interactionId / processingStart / processingEnd / target。我查不到 event timing 版本的獨立 Intent to Ship,標存疑。

實務後果:你不能從 event entry 直接讀出繪製時間,必須跟同一幀的 LoAF entry join:

const loafs = [];
new PerformanceObserver((po) => {
  loafs.push(...po.getEntries());
  if (loafs.length > 60) loafs.splice(0, loafs.length - 60);
}).observe({ type: 'long-animation-frame', buffered: true });
 
new PerformanceObserver((po) => {
  for (const e of po.getEntries()) {
    if (!e.interactionId) continue;
    const f = loafs.find((x) =>                       // 用時間區間比對,不能用元素
      x.startTime <= e.processingStart &&
      e.processingEnd <= x.startTime + x.duration);
    if (!f) continue;                                 // 找不到就別猜
    const frameEnd = f.startTime + f.duration;
    const ok = f.paintTime > 0 && f.presentationTime > 0;
    console.log(
      `INP ${e.duration}ms`,
      `pre-layout ${Math.round(f.styleAndLayoutStart - f.renderStart)}`,
      `style+layout ${Math.round(frameEnd - f.styleAndLayoutStart)}`,
      ok ? `paint→pixels ${Math.round(f.presentationTime - f.paintTime)}` : 'paint→pixels —'
    );
  }
}).observe({ type: 'event', durationThreshold: 16 });

兩個提醒:LoAF 只回報 ≥ 50 ms 的幀,快的互動 join 不到,這是有偏的樣本(只看得到慢的那些——而那恰好是你要修的);另外上一篇提過 Chrome 148 之後 e.target 更常是 null,所以 join 的鍵只能是時間區間。

九、重算 576 ms 那本帳

把六段模型代進去。量化一律 8 ms 向下取整。

情境inputprocessing縫隙pre-layoutstyle+layoutpaint+present原始和量化後評級
現況20811661495544578576差
只做上一篇的兩步253061495544309304需改善
再把 rAF 回呼壓到 4025306405544200200良好(卡邊)
再用 containment 把樣式佈局壓到 3025306403044175168良好
絕對地板(前三段全歸零)00605544105104良好

驗算:

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

三個讀法,全部跟上一篇的結論不同。

第一,上一篇的 248 ms 絕對地板是錯的,正確值是 104 ms。 差別來自一個分類錯誤:把 149 ms 的 rAF 回呼當成不可動的渲染成本。上一篇說「就算互動 handler 完全是空的、主執行緒完全閒置,仍然要 248 ms」——handler 空掉不代表 rAF 回呼空掉,而「主執行緒閒置」這個描述本身就跟「有 149 ms 的 rAF 回呼在跑」互相矛盾。這是內部不一致,不只是數字錯。

第二,進入「良好」不需要重構,需要先找到誰在 rAF 裡。 從 304 到 200 只需要把 pre-layout 從 149 壓到 40,而那多半是「互動期間暫停動畫庫」或「捲出視窗的圖表停掉 rAF 迴圈」這種設定級的改動。上一篇說「這是重構等級的工作,不是加幾個 await」——方向對,但悲觀了一格,而且把力氣指向了錯的地方(DOM 大小與樣式複雜度)。

第三,扣掉縫隙,真正不可動的瀏覽器端成本只剩 99 ms(55 + 44),而它的一半在 GPU 那端。 樣式佈局那 55 ms 可以靠 containment 往下砍;繪製與合成那 44 ms 要靠減少繪製面積、把動畫限制在 transform / opacity、必要時才用 will-change 升圖層。這一段在 M 系列 Mac 上你永遠看不到問題——上一篇引的 Web Almanac 2025「桌機 97% / 行動 77%」,有一部分就藏在這 44 ms 在中階 Android 上會變成多少。

十、什麼時候不該做這件事

今天講的三個手段每一個都有會反咬的地方。

content-visibility 只幫視窗外。 一屏就結束的應用(dashboard、編輯器、表單)收益接近零,而 CLS 的風險是真的。在這類頁面上它是淨虧。

containment 的風險是視覺回歸。 paint 會裁掉溢出,layout 會建立 BFC 改掉 margin 摺疊與 float。加之前先確認元件沒有「刻意溢出」的設計——陰影、focus ring、絕對定位的 badge 常常會超出邊界。

will-change 的官方警告(MDN 原文):「Don’t apply will-change to too many elements … Overusing the property can cause the page to slow down instead of improving its performance.」「Excessive use … will result in excessive memory use and will cause more complex rendering to occur … This will lead to worse performance.」官方建議用 JS 在互動前後開關。把 will-change: transform 寫進 stylesheet 套在 .card 上,是典型的「用記憶體換來更糟的渲染」。

Interop 2026 不會幫你。 2026 年的最終名單是 19 個 focus areas、3 個 cleanup areas、4 個 investigation areas;focus areas 裡有 View Transitions、scroll-driven animations、container style queries、scroll snap、dialog/popover、Navigation API 等,沒有 containment,也沒有 content-visibility。意思很具體:第七節那條「auto 到底有沒有隱含 contain-intrinsic-size: auto」的引擎差異在 2026 年不會被系統性測齊,你得自己在三個引擎各測一次。

順帶一提,View Transitions 進了 focus areas,而它會直接影響 presentation delay:startViewTransition() 需要凍結畫面擷取前後兩張快照,那段期間瀏覽器不繪製,使用者若剛好在凍結期間互動,那筆互動的 input delay 會很難看。我查到的說明都是二手效能部落格,沒有一手文件明說快照時間如何計入 INP,標存疑。 保守做法:回呼裡只做 DOM 更新,資料處理全部移出去。

十一、結掉一個懸案:interaction-contentful-paint

9 月 6 日那篇提過它、標了存疑,上一篇又推測「從名字看,它要補的就是 INP 只問『下一幀何時出現』、不問『那一幀有沒有內容』的缺口」。

今天再查一次:仍然查不到 W3C 規格、WICG 提案或 Chrome Platform Status 任何一手條目,搜尋只會回到 SEO 內容站在講既有的 LCP 與 INP。合理的處置是把推測收回:不該替一個查無一手實據的 API 名稱做語義推測。 那個缺口本身確實存在(上一篇的 spinner 例子不需要任何 API 就能證明),但「有一個叫這個名字的東西要來補它」,我沒有證據。

不過今天的材料給了一個可以自己做的替代:Chrome 144 的 PaintTimingMixin 同時混進了 PerformanceElementTiming。在「互動之後真正該出現的那個內容元素」上加 elementtiming 屬性,觀察 element 類型的 entry,自己算「該元素的 presentationTime − 互動的 startTime」。那個差值才是「使用者按下去到他要的內容真的出現在螢幕上」,而 INP 只量到「下一幀」。兩個限制:Element Timing 只支援圖片與文字節點(不是任意 <div>),骨架屏或純色區塊量不到;而且這不是官方指標,是我的量測方法建議,不要拿它去對任何門檻。

🧠 記

  • presentation delay 不是一種東西,是三種。 254 ms 拆開是:縫隙 6 ms(2.4%)、pre-layout 的 rAF / ResizeObserver / IntersectionObserver 回呼 149 ms(58.7%)、樣式與佈局 55 ms(21.7%)、繪製與合成 44 ms(17.3%)。第二項是你的 JavaScript,只是被排在 render 這個名字底下。
  • 更正 1:上一篇「presentation delay 是硬地板,排程手段一毫秒都碰不到」只對 6 + 55 + 44 = 105 ms 成立。 真正的絕對地板量化後是 104 ms(良好),不是上一篇說的 248 ms(需改善)。那個 248 來自把 149 ms 的 rAF 回呼誤當成渲染成本——一個名字造成的分類錯誤,會讓你把三週的工排錯順序。
  • 小更正:presentation delay 真值是 254 ms,不是 252。 252 從量化後的 576 倒算,254 從原始的 578 倒算。上一篇的「更正 1」整段就在講不能這樣算,同一篇第七節又犯了一次。只要公式裡出現 startTime + duration,就帶進 0–7 ms 的系統性低估。
  • pre-layout 不能用 scheduler.yield() 解:在 rAF 回呼裡 yield = 這一幀畫不出東西,INP 更糟。手段只有三個——搬走(requestAnimationFrame(() => setTimeout(fn, 0)))、關掉(contentvisibilityautostatechange 停掉捲出視窗的 rAF 迴圈)、降頻。
  • layout thrashing 的成本落在你讀的地方。 handler 裡讀 offsetHeight → 算進 processingDuration;rAF / ResizeObserver 回呼裡讀 → 算進 pre-layout。所以把工作從 handler 搬到 rAF,processingDuration 會漂亮地掉下來而 INP 一毫秒不動。
  • 官方在 2025-10 換掉了 DOM 的驗收標準。 舊的「> ~800 警告 / > ~1,400 失敗」已移進 Lighthouse 13 的 Optimize DOM size insight;新條件是樣式重算或佈局超過 40 ms 才 fail,「大型佈局」= 超過 100 個 layout objects,「大型樣式重算」= 影響超過 300 個元素。從靜態計數換成實測時長。 數量級錨點(存疑,單張截圖反推):25 ms / 900 元素 ≈ 27.8 µs/元素,推 1,400 元素 ≈ 38.9 ms,離新門檻只差 1.1 ms。
  • 矛盾待解(存疑):Chromium Intent(Chrome 117,依 CSSWG #8407)說 content-visibility: auto 隱含 contain-intrinsic-size: auto,MDN 至今仍叫你手寫。結論不變——照樣寫 contain-intrinsic-size: auto <估計值>:WebKit 無訊號,而且 <length> fallback 不是隱含的。last remembered size 在 ResizeObserver 的派送時機記錄,條件包含「目前沒有 size containment」→ contain: strict 的元素永遠記不住。
  • content-visibility: auto 會讓 CLS 暴增且無規格解(web-vitals #108 / layout-instability #89)。毒在三點,全部接 9/5 那篇:捲動不算輸入拿不到豁免、位移落進同一個 5 秒 session window 疊加、sources 只指受害者不指兇手。同一次發版必須同時監看 CLS。
  • 更正 2:那 44 ms 已經不是看不見了。 規格寫 PerformanceLongAnimationFrameTiming includes PaintTimingMixin;Chrome 144(stable 2026-01-13) 出貨 paintTime 與 presentationTime。順序是 startTime + duration < paintTime < presentationTime,所以 duration 仍不含它們(上一篇這句沒錯),錯的是「完全看不見」。附帶:Chromium 以前暴露的是 compositor 的近似 VSync 時間,這次連舊欄位的語義都改了。
  • 但 PerformanceEventTiming 沒有這兩個欄位(Intent 明寫「omits event timing, which would be done separately」)→ 必須把 event 與 LoAF entry 用時間區間 join。LoAF 只報 ≥ 50 ms 的幀 → 樣本有偏;沒畫/沒送顯的幀時間戳都是 0,不擋掉會算出負數污染 p75;presentationTime 是 optional 且 implementation-defined,只能跟自己比。
  • 576 ms 的新帳:現況 576 → 上一篇兩步 304 → 再壓 rAF(149→40)200,良好 → 再加 containment(55→30)168 → 絕對地板 104。從「需改善」到「良好」不需要重構,需要先找到誰在 rAF 裡跑。
  • interaction-contentful-paint 的推測收回:查無任何一手條目。替代做法:在目標內容元素加 elementtiming,自己算「該元素的 presentationTime − 互動的 startTime」。限制:只支援圖片與文字節點,而且這是方法建議不是官方指標。

✍️ 實踐

第一步(五分鐘):把六段全部印出來。 上一篇給的是「零工作量探針」量地板,今天這個是把地板本身拆開:直接用第八節那段 join 程式碼,Chrome 144 以上才有 paintToPixels。

操作十次你最慢的互動,看那幾個數字的分布。判讀只有一條:pre-layout 是不是最大的那一項? 是的話,你今天要找的是「誰在 rAF 裡」,不是 DOM 大小。(找不到對應 LoAF 就跳過,不要猜——LoAF 只報 ≥ 50 ms 的幀,快的互動本來就 join 不到。)

第二步(十分鐘):抓出誰在 rAF 裡。 在 console 覆寫 window.requestAnimationFrame,用 new Error().stack 的第三行當鍵、用 performance.now() 前後差當值,累加進一個 Map,操作一分鐘後照 ms 排序看前十名。排名第一的那一行就是你的 149 ms。通常是動畫庫的 ticker,而且通常在使用者根本沒看那個動畫的時候還在跑。(DevTools Performance 面板 4× CPU 節流錄一段,展開 rAF 區塊的堆疊,可以交叉驗證。)

第三步(十分鐘):用 DevTools 的兩個 insight 對帳。 Performance 面板的 Insights 側欄看兩項:INP breakdown(官方建議「focus on reducing the time of the slowest phase」)與 Optimize DOM size(只有樣式重算或佈局超過 40 ms 才 fail,失敗時會在火焰圖上標出那些事件)。第二項沒 fail,就別去砍 DOM。 這比舊的「元素數超過 1,400 就緊張」準得多。

第四步(十五分鐘):挑一個重複元件加 contain: content,用元素數驗收。

.card, .comment, .product-item {
  contain: content;   /* layout + paint + style,不含 size,安全 */
}

驗收不是看 INP(樣本太少,上一篇算過修一筆只動十幾毫秒),而是看「一次互動影響幾個元素」:Performance 錄一段,點開 Recalculate Style 事件看 Elements affected,前後各錄一次比那個數字,目標是掉到 300 以下(官方 insight 的「大型」門檻)。加完務必截圖比對:陰影、focus ring、絕對定位的 badge 有沒有被 paint containment 裁掉。

第五步(上線前):content-visibility 與 CLS 一起監看。

.long-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 800px;  /* 不要省,見第七節 */
}

同一次發版必須加上 CLS 觀察,而且要分開看「捲動期間發生的位移」:在 layout-shift 的 observer 裡記一個 lastScrollTime,把 performance.now() - lastScrollTime < 500 的位移標記出來,那些就是 content-visibility 最可能的嫌疑犯。注意 hadRecentInput 對捲動不生效(9 月 5 日那篇的更正一),所以它們一筆都跑不掉。上線後這類位移若明顯變多,回去把 contain-intrinsic-size 的估計值改準——那是唯一的解法,因為 sources 只會指給你受害者。

🔗 延伸學習

  • Optimize DOM size — Performance insights | Chrome for Developers — 新驗收標準的一手來源:「only fails if there is a large layout or style recalculation exceeding a duration of 40ms」,以及「大型佈局 = 超過 100 個 layout objects」「大型樣式重算 = 影響超過 300 個元素」兩條定義。
  • Long Animation Frames API | Chrome for Developers — renderStart / styleAndLayoutStart / blockingDuration 的一手定義與把一幀拆成四段的官方公式表,也是「duration … (not including presentation time)」那句話的出處。
  • Long animation frame timing — MDN — 把 paintTime 與 presentationTime 一起列進屬性表的地方(Chrome 文件還沒更新到),也是「presentationTime 是 optional、implementation-dependent」那條警語的來源。
  • content-visibility — web.dev — 「232ms → 30ms」與「50% or more」兩個數字的原始出處,以及 auto 套用哪三種 containment、contain-intrinsic-size 為什麼不能省。

💬 問 AI

我在拆 INP 的 presentation delay(不是 input delay 也不是 processing duration),請用 2026 年 9 月的現行 API 與文件狀態回答。每個結論標註來源類別(web.dev / Chrome for Developers / MDN / W3C 規格 / Chromium Intent / 我的推論),嚴格區分「來源明確寫了的」與「你推論的」,查不到就說查不到,不要引 SEO 內容站。
 
背景:React SPA,行動 CrUX INP p75 = 512 ms。代表性慢互動原始 duration 578 ms(回報 576),inputDelay 208 / processingDuration 116 / presentationDelay 254。presentation 那段的 LoAF 拆解是:縫隙 6、pre-layout(rAF / ResizeObserver / IntersectionObserver 回呼)149、樣式與佈局 55、繪製與合成 44。iOS 流量佔 4 成。
 
1. LoAF 的 renderStart 與 styleAndLayoutStart 之間那一段(官方叫 pre-layout)跑了哪些東西?為什麼它被歸在 render 區段?把這 149 ms 當成「不可動的渲染成本」錯在哪裡?為什麼不能在 requestAnimationFrame 回呼裡 await scheduler.yield()?請給我這一段真正可用的三種手段,各配一段程式碼。
 
3. Lighthouse 13 把「Avoid an excessive DOM size」換成「Optimize DOM size」insight。請給出新舊兩套判定條件的精確門檻(含數字),並論證「從靜態節點計數換成實測時長」在工程上的意義。1,400 這個舊門檻有沒有物理意義?
 
4. Blink 計算 computed style 的時間大約一半在 selector matching、一半在建構 computed style。據此推導:只優化選擇器複雜度的報酬上限是多少?為什麼官方門檻是「影響超過 300 個元素」而不是「選擇器超過 N 條」?再用「25 ms 影響約 900 個元素」反推每元素成本,列出 300 / 1,400 / 5,000 元素的推估,並說清楚這個外推在什麼條件下失效、能不能當預測用。
 
6. content-visibility: auto 的「232ms → 30ms」是哪一種量測?它對發生在視窗內的慢互動有沒有直接幫助?另外:Chromium 的 Intent(Chrome 117)說它隱含 contain-intrinsic-size: auto,MDN 至今仍叫開發者手寫——請查證 css-contain-2 正文怎麼寫,給我一個不依賴這條爭議的安全寫法,並解釋 last remembered size 在什麼時機、什麼條件下被記錄。
 
7. content-visibility: auto 會讓 CLS 暴增(web-vitals #108 / layout-instability #89)。請說明機制,並結合三件事分析為什麼特別毒:(a) 捲動不算 hadRecentInput 的輸入;(b) CLS 取最壞的 5 秒 session window;(c) layout-shift 的 sources 指的是受害者不是兇手。目前有沒有規格層的解法?
 
8. PaintTimingMixin 在 Chrome 哪一版出貨、stable 日期是什麼?它混進哪些 PerformanceEntry 介面?請特別確認 PerformanceEventTiming 有沒有拿到、有沒有獨立的 Intent to Ship(查不到就說查不到)。然後給我一段把 event entry 與同一幀 LoAF entry join 的程式碼,要處理:LoAF 只報 ≥ 50 ms 的幀所造成的樣本偏誤、時間戳為 0 的幀、以及 Chrome 148 之後 entry.target 更常是 null。順帶說明 presentationTime 是 optional 且 implementation-defined,這對「跨瀏覽器比較」與「跟自己比」分別代表什麼。
 
9. 把 208/116/6/149/55/44 重算四個情境:(a) 只做 yielding + 移出非視覺工作;(b) 再把 pre-layout 壓到 40 ms;(c) 再用 containment 把樣式佈局壓到 30 ms;(d) 前三段全部歸零的絕對地板。量化一律 8 ms 向下取整並列出除法過程。然後回答:「絕對地板是 248 ms、連良好都進不去」這個說法錯在哪裡?
 
10. 最後給我最強的反方論證:主張「presentation delay 這條路不值得投入」。要求涵蓋 content-visibility 只幫視窗外、containment 的視覺回歸風險、will-change 的反效果、presentationTime 不可跨瀏覽器比較因此難以建立 KPI、以及 Interop 2026 沒選 containment 所代表的引擎差異風險。請認真論證,不要寫成稻草人。