9 月 6 日那篇把 INP 的量測語義拆到底,留下一筆具體的帳:一次 576 ms 的互動,三段是 inputDelay 208 / processingDuration 116 / presentationDelay 252,而且指出「就算把 handler 砍到零也還是差」。今天要把那筆帳算完——假設你把 yielding 做到教科書等級,那 576 ms 會變成多少?
答案是 304 ms,而且這是期望值,不是保證。就算把 input delay 與 processing duration 同時歸零(物理上不可能),量化後的下限是 248 ms,依然進不了「良好 ≤ 200 ms」。也就是說,在這個頁面上,yielding 這條路的報酬上限是 272 ms,而缺口是 376 ms。剩下的 104 ms 必須從 presentation delay 挖,而那一段 scheduler.yield() 一毫秒都碰不到。
這件事之所以值得單獨寫一天,是因為「INP 慢就去切長任務」已經變成一句不需要思考的口號。它是對的,但它是一個有天花板的對策,而那個天花板可以精確算出來。更麻煩的是上一篇講的兩層聚合會反過來吃掉你的工程成果:你修好了一次互動,INP 可能一毫秒都不動。
📖 學
一、你修的不是一次互動,是一個順序統計量
先把「修好」這件事的定義弄清楚,否則後面所有計算都是白算。
上一篇的結論:單次造訪的 INP = 排序後第 floor(N ÷ 50) + 1 慢的那次互動。這句話對工程的意思是——你的優化目標不是 max,是一個順序統計量。這兩者的行為完全不同。
拿一個互動 137 次的造訪,floor(137 ÷ 50) = 2,回報第 3 慢。假設排序後的互動延遲是:
| 名次 | 延遲 (ms) |
|---|---|
| d₁ | 576 |
| d₂ | 540 |
| d₃ | 512 ← 這次造訪的 INP |
| d₄ | 498 |
| d₅ | 470 |
| d₆ | 455 |
| d₇ | 440 |
| d₈ | 430 |
現在你花了兩週,把 d₁ 那個元兇修到 90 ms。新的排序變成 540 / 512 / 498 / 470 / …,第 3 慢是 498。
改善 14 ms。 兩週的工。
再狠一點,把前三慢全部修掉:剩下 498 / 470 / 455 / 440 / 430,第 3 慢是 455,改善 57 ms。把前五慢全部修掉:剩下 455 / 440 / 430,第 3 慢是 430,改善 82 ms。
| 修掉幾筆最慢的 | 剩下的排序 | 新的第 3 慢 | 相對 512 的改善 |
|---|---|---|---|
| 0 | 576 / 540 / 512 / 498 / … | 512 | — |
| 1 | 540 / 512 / 498 / 470 / … | 498 | 14 ms |
| 2 | 512 / 498 / 470 / 455 / … | 470 | 42 ms |
| 3 | 498 / 470 / 455 / 440 / … | 455 | 57 ms |
| 5 | 455 / 440 / 430 | 430 | 82 ms |
規律很清楚:修 5 筆只賺 82 ms,因為尾巴是厚的。 每修掉一筆,INP 只往下滑一個名次,而相鄰名次之間的差距通常只有幾十毫秒。
這導出 INP 工程的第一條原則:INP 獎勵修「類別」,不獎勵修「個案」。 如果 d₁ 到 d₈ 全部來自同一個按鈕、同一個第三方腳本、同一種 DOM 操作,那修一次就等於修八次,INP 會直接掉到第九名的位置。如果它們來自八個不相干的地方,你修一年也搬不動 p75。
所以在動手之前,先做歸因的分群,不是排序。把 RUM 上的慢互動按 invokerType、sourceURL、interactionTarget(或你自己埋的 data-track)分組,看有沒有一個群佔掉尾巴的大半。沒有的話,你要修的其實是頁面的結構性成本(JS 總量、DOM 大小),不是某個 handler。
二、第二層的 25% 懸崖:p75 對「多糟」完全無感
順序統計量的問題在單次造訪內。跨造訪那層還有一個更粗暴的性質。
CrUX 取的是所有造訪的 p75。假設你的造訪分兩群:一群「健康」,一群「有缺陷」(某個條件下才觸發的慢路徑,例如冷快取、某支第三方廣告載入成功時、某種裝置)。缺陷造訪一律 700 ms,健康造訪的分布假設是:
| 健康造訪分位 | INP (ms) |
|---|---|
| p50 | 120 |
| p75 | 160 |
| p90 | 210 |
| p95 | 260 |
| p98.7 | 335 |
缺陷造訪佔比 f。整體 p75 落在哪裡?
f = 26%: 健康群只佔 74%,累積到 0.74 就用完了,0.75 這個位置落在缺陷群裡 → 整體 p75 = 700 ms。
f = 24%: 健康群佔 76%,0.75 落在健康群內,健康群內部的分位是 0.75 ÷ 0.76 = 0.9868 → 整體 p75 = 健康群的 p98.7 = 335 ms。
f = 10%: 0.75 ÷ 0.90 = 0.8333 → 健康群的 p83.3。在 p75(160)與 p90(210)之間線性內插:160 + (83.33 − 75) ÷ (90 − 75) × (210 − 160) = 160 + 0.5556 × 50 = 188 ms。
f = 0: 整體 p75 = 健康群 p75 = 160 ms。
| 缺陷佔比 f | 整體 p75 | 評級 |
|---|---|---|
| 26% | 700 ms | 差 |
| 25%(臨界) | 跳變點 | — |
| 24% | 335 ms | 需改善 |
| 10% | 188 ms | 需改善 |
| 0% | 160 ms | 良好 |
兩個結論。
第一,25% 是一道懸崖。f 從 24% 升到 26%,整體 p75 從 335 跳到 700,翻超過一倍。你的缺陷本身沒有變嚴重,只是多影響了 2% 的造訪。
第二,這條才是真正反直覺的:當 f < 25% 時,p75 對缺陷的嚴重程度完全無感。缺陷造訪是 700 ms 還是 7000 ms,整體 p75 都是 335 ms,一模一樣。因為 p75 這個位置根本落在健康群裡,它只知道「有 24% 的造訪比我慢」,不知道它們慢多少。
所以你可能遇到一種狀況:某個路徑爛到極點(7 秒),影響 20% 使用者,而你的 CrUX 儀表板上 INP 只顯示 335 ms、標「需改善」。你把那個 7 秒的路徑修成 700 ms——p75 一毫秒都不動。你必須把 f 本身降下來,或者根本不該用 p75 當唯一的 KPI。
實務建議很直接:在自己的 RUM 裡同時看 p75、p95、p99 與「> 500 ms 的造訪佔比」。最後那個數字就是 f,它才是告訴你「懸崖有多近」的量。CrUX 給不了你 f,但你自己的 RUM 可以。
三、yielding 修的是等待,不是工作:把期望值算出來
現在來算 yielding 的實際報酬。
長任務阻塞互動的機制是這樣:使用者在主執行緒跑一個長度 T 的任務時點擊,那個 click 事件必須排隊等到任務結束。如果使用者的點擊時刻在 [0, T] 上均勻分布,期望等待 = T/2,最壞 = T。
把這個任務切成 k 個等長區塊、中間 yield,使用者不管落在哪一塊,只需要等那一塊跑完:
期望 input delay = T / (2k)
最壞 input delay = T / k
T = 300 ms 代進去:
| 切成 k 塊 | 每塊長度 | 期望 input delay | 最壞 input delay |
|---|---|---|---|
| 1(不切) | 300 ms | 150 ms | 300 ms |
| 2 | 150 ms | 75 ms | 150 ms |
| 3 | 100 ms | 50 ms | 100 ms |
| 6 | 50 ms | 25 ms | 50 ms |
| 10 | 30 ms | 15 ms | 30 ms |
| 20 | 15 ms | 7.5 ms | 15 ms |
| 30 | 10 ms | 5 ms | 10 ms |
報酬遞減得很快:從 k=1 到 k=6 省掉 125 ms,從 k=6 到 k=30 只再省 20 ms。而成本是線性增加的——每次 yield 都有排程開銷。web.dev 的〈Optimize long tasks〉明確警告:工作單元很短時,yield 與恢復的總時間可能超過真正執行的時間。
官方給的折衷寫法是「用時間預算而不是固定塊數」:
async function runJobs(jobQueue, deadline = 50) {
let lastYield = performance.now();
for (const job of jobQueue) {
job();
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}deadline = 50 的意思就是把 k 自動定在 ceil(T / 50),也就是上表的第四列——期望 25 ms、最壞 50 ms。這個選擇不是隨便的:50 ms 同時是 RAIL 模型的單一任務預算、Long Tasks 的門檻、TBT 的扣除基準,也是 LoAF 判定「長動畫幀」的門檻。
每次 yield 的固定開銷 c 是多少?我查不到一手數字,已標存疑。 可以先用一個假設值做敏感度分析:若 c = 1 ms,總牆鐘時間變成 T + (k−1)c。T = 300、k = 6 → 305 ms(+1.7%);k = 30 → 329 ms(+9.7%)。所以切到 30 塊不只報酬只多 20 ms,還讓那份工作本身慢了 29 ms。這就是為什麼 50 ms 預算法勝過「切越碎越好」。
但整節最重要的一句話是:yielding 不減少總工作量,它只重新分配等待。 你的頁面該跑的 JavaScript 一行都沒少,CPU 時間甚至因為排程開銷而變多。它做的事是把「使用者必須等一個 300 ms 的任務」換成「使用者必須等一個 50 ms 的任務,而剩下的 250 ms 在他看到回饋之後才跑」。
這件事對電池與低階裝置的意義是負面的,對感知延遲的意義是巨大正面的。兩邊都要說。
四、scheduler.yield() 贏 setTimeout(0) 的地方:續段優先權
既然要 yield,用什麼 yield 有差別,而差別不在語法糖。
setTimeout(fn, 0) 把續段排到任務佇列的尾端。這代表在你 yield 的那一瞬間,任何第三方腳本、任何已經排隊的計時器,都可以插在你的續段前面。而且巢狀 setTimeout 超過五層之後會被夾到 5 ms 的最小延遲(clamping),切得越碎、被罰得越重。
scheduler.yield() 的續段進的是一個優先權被拉高的佇列,會在同優先權的其他任務之前跑。MDN 上這段輸出順序把語義講得最清楚:
scheduler.postTask(() => console.log("user-visible postTask"));
scheduler.postTask(() => console.log("user-blocking postTask"), {
priority: "user-blocking",
});
await scheduler.yield(); // 預設 user-visible
console.log("user-visible yield");輸出是:
user-blocking postTask
user-visible yield ← 排在同優先權的 postTask 之前
user-visible postTask
再往下,scheduler.yield() 還會繼承所在 postTask 的優先權與 abort 訊號。在 priority: "background" 的任務裡 yield,續段是「被拉高的 background」——高於其他 background,仍低於 user-visible。在 requestIdleCallback 回呼裡 yield,續段繼承 background(但不可中止)。搭配 TaskController.abort() 時,await scheduler.yield() 會直接以中止原因 throw,後半段永遠不會跑。這對「使用者連點兩次、第二次應該取消第一次」的場景是現成的。
支援度必須講清楚,因為它直接決定你的工程帳:
| 瀏覽器 | scheduler.yield() |
|---|---|
| Chrome | 129+(2024-09) |
| Edge | 129+ |
| Firefox | 142+ |
| Safari | 不支援 |
MDN 上的狀態是 Limited availability,明寫「not Baseline because it does not work in some of the most widely-used browsers」。所以降級寫法不是可選項:
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}這裡有一筆值得記下來的推論(標明是推論,不是官方因果證據):Safari 沒有 scheduler.yield(),而 Safari 在行動裝置上的佔比遠高於桌機。Web Almanac 2025 的數字是桌機 97% 的網站有良好 INP,行動只有 77%。這 20 個百分點的差距主要成因當然是 CPU 與網路,但「續段優先權在 iOS 上拿不到」是一個真實存在的、經常被忽略的次要成因——在 Safari 上你的 yield 只有 setTimeout 的語義,續段會被第三方插隊。我沒有找到量化這一項貢獻的一手研究,已標存疑。
五、isInputPending() 已經被官方收回
順帶記一條「2021 年的最佳實踐在 2026 年是反模式」的案例,因為還有很多中文文章在推它。
navigator.scheduling.isInputPending()(Chrome 87 起)的想法是:別盲目 yield,先問瀏覽器「有沒有待處理的輸入」,有才 yield。省下無謂的排程開銷。
web.dev 現在的立場是 no longer recommend using this API,理由兩條:
- 即使使用者確實已經互動,它可能回傳 false。
- 該 yield 的理由不只有「有輸入在等」——動畫、UI 更新這些非輸入的渲染工作同樣需要主執行緒,而
isInputPending()對它們一無所知。
它的定位已經被 scheduler.postTask() 與 scheduler.yield() 取代。如果你的程式碼裡還有 isInputPending() 的分支,那是在用一個會漏判的訊號去省一個很小的開銷,兩頭都不划算。
六、更正 1:上一篇那句「processing 砍到 0 也只會從 576 掉到 460」,算法漏了量化
上一篇在 576 ms 那一例的結尾寫:「就算把 processing 砍到 0,總數也只會從 576 掉到 460」。這句話的方向是對的,但算術有兩個問題,而且第二個問題比第一個重要得多。
問題一:量化沒有跟著重算。 上一篇是拿 576 − 116 = 460。但 576 已經是量化過的值,真實延遲是 578。正確的做法是從原始值減:
578 − 116 = 462
462 ÷ 8 = 57.75 → 取 57 → duration = 456
所以是 456 ms,不是 460 ms。差 4 ms,微不足道,但它示範了一件事:量化誤差不是加在最後,而是在每次重算時重新產生的。你不能拿量化後的數字做算術然後假裝結果還是量化後的數字。
問題二(比較嚴重):線性相減是上限估計,不是預測。 把 handler 裡 116 ms 的工作砍掉,那一幀的 LoAF 腳本區段也會跟著變短,渲染起點(renderStart)會提前,requestAnimationFrame 回呼可能因此落在不同的幀預算裡,presentation delay 未必維持 252 ms——它可能變小(渲染工作提前開始、跟其他工作的競爭減少),也可能變大(提前到達的那一幀撞上別的工作)。
三段拆解是一個事後的會計恆等式,不是一個因果模型。它告訴你「這 576 ms 花在哪」,不告訴你「改了 A 之後 B 會怎樣」。上一篇把它當成後者用了。這一條要改口:三段拆解決定你該去哪裡找問題,不決定修好之後的數字。修完要重量。
七、真正的天花板:把整本帳算完
有了前面的東西,可以把 576 ms 那一筆的完整工程帳列出來了。假設:
- inputDelay 208 ms 主要來自一個約 300 ms 的長任務的排隊等待(加上事件派送本身的固定成本)。
- processingDuration 116 ms 裡,真正產生下一幀視覺更新所必需的大約 30 ms(改 class、設一個 state),其餘 86 ms 是分析上報、狀態序列化、非視覺計算。
- presentationDelay 252 ms(上一篇拆過:6 ms 縫隙 + 149 ms rAF 回呼 + 55 ms 樣式佈局 + 44 ms 繪製合成)。
四個情境:
| 情境 | input | processing | presentation | 原始和 | 量化後回報 | 評級 |
|---|---|---|---|---|---|---|
| 現況 | 208 | 116 | 252 | 578 | 576 | 差 |
| 長任務切成 50 ms 塊(期望) | 25 | 116 | 252 | 393 | 392 | 差 |
| 再把非視覺工作移出 handler | 25 | 30 | 252 | 307 | 304 | 需改善 |
| input 與 processing 同時歸零(不可能的極限) | 0 | 0 | 252 | 252 | 248 | 需改善 |
驗算給你檢查:393 ÷ 8 = 49.125 → 49 → 392。307 ÷ 8 = 38.375 → 38 → 304。252 ÷ 8 = 31.5 → 31 → 248。
三個讀法。
第一,yielding 的報酬上限可以寫成一條式子:
Δ_max = inputDelay + (processingDuration − 視覺必要部分)
= 208 + (116 − 30) = 294 ms
這 294 ms 就是「把排程做到極致」的全部價值。它很大,但它不夠——576 − 294 = 282,量化後 280 ms,仍然不是「良好」。
第二,presentation delay 是一道硬地板。 248 ms 這個數字的意思是:在這個頁面的目前結構下,就算互動 handler 完全是空的、主執行緒完全閒置,使用者按下去到看見畫面仍然要 248 ms。scheduler.yield()、code splitting、移除第三方腳本——這些手段一項都動不到它。
第三,要進「良好 ≤ 200 ms」,presentation delay 至少得砍 52 ms,而且是在 input + processing 也壓到近乎零的前提下。 更實際的目標:presentation 砍到 140 ms、input 25 ms、processing 30 ms,原始和 195 → 量化後 192 ms。這意味著要動 rAF 回呼(149 ms 那塊,佔 presentation 的 59%)、DOM 大小、樣式複雜度。這是重構等級的工作,不是加幾個 await。
所以「INP 慢就切長任務」這句口號的正確版本是:切長任務是第一步,它能把你從「差」拉到「需改善」;從「需改善」到「良好」通常需要動渲染成本,而那是另一種工程。
八、更正 2:「把任務切到 50 ms 以下」既不充分也不必要
這一條是上面那個天花板的機制解釋,而且它推翻了一個很常見的操作性定義。
很多團隊把「Performance 面板上沒有紅色三角形(長任務)」當成 INP 的驗收標準。這個標準有一個精確的破法,而且 Long Animation Frames API 存在的理由就是它。
構造一個反例: 一幀由 8 個各 45 ms 的任務組成,總共 360 ms。
- 每個任務都 < 50 ms → Long Tasks API 一筆都不報。
- TBT = Σ max(0, 45 − 50) = 0 ms。
- LoAF 的
blockingDuration同樣是各長任務超出 50 ms 部分的總和 → 也是 0 ms。 - 但使用者如果在這 360 ms 中間點擊,期望 input delay = 360 ÷ 2 = 180 ms,最壞 360 ms。
TBT 是零,DevTools 上一個紅三角都沒有,INP 卻是災難。 上一篇說「TBT 是 INP 的替身」,這裡是替身失效的精確機制:TBT 與 Long Tasks 量的是「單一任務的長度」,而使用者感受到的是「這一幀何時結束」。
Chrome 的 LoAF 文件把 Long Tasks API 的四個缺陷列得很清楚:
| 缺陷 | 說明 |
|---|---|
| 歸因太粗 | 只告訴你任務發生在哪個容器(頂層文件或 iframe),不告訴你是哪支腳本、哪個函式 |
| 量不到渲染 | 渲染更新常常發生在獨立的任務裡,長任務量不到它 |
| 任務碎片化 | 一幀由多個 < 50 ms 的任務組成時,整體照樣阻塞渲染,但 API 一筆都不報 |
| 細節太少 | 除了時長與容器,拿不到可行動的診斷資料 |
第三項就是上面那個反例。LoAF 的定義因此換了基準:長動畫幀 = 渲染更新被延遲超過 50 ms 的那一幀,涵蓋 JavaScript 執行、樣式計算、佈局、繪製與合成的整體。門檻一樣是 50 ms,但量的東西從「任務」換成「幀」。
反過來,「不必要」的那一半:一個 80 ms 的任務如果發生在使用者絕對不會互動的時刻(例如 visibilitychange 之後的上報),它對 INP 的貢獻是零,但它會讓你的 TBT 難看。為了 Lighthouse 分數去切它,是在優化替身而不是優化本體。
實務上的替代驗收標準:不要看「有沒有長任務」,看 LoAF 的 duration 分布,以及每幀的 renderStart − startTime(腳本段)與 styleAndLayoutStart − renderStart(rAF 段)。
九、唯一能同時壓低三段的手法:把工作搬到 paint 之後
前面八節的結論聽起來很悲觀,但有一個手法確實能同時壓低 processing 與 presentation,而且它是官方明確推薦的。
web.dev 的〈Optimize INP〉寫法是:把事件回呼裡的內容限制成「產生下一幀視覺更新所必需的邏輯」,其餘一律移到 paint 之後。
button.addEventListener('click', async () => {
// 1. 只做視覺更新——決定 next paint 的那一段
panel.classList.add('is-open');
status.textContent = '處理中…';
// 2. 讓出主執行緒,讓瀏覽器有機會畫這一幀
await yieldToMain();
// 3. 剩下的全部在 paint 之後
const payload = buildPayload();
saveToIndexedDB(payload);
sendAnalytics(payload);
});官方還有一個更保險的雙層延後寫法:requestAnimationFrame() 裡再包一層 setTimeout(),確保續段落在渲染之後而不是渲染之前。
在量測上的意義必須誠實說:你沒有讓任何工作變快,你讓「被量到的那一段」變短了。 INP 量的是「使用者輸入到下一幀呈現」,量到那一幀就停。把 86 ms 的非視覺工作移到 paint 之後,那 86 ms 照樣在跑、照樣耗電、照樣可能阻塞下一次互動,但它不再計入這一筆 INP。
這算不算作弊?取決於那一幀對使用者是不是有意義的回饋。
- 點「展開」之後那一幀真的展開了 → 使用者確實更早看到結果,這是真正的改善。
- 點「送出」之後那一幀只畫了一個 spinner,真正的結果三秒後才出現 → 你把延遲從指標搬進了使用者的耐心裡。INP 會變好,體驗不會。
這正好接上 9 月 6 日那篇標存疑的 interaction-contentful-paint。從名字看,它要補的就是這個缺口:INP 只問「下一幀何時出現」,不問「那一幀有沒有內容」。這仍然是猜測,我查不到一手規格或 Chrome Platform Status 條目,繼續標存疑。 但這個缺口的存在本身,不需要那個 API 就能證明——上面 spinner 的例子就是。
給自己一條判準:每次用「移到 paint 之後」拿到 INP 改善,都要能說出那一幀讓使用者知道了什麼。 說不出來,那個改善是假的。
十、2026 年的現況帳:誰在失敗
最後放一組真實資料,因為它會改變你對「這個問題有多普遍」的判斷。
Web Almanac 2025 的 Performance 章節(資料源是 HTTP Archive 與 CrUX):
| 切面 | 良好 INP 比例 |
|---|---|
| 桌機(全體) | 97% |
| 行動(全體) | 77% |
| 桌機首頁 | 97% |
| 桌機次級頁 | 95% |
| 行動首頁 | 80% |
| 行動次級頁 | 69% |
行動裝置按網站排名切開,曲線是倒掛的:
| 行動排名分層 | 良好 INP 比例 |
|---|---|
| 前 1,000 名 | 63% |
| 前 10,000 名 | 56% |
| 前 100,000 名 | 56% |
| 前 1,000,000 名 | 64% |
| 前 10,000,000 名 | 76% |
| 全部網站 | 77% |
越大的網站,INP 越差。 前一萬名有 44% 的網站行動 INP 不及格,而「全部網站」只有 23% 不及格。這跟 LCP 的排名曲線方向相反(大站通常有更好的 CDN 與圖片管線)。
成因是推論而非資料證明,但方向很明顯:大站的 JavaScript 量、標籤管理器、A/B 測試腳本、同意管理平台(CMP)、個人化與推薦模組,全部落在主執行緒上,而且多半是第三方的——你切不動別人的長任務。這是 yielding 這條路最根本的限制:scheduler.yield() 只能加在你自己寫的程式碼裡。
另外一組數字要標清楚來源等級:網路上流傳 2026 年 5 月 CrUX 的良好 INP 比例是全體 86.6%、行動 82.5%、桌機 97.9%(母體約 1,840 萬個 origin)。這些數字我只在二手彙整與 SEO 內容站上看到,沒有從 CrUX 的一手發布頁核到,已標存疑。方向上與 Web Almanac 2025 一致(行動遠落後桌機、整體在改善),可以當參考,不要當引用。
「行動次級頁只有 69% 良好」這一條倒是很值得單獨記住。次級頁通常是搜尋結果、商品列表、表單——互動最密集的那些頁,也正好是上一篇講的 floor(N ÷ 50) 階梯最容易咬人的地方。
🧠 記
- 你優化的是順序統計量,不是最大值。 N = 137 時 INP 是第 3 慢。修掉最慢那一筆,INP 只從 512 滑到 498(14 ms);修掉前五筆才 512 → 430(82 ms)。INP 獎勵修類別,不獎勵修個案。 動手前先按
invokerType/sourceURL/ 埋的data-track分群。 - p75 有一道 25% 懸崖。 缺陷造訪佔比 f = 26% → 整體 p75 = 700 ms;f = 24% → p75 = 健康群 p98.7 = 335 ms。而且 f < 25% 時 p75 對缺陷的嚴重程度完全無感——700 ms 或 7000 ms,p75 都是 335。自己的 RUM 一定要另外看「> 500 ms 的造訪佔比」。
- yielding 的期望報酬有封閉解。 長度 T 的任務切成 k 塊 → 期望 input delay = T/(2k),最壞 = T/k。T = 300:不切 150 ms、切 6 塊 25 ms、切 30 塊 5 ms。報酬遞減極快,而每次 yield 的開銷是線性累加的。
- 用時間預算而不是固定塊數。
deadline = 50的批次迴圈等於 k = ceil(T/50),期望 25 / 最壞 50,是官方推薦的折衷。切越碎越好是錯的。 - yielding 不減少工作量,只重新分配等待。 總 CPU 時間不變甚至因排程開銷變多。對感知延遲是大贏,對電池與低階裝置是小輸。
scheduler.yield()的價值在續段優先權:續段排在同優先權任務之前,setTimeout(0)排隊尾且巢狀五層後被夾到 5 ms。它還繼承postTask的優先權與 abort 訊號。- 支援度:Chrome / Edge 129+、Firefox 142+、Safari 不支援 → MDN 標 Limited availability,不是 Baseline。 降級一定要寫。
isInputPending()官方已不推薦:可能在使用者已互動時回傳 false,而且看不到動畫與 UI 更新這類非輸入的渲染需求。- 更正 1:上一篇「processing 砍到 0 → 460 ms」有兩個錯。正確算法是從原始值減:578 − 116 = 462 → 量化 → 456 ms。更重要的是三段拆解是事後的會計恆等式,不是因果模型——線性相減只是上限估計,修完必須重量。
- 576 ms 那一筆的完整天花板:切 50 ms 塊 → 392;再把非視覺工作移出 handler → 304;input 與 processing 同時歸零的極限 → 248。yielding 的報酬上限 =
inputDelay + (processing − 視覺必要部分)= 208 + 86 = 294 ms,不夠填 376 ms 的缺口。presentation delay 是硬地板,排程手段一毫秒都碰不到。 - 更正 2:「任務切到 50 ms 以下」既不充分也不必要。8 個各 45 ms 的任務組成一幀 360 ms → Long Tasks 不報、TBT = 0、
blockingDuration= 0,但期望 input delay 是 180 ms。這正是 LoAF 存在的理由(它列的 Long Tasks 四缺陷之一就是任務碎片化)。改看 LoAF 的duration分布,不要看有沒有紅三角。 - 把工作搬到 paint 之後是唯一能同時壓低 processing 與 presentation 的手法:handler 裡只留視覺更新 → yield → 其餘。官方更保險的寫法是
requestAnimationFrame()裡再包setTimeout()。但這沒有讓工作變快,只讓被量到的那段變短。 判準:說得出那一幀讓使用者知道了什麼,才算真修好;只畫 spinner 就是把延遲搬進使用者的耐心裡。 - 2026 年現況(Web Almanac 2025):桌機 97% / 行動 77% 良好 INP;行動次級頁只有 69%。行動排名曲線倒掛:前 1,000 名 63%、前 10,000 名 56%,全部網站 77%——越大的站越差。推論成因是第三方腳本佔滿主執行緒,而
scheduler.yield()只能加在自己的程式碼裡。 - 網路流傳的 2026-05 CrUX 數字(全體 86.6% / 行動 82.5%)查無一手來源,已標存疑。
✍️ 實踐
第一步(五分鐘):量出你這一頁的 presentation delay 地板。 這是今天最有價值的一個實驗,因為它告訴你「排程手段的極限在哪」,而且沒有人會做。
在 console 貼進去,它會在頁面右下角放一個按鈕,handler 裡什麼都不做,只切一個 class:
const probe = document.createElement('button');
probe.textContent = 'INP 地板探針';
probe.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:2147483647';
probe.onclick = () => probe.classList.toggle('probe-on');
document.body.append(probe);
new PerformanceObserver((po) => {
for (const e of po.getEntries()) {
if (e.target !== probe) continue;
const input = Math.round(e.processingStart - e.startTime);
const proc = Math.round(e.processingEnd - e.processingStart);
const pres = Math.round(e.startTime + e.duration - e.processingEnd);
console.log(`地板探針 ${e.duration} ms = ${input} / ${proc} / ${pres}`);
}
}).observe({ type: 'event', durationThreshold: 16 });連按十下,取中位數。那個數字就是:在這個頁面的目前 DOM 與樣式結構下,一次「零工作量」的互動要花多久。 你所有的 yielding 優化加起來,都到不了這個值以下。
實務判讀:地板 < 50 ms → 你的問題確實在 JavaScript,切長任務會有效。地板 > 150 ms → 你在優化錯的東西,該去砍 DOM 節點數、簡化選擇器、上 content-visibility。
(注意 durationThreshold: 16 是規格允許的最小值。另外 Chrome 148 之後 e.target 更常是 null,所以這裡用探針元素自己比對,而不是靠選擇器字串。)
第二步(十分鐘):把 yieldToMain 與批次迴圈加進專案。 兩個函式,直接抄:
export function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
export async function runJobs(jobQueue, deadline = 50) {
let lastYield = performance.now();
for (const job of jobQueue) {
await job();
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}然後找一個最明顯的迴圈(渲染列表、處理一批資料、初始化一堆元件)改用 runJobs。只改一個,改完量。不要一次改十個,因為你會分不出哪一項有效——而且依更正 1,線性相減不能當預測用,只有重量才算數。
第三步(十五分鐘):把一個 handler 改成「視覺優先」。 挑你 RUM 上 processing duration 最高的那個 handler,把裡面的每一行分成兩類:會改變下一幀畫面的、不會的。第二類全部移到 await yieldToMain() 之後。
分類的判準很簡單:這一行如果不跑,使用者看得出來嗎? 看不出來就是第二類。sendAnalytics、localStorage.setItem、序列化、驗證後的伺服器同步,全部是第二類。
改完之後問自己那個誠實問題:yield 之前畫出來的那一幀,讓使用者知道了什麼? 如果答案是「一個轉圈圈」,那你只是把延遲藏起來了,要回頭重做第一類的內容。
第四步(五分鐘):在 RUM 上加一個欄位 —— > 500 ms 的造訪佔比。 這就是第二節那個 f。做法是在現有的 INP 上報後端加一個彙總:
f = count(visits where INP > 500) / count(visits with INP)
把它跟 p75 畫在同一張圖上。f 接近 25% 時,你的 p75 隨時會從三百多跳到七百多,而那個跳動跟你當週有沒有發版完全無關。沒有這個欄位,你會把懸崖誤讀成回歸。
第五步(下次錄製時):把驗收標準從「沒有長任務」換成「LoAF duration 分布」。 DevTools Performance 面板開 4× CPU 節流錄一段真實操作,看 INP breakdown insight 給的三段比例——官方的建議就是「專注在最慢的那一段」。然後去 Performance 的 LoAF 資料看幀的 duration,不要只看有沒有紅色三角形。8 個 45 ms 的任務在紅三角上是隱形的,在 LoAF 上是一個 360 ms 的長動畫幀。
🔗 延伸學習
- Optimize long tasks — web.dev — yielding 的官方全文。
setTimeout的 5 ms 夾板、scheduler.yield()的降級寫法、deadline = 50的批次迴圈範例,以及「isInputPending()我們不再推薦」那一段的兩條理由。 - Use scheduler.yield() to break up long tasks — Chrome for Developers — 續段優先權的核心說明:為什麼
setTimeout/postTask的續段會排在已排隊任務之後,而yield()的不會。附 DevTools 的前後對照截圖與scheduler-polyfill的限制(polyfill 不支援優先權繼承)。 - Long Animation Frames API — Chrome for Developers — Long Tasks API 四個缺陷的官方列表,其中「任務碎片化」那條就是本文第八節反例的依據。也是
blockingDuration、renderStart、styleAndLayoutStart與scripts陣列(只收 > 5 ms 的腳本)的一手定義來源。 - Performance — The 2025 Web Almanac (HTTP Archive) — 桌機 97% / 行動 77%、行動次級頁 69%、以及排名倒掛那張圖(前 10,000 名只有 56% 良好 INP)的原始出處。是目前少數能同時看到「規模」與「INP」交叉切面的公開資料。
- Scheduler: yield() — MDN — 支援度與 Baseline 狀態(Limited availability)的權威來源,以及優先權繼承、abort 訊號繼承、
requestIdleCallback內 yield 繼承 background 的三組可執行範例。
💬 問 AI
我在做 INP 的工程規劃(不是量測除錯),請用 2026 年 9 月的現行定義與 API 現況回答。每個結論請標註來源類別(web.dev / Chrome for Developers / MDN / W3C 規格 / HTTP Archive Web Almanac / 我的推論),並嚴格區分「來源明確寫了的」與「你推論的」。不確定就直接說不確定,不要猜,尤其不要引 SEO 內容農場。
背景:React SPA,行動 CrUX INP p75 = 512 ms。RUM 上抓到的代表性慢互動是 576 ms,三段是 inputDelay 208 / processingDuration 116 / presentationDelay 252。使用者平均每次造訪互動約 45 次。iOS 流量佔 4 成。
【第一組:優化目標的定義】
1. INP 是「第 floor(N/50)+1 慢」的順序統計量。假設 N = 137、互動延遲排序是 576/540/512/498/470/455/440/430,請算出:(a) 現在的 INP;(b) 只修掉最慢那一筆之後的 INP;(c) 修掉前三筆之後;(d) 修掉前五筆之後。並說明這對「該修個案還是該修類別」的工程決策代表什麼。
2. 跨造訪那一層是 p75。假設健康造訪的 INP 分布是 p50=120 / p75=160 / p90=210 / p95=260 / p98.7=335,缺陷造訪一律 700 ms。請算出缺陷佔比 f = 26% / 24% / 10% / 0% 時的整體 p75,並解釋為什麼 f < 25% 時 p75 對「缺陷有多嚴重」完全無感。我應該在自己的 RUM 上加哪些欄位來看到 CrUX 看不到的部分?
【第二組:yielding 的實際報酬】
3. 一個長度 T 的任務被切成 k 個等長區塊,使用者點擊時刻在任務期間均勻分布。請推導期望 input delay 與最壞 input delay 的封閉式,代 T = 300、k = 1/2/3/6/10/20/30 列表。再說明每次 yield 的固定開銷 c 會怎麼改變最佳 k,並告訴我 c 有沒有公開的實測數字(沒有就說沒有)。
4. 請解釋 scheduler.yield() 的續段優先權相對 setTimeout(0) 到底差在哪,包含:(a) 續段在佇列中的位置;(b) 巢狀 setTimeout 的 5 ms clamping;(c) 從 postTask 繼承優先權與 abort 訊號的語義。並給我一段同時處理「使用者連點兩次、第二次取消第一次」的程式碼。
5. Safari 不支援 scheduler.yield()。我有 4 成 iOS 流量。請說明降級到 setTimeout 之後我實際失去了什麼,以及這個差異有沒有被量化研究過。如果沒有,請明說「無公開量化」,不要估。
【第三組:天花板】
6. 針對 208/116/252 這一筆,假設 processing 裡只有 30 ms 是產生下一幀視覺更新所必需的。請算出:(a) 只做長任務切分(50 ms 塊,期望值)後的三段與量化後的 duration;(b) 再把非視覺工作移出 handler 後;(c) input 與 processing 同時歸零的理論極限。所有量化請用 8 ms 取整,並列出除法過程。最後給我一條「yielding 報酬上限」的通式。
7. 請說明為什麼三段拆解是事後的會計恆等式而不是因果模型——具體來說,砍掉 handler 裡 116 ms 的工作,為什麼 presentationDelay 未必維持 252 ms?機制上會朝哪兩個方向走?
8. 反例檢驗:一幀由 8 個各 45 ms 的任務組成。請算出 Long Tasks API 報幾筆、TBT 是多少、LoAF 的 blockingDuration 是多少、以及使用者在這一幀中間點擊的期望 input delay。然後用這個例子說明 LoAF 為什麼要把量測基準從「任務」換成「幀」,並列出 Chrome 文件給的 Long Tasks API 四個缺陷。
【第四組:誠實性】
9. 「把非視覺工作移到 paint 之後」會讓 INP 變好但工作量不變。請幫我建立一條判準,區分「真的改善了體驗」與「只是把延遲搬到指標看不見的地方」。spinner、樂觀 UI、骨架屏各屬於哪一類,為什麼?這個缺口跟 interaction-contentful-paint 有沒有關係?(如果你查不到 interaction-contentful-paint 的一手資料,請直接說查不到。)
【第五組:對我的意義】
10. 依上面全部的計算,給我一份排序後的行動清單,依「預期能把 512 ms 的 p75 拉到多少」排序而不是依難易度,每項標明它作用在三段的哪一段、預期改善區間、以及為什麼這個估計可能是錯的。
11. 最後,請給我最強的反方論證:主張「這整套 INP 工程投入不划算」。要求包含:Web Almanac 2025 顯示行動排名前 10,000 的網站只有 56% 良好 INP(越大的站越差)這件事對投入報酬的意涵、第三方腳本佔比的結構性限制、p75 的 25% 懸崖讓改善難以歸因,以及「把工程資源改投到別處」的具體替代方案。請認真論證,不要寫成稻草人。