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 的改善
0576 / 540 / 512 / 498 / …512—
1540 / 512 / 498 / 470 / …49814 ms
2512 / 498 / 470 / 455 / …47042 ms
3498 / 470 / 455 / 440 / …45557 ms
5455 / 440 / 43043082 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)
p50120
p75160
p90210
p95260
p98.7335

缺陷造訪佔比 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 ms150 ms300 ms
2150 ms75 ms150 ms
3100 ms50 ms100 ms
650 ms25 ms50 ms
1030 ms15 ms30 ms
2015 ms7.5 ms15 ms
3010 ms5 ms10 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()
Chrome129+(2024-09)
Edge129+
Firefox142+
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,理由兩條:

  1. 即使使用者確實已經互動,它可能回傳 false。
  2. 該 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 繪製合成)。

四個情境:

情境inputprocessingpresentation原始和量化後回報評級
現況208116252578576差
長任務切成 50 ms 塊(期望)25116252393392差
再把非視覺工作移出 handler2530252307304需改善
input 與 processing 同時歸零(不可能的極限)00252252248需改善

驗算給你檢查: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% 懸崖讓改善難以歸因,以及「把工程資源改投到別處」的具體替代方案。請認真論證,不要寫成稻草人。