昨天用 size-adjust 與 ascent-override 把 fallback 字型的度量對齊 web font,把字型交換那一幀的位移壓到接近零;但「壓到接近零」這句話本身是含糊的,因為我從來沒算過那一幀到底值多少分,也沒說清楚那個分數進到 CLS 之後會被怎麼處理。昨天最後留下的洞是:CLS 的 session window 會讓字型 swap 的位移和 cookie 橫幅、懶載入圖片、第三方廣告的位移擠在同一個視窗裡互相汙染,於是你把字型修好了,分數卻沒動。今天要把這個洞補起來——不是補「怎麼修 CLS」,而是補「CLS 這個數字到底是怎麼被算出來的」。因為當量測語義本身是黑盒時,所有的優化都只是碰運氣。
📖 學
一幀位移的分數:兩個分數的乘積
Layout Instability API 的規格把每一次「rendering update」(HTML 規格裡的 update the rendering 步驟)當成一個計分單位。每一幀如果有視覺上的不穩定,就產生一個 LS(layout shift)分數:
layout shift score = impact fraction × distance fraction
impact fraction(影響比例) 是「impact region 佔視口的比例」。impact region 的定義是:該幀所有 shifting node 的「前一幀視覺表現」與「當前幀視覺表現」的幾何聯集,再與視口取交集。注意這三個動作的順序——先聯集,再裁到視口內。裁切這件事很重要,一個被推出視口外的元素,推出去的那部分不計分。
distance fraction(距離比例) 是「該幀中位移最遠的那個 shifting node,其水平或垂直移動距離(取較大者),除以視口的寬或高(取較大者)」。分母是視口的最長邊,不是位移方向上的那一邊,這點很容易搞錯。
拿 web.dev 的標準範例算一次。視口 375 × 812 CSS px(相當於 iPhone X 的邏輯尺寸),視口面積 304,500 px²,最長邊 812。一個元素佔據視口的上半:矩形 (0, 0, 375, 406),面積 152,250。下一幀它往下移動 203 px(視口高度的 25%),變成 (0, 203, 375, 609)。
- 聯集矩形 = (0, 0, 375, 609),面積 = 375 × 609 = 228,375 px²
- impact fraction = 228,375 ÷ 304,500 = 0.75
- move distance = 203 px,distance fraction = 203 ÷ 812 = 0.25
- LS score = 0.75 × 0.25 = 0.1875
一次位移就吃掉將近兩倍的「良好」門檻(0.1)。這個數字之所以嚇人,是因為 impact fraction 用的是聯集而不是重疊區:元素移動前後的位置都算進去,所以「一個佔半個螢幕的元素稍微動一下」的 impact fraction 就已經逼近 0.5,再往上加就是移動的距離。
再算一次昨天的場景,這次用實際的字型交換數字。視口 393 × 852(Pixel 7 / iPhone 15 的邏輯尺寸),面積 334,836 px²,最長邊 852。一段內文用 fallback 字型渲染時 line-height 實際解析為 24 px,12 行共 288 px;web font 載入後度量不同,行框變成 26 px,12 行變成 312 px。段落本身的 start 位置沒變(它不是 shifting node),但它下面的所有東西往下推了 24 px。假設段落下方到視口底部之間有一塊 393 × 264 的內容,原本佔 (0, 588, 393, 852),移動後變成 (0, 612, 393, 876)。
- 聯集 = (0, 588, 393, 876),但要裁到視口內 → (0, 588, 393, 852),面積 = 393 × 264 = 103,752 px²
- impact fraction = 103,752 ÷ 334,836 ≈ 0.310
- distance fraction = 24 ÷ 852 ≈ 0.0282
- LS score ≈ 0.310 × 0.0282 ≈ 0.0087
一次未經度量匹配的字型交換,在這個佈局下只值 0.0087。這就是昨天那句「壓到接近零」的實際刻度:它本來就已經很接近零了。所以如果你的 CrUX 上 CLS 是 0.28,把字型度量調好只會讓它變成 0.271。這不是 size-adjust 沒用,而是字型從來不是主要的位移來源——真正的來源是那些沒有預留高度的東西。
shifting node 的三個排除條款
規格對「什麼算位移」的定義比直覺嚴格得多。一個 shifting node 是「視覺表現的起始位置與前一幀不同」的 DOM 節點,而且原因不是 transform 變更、也不是捲動。這句話裡每個詞都有排除條款。
起始位置(start location) 指的是節點的 flow-relative 偏移。規格原文舉的例子是「在 horizontal-tb / ltr 書寫模式下就是左上角」。這正好接上 09-02 講的邏輯屬性:在 direction: rtl 下,起始角是右上角;在 writing-mode: vertical-rl 下也是右上角。所以一個在 RTL 版面裡因為右側內容變寬而「向左延伸」的區塊,它的起始角沒動,不算位移;同一份佈局翻成 LTR 就會變成位移。CLS 的計分對書寫模式是有方向性的,這件事幾乎沒人講。
視覺表現 對元素而言是它的 box fragments(CSS Fragmentation Level 4 定義的片段),對文字節點而言是它的 line boxes。這正好接上 09-03 的行框:行高改變會直接改變 line box 的幾何,而 line box 就是文字節點的計分依據。strut 高度變了 → line box 高度變了 → 後續行框的起始位置變了 → 整段以下都是 shifting node。這條鏈是昨天字型 CLS 的物理機制。
尺寸改變不算位移。 一個節點加了子元素變高,但起始偏移不變,它自己不是 shifting node;被它推下去的兄弟節點才是。這解釋了 web.dev 那個「灰盒子裡加一顆按鈕」的例子:灰盒子變高不計分,新加的按鈕(原本不在 DOM 裡)也不計分,只有被推下去的綠盒子計分。
同一幀內來回移動不算位移。 規格明確寫:一個節點在同一個動畫幀內起始位置變了兩次以上(例如 forced synchronous layout 造成的),但最後畫在跟前一幀相同的位置,它不是 shifting node。所以 layout thrashing 本身不直接製造 CLS——它製造的是主執行緒阻塞,那是 INP 的問題。
transform 變更不算位移。 這是規格給的三個理由:transform 不會重排周圍內容、transform 是流暢動畫的常見目標、transform 動畫可以在合成執行緒上硬體加速。所以 transform: translateY(24px) 的分數是 0,top: 24px 的分數是 0.0087。同一個視覺結果,一個免費一個收費。
捲動不算位移。 規格寫得很精細:要成為 shifting node,起始位置必須同時相對於文件原點、視口、以及每一個祖先捲動容器都改變了。這一條同時排除了三件事:捲動普通元素(相對視口動了,相對文件沒動)、捲動時的 position: fixed 元素(相對文件動了,相對視口沒動)、捲動 overflow: scroll 容器(相對視口和文件都動了,但相對該捲動容器沒動)。
更正 1:CLS 不是「頁面上所有位移的總和」
這是最普遍的誤解,而且它曾經是對的。2020 年 CLS 剛上線時,定義確實是「整個頁面生命週期內所有 layout shift 分數的加總」。問題是這個定義對長壽頁面極不公平:無限捲動的動態消息、開著三天的看板、每隔幾秒更新比分的體育頁,只要開得夠久,分數必然無上限成長。
2021 年 4 月 Chrome Speed Metrics 團隊(Annie Sullivan、Hongbo Song)公布了改版結論,並在 Chrome 91 落地:CLS 改為 maximum session window with 1 second gap, capped at 5 seconds。中文說法是「取所有 session window 中分數最高的那一個」。
三個常數的來由都有明確理由:
| 常數 | 值 | 規格理由 |
|---|---|---|
| session gap | 1 秒 | 初期研究選定,大規模分析驗證有效;分隔開「一叢」與「下一叢」位移 |
| session cap | 5 秒 | 視窗太短的話,「頁面變慢」或「互動回應變慢」會把位移拆進不同視窗、反而改善分數——不能獎勵變慢;視窗要有上限,否則持續小幅更新的頁面(如比分頁)會無限累積 |
| 匯總方式 | 取最大 | 曾考慮取平均,但大規模分析發現:一個只有一次微小位移的視窗會拉低平均,開發者把那個微小位移修掉,分數反而接近翻倍。取最大則保證「修任何東西都不會讓分數變差」 |
改版的效果 Google 也給了數字:沒有任何頁面的分數會變差;在 p75 上 55% 的來源(origin)完全不受影響(因為它們本來就沒有位移,或位移本來就集中在單一視窗);其餘的來源分數都會改善,其中約 3% 會從「需要改進」或「差」直接跳到「良好」。
session window 的實際演算法
規格層級只給了文字描述,真正決定業界怎麼算的是 web-vitals 這個參考實作。它的判定條件只有兩行:
若 (entry.startTime − 本視窗最後一筆.startTime < 1000)
且 (entry.startTime − 本視窗第一筆.startTime < 5000)
→ 併入本視窗,sessionValue += entry.value
否則
→ 開一個新視窗,sessionValue = entry.value
然後 onCLS 每處理完一批 entries,就檢查 sessionValue > metric.value,是的話才更新 CLS。
三個細節值得注意。第一,5 秒是從視窗第一筆的 startTime 起算,不是「滑動的 5 秒」。第二,兩個比較都是嚴格小於,所以剛好隔 1000 ms 的兩筆位移會被切開。第三,startTime 是那次 rendering update 的時間戳,不是造成位移的資源的載入時間——這對歸因很關鍵,你在 waterfall 上看到的字型下載完成時間和 layout-shift 的 startTime 之間有一段渲染延遲。
更正 2:「上限 5 秒」不代表第 5 秒後的位移被忽略
很多中文材料把 “capped at 5 seconds” 翻成「只計算前 5 秒」,這是錯的。超出 5 秒的位移不是被丟棄,而是開啟一個新視窗,而那個新視窗完全有資格成為最大視窗、成為最終的 CLS。
用一個等距的例子看得最清楚。假設頁面每 800 ms 就有一次 0.03 的位移,持續很久:
- t = 0, 800, 1600, 2400, 3200, 4000, 4800 → 全部滿足「距首筆 < 5000」(4800 < 5000),共 7 筆,視窗 1 = 7 × 0.03 = 0.21
- t = 5600 → 5600 − 0 = 5600,不小於 5000,開新視窗
- t = 5600, 6400, …, 10400 → 同樣 7 筆,視窗 2 = 0.21
- 之後每個視窗都是 0.21
最終 CLS = max(0.21, 0.21, …) = 0.21,而不是 0.03 × N。這就是「capped」的實際意思:單一視窗的長度有上限,所以分數不會隨時間無限成長,但每個視窗都在競爭最大值。
反過來說,這也暴露了一個令人不舒服的性質:同樣的位移總量,分佈得越平均,分數越低。7 次 0.03 擠在 5 秒內是 0.21;同樣 7 次分散到 7 個超過 1 秒的間隔裡,每個視窗都只有 0.03,CLS = 0.03。使用者感受幾乎一樣糟,分數差了 7 倍。規格自己在 Limitations 一節承認了這件事:「layout instability 這個指標與使用者感受到的『頁面在跳』只是不完美的相關」,並舉例說「用整個新元素重建 DOM 不會觸發 layout shift(所以看起來很跳卻分數很好)」,以及「用 left 做動畫的輪播每一幀都算位移(所以體驗流暢卻分數很差)」。
更正 3:hadRecentInput 只排除離散輸入,而且它只是「提示」
LayoutShift entry 有兩個輸入相關欄位:hadRecentInput(布林)與 lastInputTime(時間戳)。規格的原文非常明確:hadRecentInput 在「最後一次輸入發生在過去 500 ms 內」時為 true,而「它應該被視為一個提示(hint),用來在計算 DCLS 與 CLS 分數時忽略該次位移」。
兩層誤解要拆開。
第一層:排除動作不是瀏覽器做的。 瀏覽器照樣發出 entry,value 照樣是計算好的分數。是消費端(你的 RUM 腳本、web-vitals、或 Chrome 內部的 UKM 上報路徑)決定要不要跳過它。規格附的範例程式碼就是自己 if (entry.hadRecentInput) return;。所以如果你自己寫 PerformanceObserver 忘了濾,你算出來的數字會系統性地高於 CrUX。規格還特地說,想用別的門檻的開發者可以自己看 lastInputTime 決定。
第二層:什麼算「輸入」的範圍極窄。 規格原文:「由指標移動或捲動造成的事件,不算入 recent input exclusion 的『輸入』」。web.dev 的說法更具體:hadRecentInput 只會對 tap、click、keypress 這類離散輸入為 true;捲動、拖曳、捏合縮放這些連續互動一律不算。
這條規則的後果非常大。捲動觸發的位移沒有 500 ms 寬限期。 現在極流行的 scroll-triggered 進場動畫,如果是用 top / margin / height 而不是 transform / opacity 做的,每一次捲動經過都在計分,而且完全拿不到豁免。同理,懶載入的圖片如果沒有 width / height 或 aspect-ratio,使用者捲到那裡才載入、才撐開版面——這一切都在計分,而且因為捲動是連續的,這些位移常常互相在 1 秒內,擠成一個很肥的 session window。
順帶一提,「拖曳或用滑鼠調整元素大小造成的位移」是在 Chrome 93 才被明確忽略的(同一版還修了 scroll anchoring 的錯誤計分)。這類「哪些位移該被赦免」的邊界,是靠 Chromium 一版一版打補丁定義出來的,不是一次寫好的。
更正 4:CLS 不是從頁面第一幀開始算的,而且它會被歸零
web-vitals 的 onCLS 開頭有一行很少人注意的程式碼:它先呼叫 onFCP,只有在 First Contentful Paint 觸發之後才開始建立 CLS metric 並註冊 observer。原始碼註解直說原因:「這麼做是為了符合 CrUX 目前的行為」。
推論很直接:一個從未觸發 FCP 的頁面不會有 CLS 值。web.dev 的「指標與 API 的差異」一節也列了同樣的規則——如果頁面在背景載入,或在瀏覽器繪製任何內容之前就被切到背景,它不應該回報任何 CLS。CrUX 也明說背景分頁的量測不上報,因為那些時間跟使用者感受不一致。
第二個歸零時機是 bfcache 還原。web.dev 的規則是:頁面從 back/forward cache 還原時,CLS 應該重設為 0,因為使用者把這當成一次獨立的造訪。web-vitals 的 onBFCacheRestore 就是把 _sessionValue = 0、重新 initMetric('CLS', 0)、重新綁定 reporter,然後用 double-rAF 排一次上報。
這件事對數據的影響是雙向的,而且不小。CrUX 把 bfcache 還原當成獨立的頁面造訪計入資料集,而 bfcache 還原是近乎瞬時的、字型與圖片都已在記憶體裡、通常 CLS 極低。Chrome 的使用數據是:桌機約 1/10、行動裝置約 1/5 的導航是上一頁或下一頁。也就是說,如果你的 RUM 沒有處理 pageshow 的 persisted,你就漏掉了資料集裡大約一到兩成、而且是最漂亮的那批樣本,你的 p75 會系統性地比 CrUX 差。反過來,如果你的站因為掛了 unload 監聽器或對主文件設了 Cache-Control: no-store 而拿不到 bfcache,你就真的失去了這批好樣本——不是量測問題,是體驗問題。
順帶記一個 2026 的新事實:Chrome 從 149 起(與 Safari 一致)不再因為開著 WebSocket 就阻擋 bfcache,其他瀏覽器仍會擋。IndexedDB 連線、進行中的 fetch() / XHR 這些仍是常見的阻擋原因,建議在 pagehide / freeze 時關掉、在 pageshow / resume 時重開。
soft navigation:2026 年最大的語義變更
到 2026 年 9 月為止,CLS 語義上真正的新東西是 soft navigation。單頁應用的「換頁」瀏覽器看不見,所以 CLS 一直是整個 tab 生命週期累積的——使用者在 SPA 裡逛了 12 個「頁面」,那 12 頁的位移全部競爭同一個 max session window,而且全部掛在第一次硬導航的 URL 上。這是 CrUX 與各家 RUM 數字對不起來的主要來源之一。
Chrome / Edge 151 起,soft navigation 量測預設開啟(先前經歷了數輪 origin trial,最後一輪從 Chrome 147 開始;正式進 stable 的確切月份我沒有從一手文件確認到,已標存疑)。Chrome 團隊給的 soft navigation 定義有三個必要條件:
- 該導航由使用者動作發起
- 該導航造成使用者可見的 URL 變更
- 該互動造成一次可見的繪製
API 面上的變化,對 CLS 最關鍵的是:layout-shift entry 現在帶有 navigationId(同批加上的還有 first-paint、first-contentful-paint、largest-contentful-paint、interaction-contentful-paint、first-input-delay、event)。配合 soft-navigation entry(帶 navigationId、name 是新 URL、interactionId 是觸發的互動),你就能把時間軸「切片」,每一段各自算自己的 max session window。官方建議的做法是:收到 soft-navigation entry 時,先把前一段的指標定案上報,然後 CLS 與 INP 都重新初始化為 0,跟頁面載入時一樣。
但歸屬的邊界條件很髒,官方文件自己列了三種:
- 位移發生在觸發導航的互動後 500 ms 內 → 可能被
hadRecentInput排除掉 - 位移發生在 URL 更新之前 → 歸給前一次導航
- 位移發生在 soft navigation 定案之後 → 歸給新的導航
換句話說,SPA 換頁時那個最典型的「新內容撐開版面」位移,會落在哪一格取決於你的框架是先改 URL 還是先繪製。同一個使用者感受,三種歸因結果。
還有一個實務上的坑:getEntriesByType 對 soft-navigation 只能拿到前 50 筆 buffered entries,長壽的 SPA 一定要用 PerformanceObserver 持續監聽。以及 TTFB 在 soft navigation 一律回報 0(與 bfcache 還原的建議一致)。web-vitals v6.0.0 起支援,用 {reportSoftNavs: true} 開啟,而且會同時給你 navigationId 與 navigationURL。
最後一個現實面:CrUX 要怎麼呈現 soft navigation,官方到現在仍然是「尚未決定,有消息會宣布」。所以 2026 年 9 月的狀態是:API 有了、web-vitals 支援了、DevTools Performance 面板的 Live Metrics 與 trace Insights 也支援了,但它還沒有變成 Core Web Vitals 的官方計分方式。Chrome 團隊建議的過渡策略是「兩套都量」:傳統的整頁生命週期一套,按 soft navigation 切片一套,兩份都 beacon 回去,好跟歷史資料與其他瀏覽器比較。
實驗室與現場為何系統性不一致(而不是隨機不一致)
這不是誤差,是定義差異。列出來會發現每一條都朝同一個方向偏。
第一,量測窗口不同。 Lighthouse 只量到載入結束;CLS 的定義是整個頁面生命週期。web.dev 對此有明確警語:實驗室工具只能量到載入期間的位移,因此實驗室報的 CLS 可能低於真實使用者經歷的值。Lighthouse 給 0、CrUX 給 0.3,兩者可以同時是對的。
第二,Lighthouse 不捲動也不互動。 它耐心等頁面載完,不會捲到懶載入圖片那一段、不會點開手風琴、不會觸發 scroll-triggered 動畫。而這些恰好是捲動位移——沒有 500 ms 豁免的那一類。
第三,個人化內容。 實驗室載入的通常是沒有個人化內容、或是通用測試使用者的版本。廣告、A/B 測試變體、推薦模組這些「晚到並插進主內容」的東西,正是位移大戶。
第四,快取狀態方向相反。 實驗室通常冷快取;現場有大量回訪使用者,字型與圖片已在快取裡、可以立刻繪製,位移反而更少。加上 bfcache 還原被 CrUX 計為獨立造訪,現場資料裡有一批 CLS 近乎 0 的樣本是實驗室永遠模擬不出來的。
第五,iframe。 這是最反直覺的一條。Layout Instability API 不會把 iframe 裡的位移回報給父框架,連同源 iframe 也不行;但 CLS 這個指標會,因為那是使用者體驗的一部分。CrUX 是由瀏覽器本體量測的,沒有這個限制。規格給的聚合方式是:iframe 內的 LS 分數,按該 iframe 在位移發生當下佔頂層視口的比例加權後,加進頂層的 CLS。
算一次:視口 393 × 852,一個 393 × 220 的廣告 iframe。權重 = (393 × 220) ÷ (393 × 852) = 220 ÷ 852 ≈ 0.258。iframe 內部量到一次 0.4 的位移(廣告換尺寸,在 iframe 自己的小視口裡佔比很大),貢獻到頂層 = 0.4 × 0.258 ≈ 0.103。一次就吃掉整個「良好」預算。而你的 JS RUM——包括 web-vitals 本身——看不到這 0.103。跨來源 iframe 更是完全沒辦法。這是 CrUX 比自家 RUM 高的最常見單一原因。
第六,聚合口徑。 CrUX 是 28 天滑動視窗、p75、只有 Chrome、只有選擇加入分享的使用者、不含 iOS 版 Chrome(WebKit 引擎)、不含 Android WebView(但含 Chrome Custom Tabs)。RUM 通常預設給中位數或別的期間。比較之前先把 RUM 濾成「Chrome + 指定裝置類型 + 28 天 + p75」,不然比的是兩件事。
第七,CLS 根本不是跨瀏覽器指標。 到 2025 年 12 月為止,web.dev 明說 CLS 只在 Chromium 系瀏覽器可用。LCP 與 INP 已經在 Safari 與 Firefox 有(雖然實作細節不同),CLS 沒有。所以你的 RUM 如果混了 Safari 流量,那批使用者在 CLS 這欄根本是空的。
第八,Lighthouse 的分數權重是另一套東西。 Lighthouse 10 之後,CLS 在 Performance 總分裡佔 25%(Lighthouse 8 時只佔 15%,LCP 25%、TBT 30%、FCP 10%、Speed Index 10%)。而且各指標是用對數常態曲線映射到 0–100 分,兩個控制點取自 HTTP Archive 真實資料:第 25 百分位對應 50 分,第 8 百分位對應 90 分。這代表 Lighthouse 的「CLS 分數」與 CrUX 的「CLS 通過與否」是兩套完全不同的判準,不要混談。
門檻與百分位:為什麼是 0.1 / 0.25 / p75
到 2026 年 9 月,Core Web Vitals 的門檻沒有變動:
| 指標 | 良好 | 差 | 百分位 |
|---|---|---|---|
| Largest Contentful Paint | ≤ 2500 ms | > 4000 ms | 75 |
| Interaction to Next Paint | ≤ 200 ms | > 500 ms | 75 |
| Cumulative Layout Shift | ≤ 0.1 | > 0.25 | 75 |
CLS 的門檻來由跟 LCP / INP 不一樣。LCP 與 INP 都有數十年的 HCI 研究可以引(Card、Miller、Newell、Michotte 的因果知覺實驗、Kaaresoja 的虛擬按鍵研究),CLS 是全新指標,沒有既有研究可以直接對應。所以 Chrome 團隊的做法是拿真實頁面做內部主觀評測,結論是:0.15 以上一致被感受為干擾,0.1 以下「看得出來但不至於過度干擾」。
可達成性這一關是用 CrUX 資料驗的(2020 年 4 月的數字):
| CLS 候選門檻 | 0.05 | 0.1 | 0.15 |
|---|---|---|---|
| 手機端 origin 判為「良好」比例 | 49% | 60% | 69% |
| 桌機端 | 42% | 59% | 69% |
判定「良好」門檻的規則是至少 10% 的 origin 要能達到。0.05 其實有 49% 達成,完全過關。Google 明說他們知道 0.05 是合理的,但選了 0.1,理由是第三方嵌入內容(社群嵌入之類)在載完之前高度未知,難以避免超過 0.05 的位移;文件裡甚至寫了一句期待:希望生態系日後解決第三方嵌入的位移問題,好讓未來版本的 Core Web Vitals 能用更嚴格的 0.05 甚至 0。
「差」門檻的規則是把最差的 10–30% origin 劃進去。0.25 對應手機約 20%、桌機約 18%,落在區間內。
p75 的選擇則是兩個相衝目標的折衷:百分位要夠高才能保證「多數造訪達到目標水準」,但太高就容易被離群值主導。文件給的算術很直白:一個有 100 次造訪的網站,用 p95 只要 5 個離群樣本就能污染結果;用 p75 需要 25 個。
還有一條容易忽略的判定規則:沒有互動的頁面不需要 INP 通過,只要 LCP 與 CLS 通過就算通過。
2026 年的兩個實作變更(Chrome 145)
cls.md 這份 Chromium 官方變更日誌從 Chrome 79 開始記錄。2026 年的條目在 Chrome 145(2026 年 2 月進 stable),兩項都跟歸因有關,而且都不改變 CLS 分數本身:
一、attribution 矩形改用 CSS 像素。 Chrome 145 之前,LayoutShift entry 裡 LayoutShiftAttribution 的 previousRect / currentRect 是用物理像素回報的,數值隨裝置的 devicePixelRatio 變動。145 起改為 CSS 像素,與 getBoundingClientRect() 等平台 API 一致。規格變更是 WICG/layout-instability#125。
實務衝擊:任何把位移方框疊在截圖上做視覺化的工具、任何跨裝置合併位移資料的 RUM,如果原本假設是物理像素,在 145 之後會偏掉一個 DPR 倍數。在 DPR = 3 的手機上,這是三倍的誤差。
二、sources 陣列改為按影響面積降冪排序。 145 之前,LayoutShift.sources(最多 5 個歸因來源)的順序是任意的,診斷工具得自己算面積找出主兇。145 起保證 entry.sources[0] 永遠是影響面積最大的元素。規格變更是 WICG/layout-instability#126。
順帶把 sources 的既有規則記清楚,因為它決定了你能不能找到真兇:
- 最多 5 個歸因,上限的理由是大型 DOM 一幀可能有大量節點同時位移,全部回報既不可行也難用
- 若兩個節點都位移且一個在視覺上完全包含另一個,只歸因較大的那個(所以容器位移不會把所有後代都列出來)
- 去重之後若仍超過 5 個,按對 impact region 的貢獻面積排序取前 5
- 歸因的是「被推走的元素」,不是「推它的元素」。 規格在 Caveat: Causality 一節直接承認:如果一個新插入的元素把下方內容推走,
sources只會報那些被推走的元素,不會報那個插入者。瀏覽器沒辦法做這種層級的根因分析
這第四點是實務上最痛的:DevTools 圈給你看的是受害者,不是兇手。你得自己往上一個 DOM 順序找。
回到字型:session window 為什麼讓你的修正看不出效果
把上面所有東西串起來,昨天留下的那個洞就有答案了。設一個真實的載入時間軸:
| 時間 | 事件 | LS 分數 |
|---|---|---|
| 1200 ms | web font 交換(未做度量匹配) | 0.0087 |
| 1450 ms | 無尺寸的 hero 圖載入完成 | 0.0900 |
| 2100 ms | cookie 同意橫幅插入到頂端 | 0.1200 |
| 2900 ms | 第三方廣告位重新調整尺寸 | 0.0500 |
| 4200 ms | 捲動觸發的懶載入模組撐開 | 0.0300 |
套用視窗規則:1200 → 1450 相隔 250 ms、1450 → 2100 相隔 650 ms、2100 → 2900 相隔 800 ms,都小於 1000,而且 2900 − 1200 = 1700 < 5000,所以前四筆同屬一個視窗。2900 → 4200 相隔 1300 ms,超過 1 秒間隔 → 開新視窗。
- 視窗 1 = 0.0087 + 0.09 + 0.12 + 0.05 = 0.2687
- 視窗 2 = 0.03
- CLS = 0.2687(「差」,因為 > 0.25)
現在只修字型度量:視窗 1 = 0.26。還是「差」。你花了一整天調 size-adjust 與 ascent-override,CrUX 上的 p75 幾乎不動。
改成只修 cookie 橫幅(給它固定高度、或改用不佔流的覆蓋層):視窗 1 = 0.0087 + 0.09 + 0.05 = 0.1487,跳到「需要改進」。同樣一天的工,效果差了一個等級。
這就是 max session window 的實際含義:CLS 是一個「最壞的 5 秒」指標,不是平均指標。優化的正確順序不是「從最容易修的開始」,而是「先找出最大的那個視窗,然後在那個視窗裡按分數排序」。這也是為什麼 entry.sources[0] 在 Chrome 145 之後被保證排序,以及為什麼 web-vitals 的 callback 會把 metric.entries 設為構成最大視窗的那批 entries——它交給你的不是全部位移,是你該修的那批。
還有一個更陰險的推論:因為 1 秒間隔會切開視窗,讓某個位移「晚一點發生」有時候就能改善分數,即使使用者體驗更差(多看一秒的破版)。Chrome 團隊在選 5 秒上限時明確說「不能獎勵變慢」,但 1 秒間隔這條規則本身就內建了這個漏洞。規格自己的 Precision, Variance, and Evolution 一節寫得很坦白:「我們預期開發者把這個分數當成訊號,而不是依賴它的精確數值到『輕微偏差就會影響頁面正確性』的程度」,而且「不應該因為規格已經寫出來就認為這個指標的定義被凍結了」。
🧠 記
- LS score = impact fraction × distance fraction。impact 是「前後幀視覺表現的聯集、裁到視口內、除以視口面積」;distance 是「最遠位移節點的水平或垂直位移(取大)除以視口最長邊」。範例:半視口元素下移 25% 視口高 → 0.75 × 0.25 = 0.1875。
- CLS = 所有 session window 中分數最大的那一個,不是總和。視窗規則:相鄰位移間隔 < 1 秒、距視窗首筆 < 5 秒,否則開新視窗。Chrome 91 起生效。
- 5 秒上限不是截止線,超過就開新視窗,新視窗一樣有資格當最大值。所以分散在時間軸上的位移比擠在一起的分數低,即使總量相同。
hadRecentInput只是「提示」,排除動作要你自己做;而且只對 tap / click / keypress 這類離散輸入為 true,捲動、拖曳、捏合縮放不算輸入,沒有 500 ms 豁免。- shifting node = 起始位置(flow-relative offset)改變。尺寸改變不算、新插入的元素不算、transform 變更不算、捲動不算、同幀內來回但最終回到原位不算。文字節點的視覺表現是 line box——這就是行高變化製造 CLS 的機制。
- CLS 在 FCP 之前不開始算(
web-vitals明說是為了對齊 CrUX),bfcache 還原時歸零,背景載入的頁面不回報。桌機約 1/10、行動約 1/5 的導航是上下頁,不處理pageshow.persisted就會漏掉最漂亮的那批樣本。 - iframe 內的位移:API 不報,指標要算。CrUX 按「iframe 佔頂層視口的面積比例」加權後計入。393×220 的廣告在 393×852 視口 → 權重 0.258;iframe 內 0.4 的位移貢獻 0.103 到頂層。你的 JS RUM 看不到這個數字。
- 門檻 2026 年 9 月未變:良好 ≤ 0.1,差 > 0.25,取 p75。0.05 其實可達成(49% origin),選 0.1 是為了遷就第三方嵌入;官方寫明希望未來能收緊到 0.05 或 0。
- Chrome 145(2026-02):
LayoutShift的previousRect/currentRect改用 CSS 像素(先前是物理像素、隨 DPR 變動);sources改為按影響面積降冪排序,sources[0]保證是最大主因。分數本身不變。 - Chrome / Edge 151 起 soft navigation 預設開啟,
layout-shiftentry 帶navigationId,可按 SPA 換頁切片各算一次 max session window;web-vitalsv6.0.0 用{reportSoftNavs: true}。但 CrUX 要怎麼呈現仍未定案,所以現階段建議兩套都量。 sources歸因的是被推走的元素,不是推它的元素。規格在 Caveat: Causality 明確承認做不到根因分析。- Lighthouse 10+ 的 CLS 權重是 25%(L8 是 15%),曲線控制點是 HTTP Archive 的 p25 → 50 分、p8 → 90 分。它只量載入期、不捲動、不互動、冷快取、無個人化內容、看不到 bfcache——所以它報 0 而 CrUX 報 0.3 是完全一致的兩件事。
✍️ 實踐
先找出最大視窗,再排序視窗內的元凶。 在瀏覽器 console 貼進去這段,它會按 web-vitals 的規則重建視窗,並印出構成最大視窗的每一筆位移與它的主要來源:
let win = [], best = {v: 0, e: []};
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // 提示,要自己濾
const first = win[0], last = win[win.length - 1];
if (win.length &&
entry.startTime - last.startTime < 1000 &&
entry.startTime - first.startTime < 5000) {
win.push(entry);
} else {
win = [entry];
}
const v = win.reduce((s, e) => s + e.value, 0);
if (v > best.v) best = {v, e: [...win]};
console.clear();
console.log('CLS(max window) =', best.v.toFixed(4));
console.table(best.e.map(e => ({
t: Math.round(e.startTime),
score: +e.value.toFixed(4),
// Chrome 145+ sources 已按影響面積降冪排序
culprit: e.sources?.[0]?.node?.nodeName ?? '?',
moved: e.sources?.[0]
? Math.round(e.sources[0].currentRect.top - e.sources[0].previousRect.top) + 'px'
: '',
})));
}
}).observe({type: 'layout-shift', buffered: true});跑完之後捲動整頁到底再捲回來,這一步是關鍵:Lighthouse 不會做,而捲動位移沒有輸入豁免。表格的第一列就是你今天該修的東西,不是分數最容易降的那個。
接著把三個歸零時機接上,否則你的 RUM 跟 CrUX 永遠對不齊。 在 pageshow 檢查 event.persisted 補一次 pageview 並把 CLS 重置為 0;用 pagehide 取代任何 unload 監聽器(並考慮送 Permissions-Policy: unload=() 擋掉第三方偷加);把主文件的 Cache-Control: no-store 換成 no-cache 或 max-age=0,除非那頁真的有敏感資料。然後到 DevTools 的 Application → Back-forward Cache → Run Test 驗一次,面板會標出哪些原因是 Actionable 的。
如果是 SPA,今天就把 soft navigation 的切片打開。 用 web-vitals v6 同時跑兩套(傳統整頁 + {reportSoftNavs: true}),兩份都 beacon 回去。原因是 CrUX 尚未宣布怎麼呈現 soft navigation,你需要歷史資料來銜接;而且切片後你才會第一次看到「使用者在第 7 個 SPA 頁面上遇到的 CLS」,而不是把 12 個頁面的位移全掛在首頁 URL 上。
最後檢查兩件跟像素有關的事。 一,如果你有自己寫的位移視覺化疊圖工具,確認它在 Chrome 145 之後有沒有多乘一次 DPR;二,把所有 scroll-triggered 進場動畫從 top / margin-top / height 改成 transform / opacity,並記得配 prefers-reduced-motion。前者是量測正確性,後者是規格層級的免費午餐:transform 變更根本不進計分。
🔗 延伸學習
- Cumulative Layout Shift (CLS) — web.dev — 官方指標定義,含 impact / distance fraction 的三個圖解範例、
hadRecentInput的警語、以及「指標與 API 的差異」一節(bfcache 歸零、iframe、背景載入)。 - Explainer: Layout Instability Metric — WICG/layout-instability — 規格層級的原文。shifting node 的 flow-relative 定義、transform / scrolling 三條排除規則、iframe 加權公式、
sources的 5 個上限與去重規則、以及誠實的 Limitations 與 Caveat: Causality。 - Evolving the CLS metric — web.dev — 為什麼是 session window、為什麼取最大而不是平均(那個「修掉小位移反而分數翻倍」的圖)、為什麼 1 秒與 5 秒,以及改版後 55% / 3% 的影響數據。
- Measuring soft navigations — Chrome for Developers — Chrome 151 的現行狀態、soft navigation 的三條定義、CLS 在 soft navigation 下的三種歸屬邊界條件、以及
web-vitalsv6 的reportSoftNavs用法。
💬 問 AI
我在做 Core Web Vitals 的 CLS 除錯,請用 2026 年的現行定義回答,並在每個結論標註來源(web.dev / Chrome for Developers / WICG layout-instability / Chromium metrics_changelog)。不確定的請直接說不確定,不要猜。
背景:我的 Lighthouse CLS 是 0.02,但 CrUX 的 p75 是 0.29。頁面是 Next.js 的 SPA,有 cookie 同意橫幅、跨來源廣告 iframe、以及捲動觸發的進場動畫。
請依序回答:
1. 用 max session window(1 秒間隔 / 5 秒上限)的規則,幫我重建這段時間軸的 CLS,並指出最大視窗是哪一段:
t=900ms 位移 0.01 / t=1300ms 位移 0.11 / t=2000ms 位移 0.07 /
t=3400ms 位移 0.04 / t=3900ms 位移 0.09 / t=9000ms 位移 0.06
請把每一步的視窗歸屬與加總都列出來。
2. Lighthouse 0.02 vs CrUX 0.29 的落差,依定義可以拆成哪幾個「系統性」來源?請把「量測窗口只到載入結束」「不捲動不互動」「iframe 內位移 API 不報但指標要算」「bfcache 還原被 CrUX 計為獨立造訪」「個人化內容」分別解釋,並說明各自會讓差距朝哪個方向偏。
3. 我的跨來源廣告 iframe 是 393×220、視口是 393×852。若 iframe 內部量到 0.4 的位移,依 WICG 的加權規則,它對頂層 CLS 貢獻多少?為什麼我的 web-vitals 腳本量不到它?有什麼替代做法?
4. 我的進場動畫用 IntersectionObserver 改 `top` 與 `height`。為什麼這些位移拿不到 hadRecentInput 的 500ms 豁免?改成 transform / opacity 之後,依規格為什麼分數會變 0?
5. 我要開始按 soft navigation 切片量 CLS。請給我 web-vitals v6 的最小可用程式碼,同時輸出傳統整頁生命週期與切片後的兩份數據,並說明「位移發生在 URL 更新之前 / 之後 / 互動後 500ms 內」這三種情況各自會歸給哪一次導航。另外告訴我 CrUX 目前有沒有正式採用 soft navigation 計分。
6. 最後給我一個排序後的修復清單,依「對最大視窗的貢獻」而不是「修起來的難易度」排序,並說明每一項預期把 p75 從 0.29 拉到多少。