昨天在「更正三」留了兩個沒填的洞。第一個是「Safari 依我查到的資料不支援 ascent-override / descent-override / line-gap-override」,那句後面掛了「已標存疑」;第二個是「度量覆寫描述符只對 @font-face 宣告的字型有效,蓋不到瀏覽器自選的系統 fallback」,那句只說了限制,沒說怎麼繞。今天先把這兩個洞用一級來源封掉——Safari 的答案是「到 2026 年 9 月仍然不支援 *-override,但從 Safari 17 起支援 size-adjust」,而各引擎在各作業系統上到底讀哪一組垂直度量,Chrome 官方部落格有一張表格,那張表格會推翻昨天我寫的一句過度概括。

但更重要的是往前一步。昨天整篇談的是「靜態度量」:字型檔裡的數字、行框怎麼算、strut 怎麼撐高。那是把時間凍結在「字型已經在了」那一刻。實際上一個 webfont 從「頁面開始下載」到「像素落在螢幕上」中間有一整條時間軸,而昨天那條 行框高度 = LH + (max dᵢ − min dᵢ) 的極差公式,在這條時間軸上會動兩次:一次是 fallback 字型參與排版時,一次是 webfont 到達接手時。兩次之間的差額就是 CLS。所以昨天講的是幾何,今天講的是時序——而時序決定了那個幾何差額會不會被使用者看見、以及會不會被 Chrome 的 field 指標記下來。

今天的路線是:@font-face 到底什麼時候才觸發下載(答案不是「解析到的時候」)、font-display 的三段時間軸與五個值的精確毫秒數、一次字型 swap 在 CLS 公式裡怎麼被計分、度量匹配的完整算式(含一個可以自己驗算的、Google 官方文件裡的算術錯誤)、preload 為什麼會繞過 unicode-range、靜態 subsetting 在複雜書寫系統上為什麼會產生亂碼、以及 W3C 的 Incremental Font Transfer 到 2026 年 9 月走到哪一步。

📖 學

@font-face 不觸發下載——字型的發現時間是 CSS 的函數

這是整條時間軸的起點,也是最多人記錯的一件事。web.dev 的字型最佳實務把它列為第一段:「常見的誤解是遇到 @font-face 宣告時字型就被請求了。這是錯的。」@font-face 本身不觸發下載,只有當這個 family 被頁面上實際套用到的樣式引用時才下載。

@font-face {
  font-family: "Open Sans";
  src: url("/fonts/OpenSans-Regular-webfont.woff2") format("woff2");
}
h1 { font-family: "Open Sans"; }

這段 CSS 裡的 Open Sans 只有在頁面真的含 <h1> 元素時才會下載。這個設計是為了不浪費頻寬,但它有一連串後果,而這些後果比「省頻寬」本身重要得多:

字型的發現時間 = CSS 全部下載完成 + 樣式匹配完成之後。 web.dev 有一句很關鍵的註記:即使你只 inline 了一部分 CSS,瀏覽器仍然必須等所有 CSS 都載入完,才能確定需要哪些字型。也就是說,一個晚到的、非關鍵的 stylesheet 可以把字型的請求時間整個往後拖。這也解釋了一個 CSS-in-JS 與 code splitting 時代的具體毛病:把 @font-face 放進元件層級的 CSS chunk 裡,字型的發現時間就被綁在那個 chunk 的載入時間上,而那個 chunk 通常在 JS 執行之後才進來。

所以「inline 字型宣告到 <head>」比 preload 更接近根治。 web.dev 明確建議:大多數網站會顯著受益於把字型宣告與其他關鍵樣式 inline 進主文件的 <head>,而不是放在外部 stylesheet 裡。同一篇同時警告:不要 inline 字型檔本身(base64 塞進 CSS),因為把大資源 inline 進主文件會延後主文件的送達,連帶延後其他所有資源的發現。

這裡有個容易搞混的界線:inline 的是 @font-face 宣告(幾百 bytes 的文字),不是 src 指向的字型檔(幾十 KB 的二進位)。前者是把「發現時機」提前,後者是把「關鍵路徑」加重。

font-display:三段時間軸,五個值,以及規格與實作的落差

CSS Fonts Module Level 4 的 font-display 描述符把字型未到達時的行為切成三段時間軸:

  • block period(阻擋期):字型還沒載入時,任何試圖使用它的元素必須用一個隱形的 fallback 字型渲染。注意「隱形」這個詞——文字佔了位,但畫不出來。字型在這期間到達,就正常使用。
  • swap period(交換期):緊接在 block period 之後。字型還沒載入時,元素用可見的 fallback 字型渲染。字型在這期間到達,就「換」進來。
  • failure period(失敗期):緊接在 swap period 之後。字型還沒載入就被視為載入失敗,走一般的字型 fallback。

五個值對應的規格建議數值:

block periodswap period規格用語
autoUA 自訂UA 自訂font display policy 由 user agent 定義
block短(建議 3s無限short block period, infinite swap period
swap極短(建議 100ms 或以下無限extremely small block period, infinite swap period
fallback短(建議 100ms 或以下)短(建議 3sshort block period, short swap period
optional短(建議 100ms 或以下)short block period, no swap period

引擎的預設行為(auto)在 2026 年仍然分裂:Chromium 系與 Firefox 預設阻擋文字渲染最多 3 秒,Safari 則無限期阻擋。 這一句是 web.dev 字型最佳實務的原文轉述,而它的實務意義是:一個沒寫 font-display 的網站,在 Safari 上一個掛掉的字型 CDN 會讓文字永遠不出現。這不是理論——第三方字型服務中斷時,這就是「頁面一片空白但 DOM 都在」的成因。

Web Almanac 2025 的字型章節給了現實世界的分布。2025 年抓取的資料裡,使用某個 font-display 值的比例是:

桌機行動
swap49.6%50.1%
block約 24.9%約 24.9%
auto8.5–9.1%8.5–9.1%
fallback約 5.1%約 5.1%
optional約 0.5%約 0.5%

Almanac 自己的解讀是 swap(約 50%)主宰內文與 UI,block(約 25%)在 icon font 上仍然常見。而 optional 只有 0.5% ——這是今天最值得盯住的一個數字,因為 web.dev 明確說 optional 是「最有效能」的策略:文字渲染延後不超過 100ms,並且保證不會有字型 swap 造成的位移。最能保證零位移的策略,使用率是所有值裡最低的。原因不難猜:optional 的代價是「字型晚到就這一次不用了」,品牌方不接受。但這代表大約一半的網站選了 swap,也就選了「保證看得見的位移」。

更正一:font-display: block 不能避免 CLS,而 swap 的 block period 規格與實作不一致

很常見的推理是:「block 期間文字是隱形的,使用者看不到位移,所以 blockswap 安全。」前半句對,後半句錯。 web.dev 把這件事說得很清楚:block 的初始文字顯示被延後,但它仍然可以造成位移,因為文字其實是被「畫成隱形的」,佔的位是 fallback 字型的位;webfont 載入後需要的空間可能不同,於是就位移了。

這件事分成兩層要分開:

  • 視覺上block 的位移「比較不刺眼」,因為使用者沒有看到文字從一個字型跳到另一個字型——他看到的是文字從無到有,同時周圍內容移動。
  • 指標上,位移一樣被記。CLS 量的是 layout shift,不是「文字外觀變化」。隱形文字佔位造成的後續位移照樣進 CLS。

web.dev 那張表下面的註記把四個值一起框起來:autoblockswapfallback 都有造成位移的可能;這四個裡面 swap 是延後文字渲染最少的,所以在「文字要盡快出來、但最終要用 webfont」的情境裡它是首選。唯一結構性地不會造成 swap 位移的是 optional,因為它沒有 swap period。

順帶更正一個更細的落差。MDN 與 web.dev 都把 swap 的 block period 寫成 0ms,但 CSS Fonts Module Level 4 的原文是「極短的 block period(大多數情況建議 100ms 或以下)」——這是「極短」不是「零」。Chromium 有一張追蹤這個落差的 issue(383078471,標題直接寫 font-display: swap 沒有 block period、與規格所述不同),也就是 Chrome 的實作是 0ms,沒有照規格建議留那 100ms。(已標存疑:這條 issue 我只讀到標題與第三方摘要,issue 內文需要登入才看得到;規格條文我是從搜尋摘要與 MDN 交叉比對的,沒有直接讀到 drafts.csswg.org/css-fonts-4/#font-display-desc 的原始 HTML。)

這個 100ms 有沒有差?有。規格留那一小段 block period 的用意是:如果字型已經在 disk cache 裡,或者只差一兩個 frame 就到,那就別讓使用者看到 fallback 閃一下。0ms 的實作意味著 Chrome 會先用 fallback 畫一次,即使 webfont 在下一個 frame 就可用——這正是「明明字型有 cache 為什麼還是閃」的來源之一。

一次字型 swap 在 CLS 公式裡值多少

要判斷「這個字型差異該不該修」,得先知道 CLS 怎麼計分,而 CLS 的計分方式會讓很多直覺失效。

單次 layout shift 的分數是兩個分數的乘積:

layout shift score = impact fraction × distance fraction

impact fraction 是「這次位移影響到的區域」佔視窗的比例(不穩定元素在位移前後所佔區域的聯集);distance fraction 是「移動最大的元素移動了多遠」除以視窗較大邊的長度。CLS 則不是把所有位移加總,而是用 session window:一個視窗由第一次非預期位移開啟,在連續 1 秒沒有新位移時關閉,且單一視窗最長 5 秒;CLS 取的是分數最高的那個視窗,不是所有視窗的總和。門檻是 0.1 以下為良好、0.25 以上為不佳,判定看的是使用者的第 75 百分位。

從這個公式掉出三個反直覺的結論:

第一,位移的「像素量」不是分母,視窗尺寸才是。 行高差 1px 但整頁 300 行內文全部往下推,impact fraction 接近 1,distance fraction 是 1px 除以視窗高度——分數很小。但如果那 1px 讓一個佔半個視窗的區塊往下移了 24px(因為累積 24 行),分數就是 0.5 × (24/812) ≈ 0.015。反過來,一個 hero 標題字型 swap 後長高 8px、推動了整個 above-the-fold 內容,impact fraction 可能是 0.9、distance fraction 是 8/812 ≈ 0.0098,分數 0.0089。單看一次 swap 通常都不到 0.1。 字型 CLS 的危險不在單次,在疊加。

第二,session window 讓字型 swap 與其他位移互相汙染。 因為取的是最大視窗分數,字型 swap 若落在跟圖片載入、廣告插入、cookie 橫幅出現同一個 1 秒窗裡,它們的分數會累進到同一個視窗。也就是說,同樣的字型 swap,在一個乾淨的頁面上可能無害,在一個 above-the-fold 有廣告的頁面上就變成把整體推過 0.1 的那根稻草。 修字型 CLS 的優先序,不該只看字型自己的貢獻。

第三,也是最容易在工程流程上踩到的:Lighthouse 這類 lab 工具幾乎量不到字型造成的 CLS。 原因是複合的:lab 環境的網路是模擬的、字型常常已經在 cache、而且 lab 跑的時間窗通常不涵蓋真實使用者的 swap 時序。CLS 本質上是一個 field 指標。要真的看到字型的貢獻,得在真實流量上用 PerformanceObserver 訂閱 layout-shift entry,並且把 entry.sources 記下來——sources 裡的 node 就是被推動的元素,previousRectcurrentRect 是位移前後的矩形。沒有 sources,你只會知道「CLS 是 0.14」,不會知道是誰。

度量匹配的完整算式:兩條路線、四個描述符

昨天給了處方,今天給算式。Chrome 官方部落格 Improved font fallbacks 把它分成兩條路線。

路線一,只用度量覆寫。 三條式子,全部只讀 webfont 自己的 metadata:

ascent-override   = ascent   / unitsPerEm
descent-override  = descent  / unitsPerEm
line-gap-override = line-gap / unitsPerEm

以 Poppins 為例(Chrome 部落格給的數字:ascent 1050、descent 350、line-gap 100、upem 1000):

@font-face {
  font-family: "fallback for poppins";
  src: local("Times New Roman");
  ascent-override: 105%;   /* 1050/1000 */
  descent-override: 35%;   /* 350/1000  */
  line-gap-override: 10%;  /* 100/1000  */
}

這條路線有一個很好的性質:因為三個值都只從 webfont 讀,它們與「用哪個字型當 fallback」無關。 所以你可以把同一組覆寫值套到 Arial 的 fallback、Roboto 的 fallback、任何 fallback 上,垂直方向都會對齊。代價是水平方向不對齊——fallback 的字寬跟 webfont 不一樣,斷行位置不同,行數就可能不同,於是垂直方向還是會跳(少幾行或多幾行)。

補一個抄寫時的雷點:descent-override 吃的是正數百分比,而字型表裡的 descender / sTypoDescender 慣例上是負數(昨天講過)。直接把 −350 抄進去是無效值。

路線二,度量覆寫加上 size-adjust size-adjust 是等比縮放(size-adjust: 200% 把字符放大到兩倍、50% 縮到一半),它單獨用的價值有限——因為多數情況你需要的是「稍微變窄或變寬」而不是等比縮放。但跟度量覆寫組合起來,就可以讓任意兩個字型在水平與垂直兩個方向都對齊。算式是四條:

size-adjust       = webfont 的 avgCharacterWidth / fallback 的 avgCharacterWidth
ascent-override   = webfont ascent   / (webfont upem × size-adjust)
descent-override  = webfont descent  / (webfont upem × size-adjust)
line-gap-override = webfont line-gap / (webfont upem × size-adjust)

avgCharacterWidth 只能近似。最粗率的做法是取 [a-z\s] 所有字元寬度的算術平均,但那會低估高頻字母(e)、高估低頻字母(z)的權重;改良做法是用字母頻率加權的平均寬度。另一個看似方便的來源是 OS/2 表裡的 xAvgCharWidth,但 Chrome 部落格明確警告兩件事:很多 subsetting 工具在增刪字符後不會更新 xAvgCharWidth;而且這個欄位的計算方法在 OpenType 規格歷史上改過,所以同一個數字在不同 OS/2 表版本下含意不同。

至於「為什麼不能乾脆設一個固定 line-height 就好」,Chrome 部落格的回答值得抄下來:度量覆寫與 size-adjust 處理的是 webfont 與 fallback 不匹配的根因line-height 沒有——line-height 設的是 line box 的高度。這句話跟昨天那條極差公式是同一件事的兩種說法:line-height 鎖住的是單一 inline box 的高度,鎖不住行框,也鎖不住字寬造成的斷行差異。

更正二:昨天那句「macOS 上的引擎走 CoreText、實質對應 hhea」是過度概括

昨天我寫「Firefox 尊重 USE_TYPO_METRICS;Windows 上的瀏覽器歷史上採 usWin*,但同樣尊重 USE_TYPO_METRICS;macOS 上的引擎走 CoreText,實質對應 hhea」,並標了存疑,理由是找不到規範性或引擎官方文件。今天找到了:Chrome 官方部落格 Improved font fallbacks 有一張九宮格表。而那張表顯示我第三句是錯的——macOS 上有一個引擎是例外。

macOSWindows
Chromium一律用 hhea設了 USE_TYPO_METRICStypo,否則用 win
Firefox設了 USE_TYPO_METRICStypo,否則用 hhea設了 USE_TYPO_METRICStypo,否則用 win
Safari一律用 hhea設了 USE_TYPO_METRICStypo,否則用 win

也就是說:Firefox 是唯一在 macOS 上尊重 USE_TYPO_METRICS 的引擎。 Chromium 與 Safari 在 macOS 上一律讀 hhea,不管字型作者有沒有表態。直接後果是——一個設了 USE_TYPO_METRICShheasTypo* 不同值的字型,在同一台 Mac 上、同一份 CSS 下,Firefox 與 Safari 的行高會不同。昨天我把 macOS 當成單一情況處理,那是錯的。(這也解釋了昨天為什麼建議「hhea 應該設成跟 sTypo* 同值」:那個建議的真正作用是讓上面那張表的九格全部收斂到同一個答案。)

這張表也給出「什麼時候一組覆寫值可以跨作業系統通用」的判準。Chrome 部落格說,對絕大多數字型(例如 Google Fonts 託管的字型裡約 90%),度量覆寫值可以在不知道使用者作業系統的情況下安全使用——因為對這些字型來說,不管適用 hheatypo 還是 win,算出來的三個覆寫值完全相同。判準是:

  • 若字型設了 USE_TYPO_METRICS:比較 (hheaAscent + hheaDescent + hheaLineGap) / upem(typoAscent + typoDescent + typoLineGap) / upem,相同就通用。
  • 若字型沒設 USE_TYPO_METRICS:比較 (hheaAscent + hheaDescent + hheaLineGap) / upem(winAscent + winDescent) / upem,並且額外要求 hheaLineGap == 0——因為 win* 沒有 winLineGap 這種東西。

剩下那約 10% 的字型需要 macOS 與 Windows 兩套不同的覆寫值,而 Chrome 部落格的建議是:只有在你「有能力依使用者作業系統切換 stylesheet」的情況下才推薦用這套技術。一份靜態 CSS 檔做不到這件事,這是這個技術一個很硬的邊界。

再把 Safari 的支援度確定下來。caniuse 到 2026 年 9 月的資料顯示:Safari 3.1 到 27 都不支援 ascent-override,3.1 到 18.5 都不支援 descent-override(也就是說到目前的 Safari 版本都沒有),WebKit 的追蹤 bug 是 219735。而 Chrome / Edge 從 87(2020 年 11 月)起、Firefox 從 89(2021 年 6 月)起支援這三個覆寫描述符。相對地 size-adjust 的命運不同:Chrome / Edge 87、Firefox 89、Safari 17(2023 年 9 月),因此 size-adjust 自 2023 年 9 月起是跨瀏覽器可用的。(已標存疑:版本號來自 caniuse 與 web-features 的二手整理;Safari 27 這個上界是搜尋結果給的版號範圍,實際請以 caniuse 現況複核。)

這個落差的實務意義很具體:在 Safari 上你只能做等比縮放,不能做非等比的垂直修正。 昨天那條處方的四個描述符,在 Safari 上只有一個生效。而因為 size-adjust 是等比的,它同時改變寬和高——你為了對齊字寬而設的 size-adjust,在 Safari 上會連帶把 ascent / descent 一起縮放到「你沒打算要的」值,而你少了 ascent-override 去把它拉回來。所以在 Safari 上,字型 swap 的垂直殘差是必然存在的,只能靠昨天那個「分語言 line-height token」硬吃。

更正三:ascent-override 的百分比不是相對 font-size,是相對「size-adjust 之後的 em」

昨天我寫「ascent-override / descent-override / line-gap-override 直接覆寫該字型回報的度量,百分比是相對 font-size」。這句話在不用 size-adjust 的時候成立,一旦加上 size-adjust 就不成立了——而這正是為什麼路線二的算式要多除一個 size-adjust

看回那四條式子:ascent-override = webfont ascent / (webfont upem × size-adjust)。如果 override 的百分比是相對原始 font-size,這裡就不需要除 size-adjust。要除,是因為 size-adjust 先把整個字型(含 em box 與度量)縮放了,度量覆寫是套在縮放後的座標系上。Chrome 部落格自己也提醒兩者的百分比定義完全不同:size-adjust: 10% 是把字型縮到十分之一大;ascent-override: 10% 不會讓字型變小,它是把 ascent 設成 upem 的 10%,改的是比例而不是尺寸。

實務上的錯法是這樣的:你先算出 size-adjust 讓字寬對上,然後直接把 ascent / upem 填進 ascent-override——結果字寬對了、行高錯了,而且錯的方向是「fallback 比 webfont 矮」(因為 size-adjust 通常小於 100%,你少除了一次就等於少乘了 1/size-adjust)。這種錯誤特別難抓,因為它在 size-adjust 接近 100% 的字型組合上幾乎看不出來。

更正四:Chrome 官方文件那組 Poppins 範例值,ascent-override 算術不一致

這條是我自己驗算出來的,任何人都可以複驗。Chrome 部落格 Improved font fallbacks 的「Choosing fallback fonts」段落給了一組完整的 Poppins 處方:

Poppins font metrics: ascent = 1050, descent = 350, line-gap = 100, UPM = 1000
AvgCharWidth: Poppins 538.0103768, Arial 884.1438804, Roboto 969.0502537
@font-face {
  font-family: poppins-fallback;
  src: local("Arial");
  size-adjust: 60.85099821%;
  ascent-override: 164.3358416%;
  descent-override: 57.51754455%;
  line-gap-override: 16.43358416%;
}

先驗 size-adjust538.0103768 / 884.1438804 = 0.608509982160.85099821%,對得上

再驗其餘三個。1 / 0.6085099821 = 1.6433584。照文中自己給的式子:

  • descent-override = 350 / (1000 × 0.6085099821) = 0.35 × 1.6433584 = 0.575175457.51754%,對得上
  • line-gap-override = 100 / (1000 × 0.6085099821) = 0.10 × 1.6433584 = 0.164335816.43358%,對得上
  • ascent-override = 1050 / (1000 × 0.6085099821) = 1.05 × 1.6433584 = 1.7255263 → 應為 172.55263%。文中寫的是 164.3358416%

164.3358416% 恰好等於 1.6433584,也就是 1000 / (1000 × size-adjust)——分子用的是 1000(等於 upem)而不是 1050(文中自己標的 ascent)。誤差正好是 1.05 倍。同一段的 Roboto 那組也是同一個毛病:size-adjust: 55.5193474%538.0103768 / 969.0502537 = 0.5551935,對得上),1 / 0.5551935 = 1.8011739descent-override: 63.04108683% = 0.35 × 1.8011739 對得上、line-gap-override: 18.01173909% = 0.10 × 1.8011739 對得上,而 ascent-override: 180.1173909% = 1.0 × 1.8011739,照式子應為 1.05 × 1.8011739 = 1.8912326189.12326%

兩組範例犯的是同一個系統性錯誤,所以這不是我算錯,是文件裡的算術與它自己列的式子不一致。(已標存疑) 我不能完全排除另一種解釋:Poppins 實際適用的那一組 ascent 恰好是 1000,而文中註解裡的 1050 是另一組度量(例如 typohhea 不同值)。但無論哪一種,這段文件內部是矛盾的:如果 ascent 真的是 1000,那 descent 用 350、line-gap 用 100 就得說明是從哪一組來的。

實務結論不是「Chrome 文件不可信」,而是:這類覆寫值一定要用工具產生並實測驗證,不要手抄部落格裡的數字。 這也正好解釋了為什麼 next/font@nuxtjs/fontaine、Fontaine 這類工具存在的價值不只是省事——它們把「讀 hhea / typo / win 哪一組、要不要除 size-adjustavgCharacterWidth 怎麼加權」這些一手一個坑的步驟固化成程式。Chrome 部落格自己也維護了一個 repo(khempenius/font-fallbacks-dataset),裡面有 Google Fonts 所有字型的覆寫值,以及哪些字型不能跨作業系統通用。

preload 的三個陷阱

preload 在字型優化的討論裡幾乎是標配,但 web.dev 對它的措辭是「be cautious」,而且列出了很具體的理由。

第一,它會排擠其他資源。 preload 高度有效地讓字型在載入流程早期被發現,但代價是「把瀏覽器的資源從其他資源的載入上拿走」。web.dev 的立場是:inline 字型宣告、調整 stylesheet 結構,是更貼近根因的做法;preload 只是繞過症狀。它承認的適用場景是使用外部 stylesheet 時——那種情況下瀏覽器要很晚才會發現字型,preload 最重要的那幾個字型就非常有效。

第二,也是最容易造成純浪費的:preload 會忽略 unicode-range 這句話 web.dev 是直接寫出來的:「preload 繞過瀏覽器內建的一部分內容協商策略。例如,preload 會忽略 unicode-range 宣告」。連鎖後果是:如果你用 Google Fonts 那種 latin / latin-ext / cyrillic / devanagari 分片的 @font-face(每片各有自己的 unicode-range),而你去 preload 它們,那些分片會全部被下載——包含頁面上一個字元都用不到的那些。unicode-range 的整個價值就是「頁面有落在範圍內的字元才下載」,preload 剛好把這個機制關掉。同一段還說:preload 若要用,應該只用來載入單一一種字型格式(否則就是同時下載 woff2 和 woff)。

第三,字型必須走 CORS。 這一點在 preconnect 那節寫得最明白:不像 stylesheet,字型檔必須透過 CORS 連線傳送。所以 <link rel="preload" as="font"> 少了 crossorigin 屬性,preload 出來的那份就跟後續實際請求的那份對不上,等於下載兩次。同理 preconnect 到第三方字型服務要開兩條——一條不帶 crossorigin(給 stylesheet),一條帶 crossorigin(給字型檔):

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Web Almanac 2025 的 resource hint 使用率(桌機):preconnect 18.3%、dns-prefetch 14.6%、preload 12.0%、prefetch 0.6%。dns-prefetch 有 14.6% 這件事值得說一句:字型的第三方連線瓶頸主要是 TCP 握手加 TLS 協商,不是 DNS 查詢,所以 dns-prefetch 對字型的幫助遠小於 preconnect——那 14.6% 裡有相當一部分是舊教材留下的慣性。

optional + preload:唯一能結構性歸零的組合

有一個組合值得單獨講,因為它是規格與實作難得對齊的一次。

font-display: optional 的定義是「短 block period,沒有 swap period」。原本 Chrome 的行為是:100ms block period 內字型沒到,就用 fallback 且不再交換;但不論字型有沒有及時到達,Chrome 都會渲染頁面兩次——先畫一次隱形文字,再畫一次。這造成一個輕微的隱形文字閃動,以及在字型及時到達的情況下的位移。而且這件事即使字型已經在 disk cache 裡、可以在 block period 結束前很久就載入完,也照樣發生。

Chrome 83 之後的優化把這件事解掉了:對用 <link rel="preload"> 預載的 optional 字型,Chrome 完全移除第一個 render cycle,改成「阻擋渲染直到字型載入完成或一段時間過去」,這個 timeout 目前設在 100ms。web.dev 的結論是:預載 optional 字型可以完全消除位移與 FOUT 的可能性;而這也正是 CSS Fonts Module Level 4 對 optional 的規範要求——optional 字型不該造成 re-layout,UA 可以改為延遲渲染一段合理時間。

換句話說,optional + preload 是唯一一組「規格說不該位移、實作也真的不位移」的配置,代價是明確的:字型在 100ms 內沒到,這次瀏覽就完全不用它(下一次瀏覽會從 cache 拿到,所以是「第一次訪問看不到品牌字型」)。回頭看 Almanac 那個 0.5%——這個組合的採用率低到接近零。這是一個很典型的「技術上最優、組織上最難通過」的選項:它要求品牌方接受「有些使用者的第一次訪問看不到我們的字型」。

subsetting、unicode-range,以及為什麼它在複雜書寫系統上會壞掉

字型檔通常包含它支援的所有字元的字符,而你的頁面用不到全部。unicode-range 描述符告訴瀏覽器「這個字型檔可以用在哪些字元上」,頁面裡出現一個或多個落在範圍內的字元,這個檔才會被下載。

規模差異是這樣的:拉丁字型通常在 100 到 1000 個字符的量級,CJK 字型可能超過 10,000 個字元。 Web Almanac 2025 的檔案大小分布:第 75 百分位約 76 KB 上下、第 90 百分位約 115–116 KB、而第 99 百分位在 2024 年的桌機資料是約 776 KB(行動 572 KB),2025 年仍在數百 KB 量級。格式分布上 WOFF2 佔約 65%(桌機 65.2% / 行動 65.4%),WOFF 仍有約 16%,TTF 約 6.7%,另外約 6.7% 是 application/octet-stream(也就是伺服器沒設對 MIME type)。輪廓格式則是 glyf(TrueType 系)約 93%、CFF 約 7%。

unicode-range 分片有一個跟昨天直接接上的性質:同一段文字如果跨了兩個分片,就會用兩個字型檔渲染。 但因為這些分片是同一套字型切出來的,垂直度量相同,昨天那條 行框高度 = LH + (max dᵢ − min dᵢ) 的極差是零,行高不跳。這是分片相對 fallback 的一個結構性優勢——分片之間安全,分片與 fallback 之間不安全。

但靜態 subsetting 在複雜書寫系統上有一個更嚴重的問題,W3C 的 IFT 說明文件寫得很直白:對有複雜 shaping 需求的語言,靜態 subsetting 給出的檔案很小(複雜 shaping 類的 WOFF2 中位數 93.5 kB),但與 CSS 的 unicode-range 併用時,已知有時會產生 malformed、無法閱讀的文字。失敗的機制有兩類:一是不同 OpenType 表之間存在複雜的相互關係(切掉一部分字符會讓 GSUB / GPOS / mark attachment 的規則指向不存在的東西);二是同一個字元被多個書寫系統共用,但在各系統裡行為不同——按碼位切片會把「同一個碼位在不同語境下需要的不同處理」切散。此外,現行的 subsetting 完全不處理變數字型的設計軸

那份文件還給了三個數字,是理解「為什麼 CJK 網頁幾乎不用 webfont」的關鍵:全球約 75% 的頂層網頁使用 webfont,但 webfont 主要用在簡單書寫系統(拉丁、希臘、西里爾),這類的 WOFF2 中位數是 8.3 kB;而字符量大的字型(中文、日文典型使用)即使經過 WOFF 1 / 2 壓縮,中位數仍是 1.8 MB,於是中國與日本的 webfont 使用率接近零。Almanac 2025 也用一句話呼應:webfont 的使用已趨飽和,「除了最大的 CJK 字集之外」。

IFT:2026 年 9 月的狀態,以及它為什麼是 CJK 的唯一結構解

Incremental Font Transfer 的做法是:頁面照樣用 @font-face,但用 tech(incremental)src 裡表態,或用 @supports 裡的 font-tech(incremental) 做條件分支——這讓同一份 CSS 可以同時指向 IFT 字型與一個(比較大、比較慢的)fallback 字型。

src 指向的 IFT 字型本身就是一個正常的 OpenType 字型(通常用 WOFF2 包裝),裡面只含渲染某個 subset 所需的資料,而那個 subset 是三個維度的交集:碼位、layout feature、設計變化空間。除此之外它多帶幾張擴充表,裡面是 patch map——把 subset 定義映射到相關 patch 的連結,而連結是以 RFC 6570 URI Template 的形式存在字型檔裡的。

於是機制變成:一個 IFT 字型可能一開始只支援拉丁、單一字重,但字型裡帶著「加上字重變化軸」、「加上 small caps」、「加上西里爾字元」各自的 patch URL;遇到需要的內容才下載並套用。patch 分兩種:**independent(可交換)**的 glyph-only patch 可以平行請求,功能上非常接近今天的 unicode-range 載入;**dependent(不可交換)**的則必須依序套用。

這個架構的三個工程性質很重要:patch 是靜態檔案,可以純靜態託管patch 在使用者之間共用,所以 CDN cache 行為與現有基礎設施相容;以及比動態方案更保護隱私。相對地,兩個被放棄的前身各自死在不同地方——Range Request(靠 HTTP Range,任何伺服器都能用,但只 subset 字符輪廓,效能不足,在慢速網路上甚至比完全不 subset 更糟)與 Patch Subset(伺服器動態計算二進位 patch,大字型的位元組中位數可省 90%,但動態 subsetting 嚴重破壞 CDN cache、需要智慧型伺服器、極細粒度的 subsetting 有隱私推斷風險、還要自訂協定與 HTTP header)。

2026 年 9 月的狀態(已標存疑,日期來自搜尋摘要與 W3C 發布歷史,不是我直接讀到的規格首頁): 最近一次狀態變更是 2025 年 11 月 18 日進入 Candidate Recommendation Draft,Editor’s Draft 的日期是 2026 年 7 月 31 日。目前沒有實作報告,測試套件仍在開發中(w3c/ift-client-tests),encoder 實作(w3c/ift-encoder)也還在早期階段。廠商立場:Chromium positive(在 googlefonts/fontations 裡有實作),WebKit supportive(standards-positions #461),Gecko no signals(mozilla/standards-positions #872),字型廠(Adobe、Dalton Maag、Google)positive。Chrome 目前公開的是 Intent to Prototype(追蹤 bug 498722796),不是 Intent to Ship;官方說法是「相較 subsetting 的做法,典型可省 50% 以上」。

所以 2026 年 9 月的判斷很直接:IFT 不能用在生產,但它是 CJK webfont 目前唯一的結構性解,而且它的 opt-in 機制(tech(incremental)@supports font-tech(incremental))天生就是漸進增強的。 值得現在做的事不是等它,而是把字型交付層抽象出來——讓「哪些 @font-face、指向哪些 URL、怎麼分片」是一個可以換掉的產物,而不是散在幾十個 CSS 檔裡的字面值。

Font Loading API:什麼時候真的需要 JS,以及 document.fonts.ready 的精確語意

CSS Font Loading API 的核心是 FontFaceSet,透過 document.fonts 取得。常用的四樣東西:document.fonts.add(new FontFace(...)) 註冊、document.fonts.load() 觸發載入、document.fonts.check() 查詢、document.fonts.ready 等待。事件有 loadingloadingdoneloadingerror,其中 loadingdone 在字型集完成載入時觸發,而且在 Web Worker 裡可用FontFace.status 有四個狀態:unloadedloadingloaded,以及第四個表示失敗的狀態——MDN 的說明用 error,我看到的另一份摘要寫 failed已標存疑,實際字面值請以 MDN 現況或實測為準)。status 在資料成功取得並載入後變成 loaded

document.fonts.ready 的精確語意值得逐句讀,因為它跟大家以為的不一樣。MDN 的描述是:這個 promise 只會在文件完成字型載入、layout 操作也完成、並且不再需要進一步的字型載入時 resolve。三個條件都要滿足。

把這句話跟本文第一節接起來,就會得到一個很反直覺的結論:document.fonts.ready resolve 不代表「所有 @font-face 宣告的字型都下載好了」。 因為 @font-face 本身不觸發下載,一個「頁面上還沒有任何元素用到」的字型根本不在「需要載入」的集合裡,ready 會在它沒下載的情況下 resolve。之後你動態插入一個用到它的元素,字型才開始下載,ready 也會被重置成新的 pending promise。所以在 SPA 或有延遲渲染內容的頁面上,把 document.fonts.ready 當「字型都好了」的一次性訊號是錯的。

還有一個高頻踩雷:document.fonts.load() 的第一個參數是 font shorthand 字串,而 shorthand 語法要求字級不能省略。所以要寫 document.fonts.load('1em "Inter"'),不是 document.fonts.load('"Inter"');省掉字級會拋 syntax error 而不是靜默失敗(已標存疑,建議實測確認拋的是哪一種錯)。那個 1em 只是為了讓 shorthand 合法,不影響下載哪個檔案。

那麼什麼時候真的需要 JS?答案是那些不會因為 CSS 重排而自動更新的東西

  • canvas 上用 ctx.fillText() 畫的文字——字型換了,已經畫上去的像素不會變。
  • SVG 裡依賴 textLengthgetComputedTextLength() 的排版。
  • 用 JS 量測寬度做的截斷、tooltip 定位、或「文字放不下就縮字級」邏輯。
  • 虛擬清單(virtualized list)快取的 item 高度。

這幾類全都應該掛在 document.fonts.ready.then(...)loadingdone 之後重算一次。

反過來,過去那套「載入完成後在 <html> 上加 fonts-loaded class、CSS 分兩階段」的兩階段渲染模式,在 2026 年基本上該退休了。 理由有三個:font-display 已經把 render policy 交給 CSS 宣告式處理;度量覆寫已經把尺寸差異處理掉;而 JS 方案本身要等 script 下載並執行,反而把文字出現的時間往後推——用一個會延後 FCP 的機制去解一個 CLS 問題,划不來。

你沒辦法用 @supports 偵測這些描述符

最後一個容易誤判的邊界。看到 Safari 不支援 ascent-override,第一反應會是寫個 @supports 分支:

@supports not (ascent-override: 100%) { /* Safari 用這裡 */ }

這不會如預期運作。 @supports 的 declaration condition 測的是屬性(property),而 ascent-override@font-face描述符(descriptor),不是屬性——它在任何元素上都不是合法宣告,所以測試結果不代表「引擎支不支援這個描述符」。(已標存疑:這個坑有第三方文章記錄過,但各引擎對「未知描述符名稱在 @supports 裡的求值結果」的具體行為我沒有找到規範性條文,請自己在三個引擎上實測。)

CSS Conditional Rules Level 5 補上的工具是 font-tech()font-format():前者測引擎是否支援某項字型技術(例如 variationspalettes、以及 IFT 的 incremental),後者測是否支援某種字型格式(例如 woff2)。同一份規格也定義了 at-rule(),用來測 at-rule 本身的支援度。

@supports font-tech(incremental) { /* 可以用 IFT */ }
@supports font-format(woff2)     { /* 幾乎恆真,但可用於漸進策略分支 */ }

支援狀態(已標存疑,版本號來自 caniuse 的二手整理):Chromium 已 ship(font-tech() / font-format() 有正式的 Intent to Ship),iOS Safari 自 17.0 起、Chrome for Android 144、Firefox for Android 147、Samsung Internet 21。桌機 Safari 的支援狀況我查到的資料不一致,請複核。

所以 2026 年在字型能力偵測上的實際圖像是:格式與技術可以測(font-format() / font-tech()),描述符不能測。 這也意味著 Safari 上缺少的那三個度量覆寫描述符,你連「偵測到就換一套 CSS」都做不到——只能無條件把 size-adjust 寫上去(三個引擎都支援),把 *-override 也寫上去(Safari 會忽略),然後接受 Safari 上有殘差,並用分語言的 line-height token 把殘差控制在可預測的範圍內。這一句就是昨天結論的正式收束:在多語系產品裡,可預測比完美值錢得多——而今天多加了一層原因:完美所需的那幾個描述符,有一個主流引擎不給你,而且你偵測不到。

🧠 記

  • @font-face 本身不觸發下載;只有 family 被頁面上實際套用的樣式引用時才下載。所以字型的發現時間 = 全部 CSS 載入完 + 樣式匹配完成之後,一個晚到的非關鍵 stylesheet 就能拖慢字型。
  • inline 的應該是 @font-face 宣告(進 <head>),不是字型檔本身;inline 字型檔會加重主文件、延後所有其他資源的發現。
  • font-display 的三段時間軸:block period(隱形 fallback 佔位)→ swap period(可見 fallback)→ failure period(視為載入失敗)。
  • 規格建議值:block = 3s block + 無限 swap;swap = 極短 block(建議 100ms 以下)+ 無限 swap;fallback = 100ms block + 3s swap;optional = 100ms block + swap;auto = UA 自訂。
  • 引擎預設(auto):Chromium 與 Firefox 阻擋文字最多 3 秒,Safari 無限期阻擋。沒寫 font-display 時,字型 CDN 掛掉在 Safari 上等於文字永不出現。
  • Web Almanac 2025:swap 49.6%(桌機)/ 50.1%(行動)、block 約 24.9%、auto 8.5–9.1%、fallback 約 5.1%、optional 約 0.5%。最能保證零位移的策略採用率最低。
  • font-display: block 不能避免 CLS:隱形文字佔的是 fallback 的位,webfont 到達後尺寸不同照樣位移,只是使用者沒看到字型跳動。auto / block / swap / fallback 四者都有位移可能,只有 optional 沒有 swap period。
  • MDN 與 web.dev 把 swap 的 block period 寫成 0ms,CSS Fonts 4 原文是「極短(建議 100ms 以下)」;Chromium issue 383078471 追蹤這個落差。0ms 的後果是即使字型下一個 frame 就到、或已在 disk cache,仍會先用 fallback 畫一次。
  • 單次位移分數 = impact fraction × distance fraction;CLS 用 session window(1 秒無位移即關閉、單窗上限 5 秒),取最大視窗而非總和;門檻 0.1 良好 / 0.25 不佳,看第 75 百分位。
  • 推論:單次字型 swap 的分數通常遠低於 0.1,危險在於它會跟圖片、廣告、cookie 橫幅落進同一個 session window 疊加。且 CLS 是 field 指標,Lighthouse 這類 lab 工具幾乎量不到字型 CLS——要用 PerformanceObserverlayout-shift 並讀 entry.sources(含 node / previousRect / currentRect)。
  • 度量覆寫路線一(只用 overrides):ascent-override = ascent/upemdescent-override = descent/upemline-gap-override = line-gap/upem。三個值只讀 webfont,與選哪個 fallback 無關,可套用到多個 fallback。代價是水平不對齊、斷行位置不同。
  • descent-override正數百分比,而字型表裡的 descender 慣例是負數,抄的時候要取絕對值。
  • 度量覆寫路線二(加 size-adjust):size-adjust = webfont avgCharWidth / fallback avgCharWidth,然後三個 override 都要再除一個 size-adjust。因為 override 相對的是「size-adjust 之後的 em」,不是原始 font-size——這是昨天那句「百分比相對 font-size」需要補的界線。
  • avgCharacterWidth 只能近似;[a-z\s] 算術平均會低估高頻字母,建議用字母頻率加權。別用 OS/2xAvgCharWidth:subsetting 工具常不更新它,而且它的計算方法在規格歷史上改過。
  • 各引擎讀哪一組垂直度量(Chrome 官方表格):Chromium/mac 與 Safari/mac 一律 hheaFirefox/mac 是唯一在 macOS 上尊重 USE_TYPO_METRICS 的引擎(設了用 typo、否則 hhea);三個引擎在 Windows 上都是「設了用 typo、否則用 win」。昨天寫的「macOS 一律對應 hhea」是過度概括。
  • 覆寫值能否跨 OS 通用的判準:設了 USE_TYPO_METRICS 就比 (hheaA+hheaD+hheaGap)/upem(typoA+typoD+typoGap)/upem;沒設就比 (hheaA+hheaD+hheaGap)/upem(winA+winD)/upem並且要求 hheaLineGap == 0(沒有 winLineGap 這種欄位)。Google Fonts 託管字型約 90% 可跨 OS 通用,剩下 10% 需要依 OS 切換 stylesheet——靜態 CSS 做不到。
  • 支援度(caniuse 二手整理,已標存疑):ascent-override / descent-override / line-gap-override = Chrome/Edge 87(2020-11)、Firefox 89(2021-06)、Safari 至今不支援(WebKit bug 219735)。size-adjust = Chrome/Edge 87、Firefox 89、Safari 17(2023-09),故 2023-09 起跨瀏覽器可用。
  • Safari 只有等比的 size-adjust、沒有非等比的垂直修正,所以字型 swap 的垂直殘差在 Safari 上必然存在,只能靠分語言 line-height token 吃掉。
  • Chrome 官方部落格的 Poppins 範例中 ascent-override 與它自己列的式子不一致size-adjust 60.85099821%、descent-override 57.51754455%、line-gap-override 16.43358416% 都驗算得上,但 ascent-override 給 164.3358416%(= 1000/(1000×size-adjust)),照式子應為 172.55263%(= 1050/(1000×size-adjust)),差正好 1.05 倍;Roboto 那組同樣誤差(給 180.1173909%,應為 189.12326%)。覆寫值要用工具生成並實測,不要手抄。
  • preload 三個陷阱:(a) 會排擠其他資源,inline 宣告更接近根治;(b) preload 忽略 unicode-range,所以 preload 分片式 @font-face 會把用不到的分片全下載,且只該用來 preload 單一格式;(c) 字型必須走 CORS<link rel="preload" as="font"> 少了 crossorigin 會下載兩次;preconnect 到第三方要開兩條(一條帶 crossorigin)。
  • Almanac 2025 resource hint(桌機):preconnect 18.3%、dns-prefetch 14.6%、preload 12.0%、prefetch 0.6%。字型的瓶頸是 TCP+TLS 不是 DNS,所以 dns-prefetch 的 14.6% 多半是舊慣性。
  • optional + preload 是唯一「規格說不該 re-layout、Chrome 實作也真的不 re-layout」的組合:Chrome 83 起對預載的 optional 字型移除第一個 render cycle,阻擋渲染直到字型到達或 100ms 過去。代價是字型晚到就這次不用。
  • 字符量級:拉丁字型 100–1000 個字符,CJK 可超過 10,000。Almanac 2025 檔案大小 p75 約 76 KB、p90 約 115–116 KB、p99(2024 桌機)約 776 KB;格式 WOFF2 約 65%、WOFF 約 16%、TTF 約 6.7%、application/octet-stream 約 6.7%(MIME 設錯);輪廓 glyf 約 93% / CFF 約 7%。
  • unicode-range 分片之間度量相同,所以昨天那條極差公式為零、行高不跳;分片與 fallback 之間才會跳。分片安全,fallback 不安全。
  • 靜態 subsetting + unicode-range 在複雜書寫系統上已知會產生 malformed / 無法閱讀的文字:OpenType 表之間有跨表關聯、以及同一碼位被多個書寫系統共用但行為不同。現行 subsetting 也完全不處理變數字型的設計軸。
  • W3C 數字:全球約 75% 頂層網頁用 webfont;簡單字母系統 WOFF2 中位數 8.3 kB、複雜 shaping 靜態 subset 後 93.5 kB、大字集 1.8 MB中國與日本的 webfont 使用率接近零
  • IFT 機制:@font-facesrc 指向一般 OpenType(通常 WOFF2 包裝),以 tech(incremental)@supportsfont-tech(incremental) opt-in;字型內帶 patch map 表,patch 連結以 RFC 6570 URI Template 存在字型裡;subset 維度是碼位 × layout feature × 設計變化空間;patch 分 independent(可交換、可平行請求)與 dependent(不可交換)。靜態託管、CDN cache 可共用、比動態方案更保護隱私。
  • IFT 狀態(已標存疑):Candidate Recommendation Draft 2025-11-18,Editor’s Draft 2026-07-31;無實作報告、測試套件與 encoder 仍在早期。Chromium positive(googlefonts/fontations 有實作,Chrome 目前是 Intent to Prototype、追蹤 bug 498722796),WebKit supportive(#461),Gecko no signals(#872)。官方稱相較 subsetting 典型省 50% 以上
  • 兩個被放棄的前身:Range Request(只 subset 輪廓、慢網路上比不 subset 更糟)、Patch Subset(大字型省 90% bytes,但破壞 CDN cache、需智慧伺服器、細粒度 subsetting 有隱私推斷風險、需自訂協定與 header)。
  • document.fonts.ready 的 promise 只在「字型載入完成 + layout 操作完成 + 不再需要進一步字型載入」三個條件都成立時 resolve。因為 @font-face 不觸發下載,它 resolve 不代表所有宣告的字型都下載好了——在 SPA 裡當一次性訊號用是錯的。
  • document.fonts.load() 吃 font shorthand 字串,字級不能省document.fonts.load('1em "Inter"')FontFace.status 四態 unloaded / loading / loaded / error(第四個字面值 MDN 作 error、另一摘要作 failed已標存疑)。loadingdone 事件在 Web Worker 裡可用。
  • 真的需要 JS 的是「不會隨 CSS 重排更新的東西」:canvasfillText、SVG 的 textLength / getComputedTextLength()、JS 量測寬度做的截斷與定位、虛擬清單快取的 item 高度。
  • 兩階段 html.fonts-loaded class 切換在 2026 年該退休font-display 管 render policy、度量覆寫管尺寸,都不需要 JS;而 JS 方案要等 script 執行,用延後 FCP 去換 CLS 不划算。
  • @supports 測不到 @font-face 描述符——它測的是屬性,ascent-override 不是屬性(已標存疑)。Conditional Rules 5 給的是 font-tech()(測 variations / palettes / incremental 等技術)、font-format()(測 woff2 等格式)與 at-rule()。所以「格式與技術可以測,描述符不能測」。

✍️ 實踐

今天做一件事:做一個字型交付實驗台,在同一份頁面上跑四種 font-display 策略,用 PerformanceObserver 量出各自真實的 CLS 貢獻,並自己算一組度量覆寫值去把它壓到零。 這件事 30–45 分鐘做得完,而做完之後你會知道自己網站上那個 CLS 數字裡有多少是字型的。

第一步:準備一個會慢的字型。 關鍵是要能穩定重現 swap,所以不能靠真實網路。三個做法任選:(a) Chrome DevTools 的 Network 面板選 Slow 4G 或自訂 throttling profile,並勾 Disable cache;(b) 用 Service Worker 把字型請求延遲固定毫秒數再放行——這個最穩定,因為延遲量是你決定的;(c) 起一個本地 server,在字型路由上 await sleep(1500)。建議直接用 (c) 或 (b),因為 DevTools throttling 對小檔案的實際延遲抖動很大,量出來的數字不可重現。

第二步:建四個並排的區塊,各自一個 font-display 值。 每個區塊放同樣的內容:一個 h2 標題、一段大約 8–12 行的內文、下面接一個有邊框的方塊(這個方塊是「被推動的東西」,讓位移看得見也量得到)。四個區塊分別套 font-display: block / swap / fallback / optional,用四個不同的 font-family 名字指向同一個字型檔(同一個檔、四個 @font-face、四種 font-display)。

第三步:接 layout-shift observer,這一步是重點。 不要只印總分,要印出「誰被推動了、推了多遠」:

const shifts = [];
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;            // 使用者輸入 500ms 內的位移不計
    shifts.push({
      t: Math.round(entry.startTime),
      value: entry.value,
      sources: (entry.sources || []).map(s => ({
        node: s.node?.dataset?.probe ?? s.node?.tagName,
        dy: Math.round(s.currentRect.top - s.previousRect.top),
        dh: Math.round(s.currentRect.height - s.previousRect.height),
      })),
    });
  }
}).observe({ type: 'layout-shift', buffered: true });
 
// 在 fonts.ready 之後 + 一段緩衝,把結果印出來
document.fonts.ready.then(() => setTimeout(() => console.table(
  shifts.flatMap(s => s.sources.map(src => ({ t: s.t, value: s.value.toFixed(5), ...src })))
), 300));

在每個區塊的元素上加 data-probe="swap-box" 這種標記,sources[].node 就會直接告訴你是哪一塊在動。dy 是往下移了幾 px、dh 是自己長高了幾 px——這兩個要分開看,因為「自己變高」和「被別人推下去」是不同的問題。

第四步:自己算一組度量覆寫值。fontkit(Node)或 fontdrop.info(瀏覽器)讀出你的 webfont 的 unitsPerEmhhea 的 ascender / descender / lineGap、以及 OS/2sTypo*usWin*。先做只用 overrides 的版本:

@font-face {
  font-family: "MyFont fallback";
  src: local("Arial");
  ascent-override: /* ascent/upem */ 0%;
  descent-override: /* |descent|/upem */ 0%;
  line-gap-override: /* lineGap/upem */ 0%;
}
body { font-family: "MyFont", "MyFont fallback", sans-serif; }

然後做加 size-adjust 的版本。avgCharacterWidth 先用最粗的做法:把 abcdefghijklmnopqrstuvwxyz 每個字元的 advance width 取平均,webfont 除以 Arial 的比值就是 size-adjust;三個 override 記得再除一次 size-adjust

第五步:跑兩個瀏覽器,這一步是為了看見更正二那張表。 同一頁在 Chrome 與 Firefox 上各跑一次(如果手上有 Mac 就在 Mac 上跑,那張表的差異在 macOS 上才顯現)。如果你的 webfont 設了 USE_TYPO_METRICShheasTypo* 不同值,Firefox 與 Chrome 在 macOS 上會給你不同的行高——這就是那張表的實證。

第六步:驗 preloadunicode-range 陷阱。 建三個 @font-face 指向三個不同分片(可以用 Google Fonts 的 CSS 直接抄 latin / latin-ext / cyrillic 三段),頁面上只放純 ASCII 內容。先不加 preload,看 Network 面板下載了幾個檔(應該只有 latin);再把三個都 <link rel="preload" as="font" crossorigin>,看下載了幾個。

自我檢查

  • 四種 font-display 之中,block 那一區塊有沒有出現非零的 layout-shift entry?如果沒有,你的字型可能太快到達或還在 cache——把延遲拉到 2500ms、確認 Disable cache 有勾、再跑一次。這一題答對才算真的驗證了「更正一」。
  • optional 那一區塊的位移是不是零?如果不是零,檢查兩件事:你有沒有 preload 它(沒 preload 的 optional 在舊行為下仍會 re-render 兩次),以及你的字型有沒有在 100ms 內到達(到達了就會被用,且 Chrome 83 後的優化讓它不位移)。
  • sources[].dysources[].dh 是不是不同的元素在動?標題自己長高(dh)與下方方塊被推下去(dy)應該是兩筆不同的 source。如果只看到一筆,你的探針標記得不夠細。
  • 把所有 layout-shift entry 的 startTime 印出來,算一下它們之間的間隔。有沒有任何兩筆的間隔小於 1000ms? 有的話它們落在同一個 session window、分數會累加——這就是「字型 swap 與其他位移互相汙染」的實證。
  • 加上度量覆寫之後,swap 那一區塊的位移量是不是明顯下降?如果只降了一點,很可能是水平方向沒對齊(斷行位置不同導致行數不同)——這時候才需要上 size-adjust
  • 加上 size-adjust 之後,你有沒有把三個 override 再除一次 size-adjust?故意不除,跑一次,看位移量往哪個方向跑。這是驗證「更正三」最快的方式。
  • 在 Safari 上跑同一頁。size-adjust 生效了嗎(字寬有變)?ascent-override 生效了嗎(行高有變)?預期答案是前者是、後者否。
  • preload 三個分片之後,Network 面板是不是下載了三個字型檔而不是一個?如果是,你剛剛親手驗證了「preload 忽略 unicode-range」。
  • 用 Lighthouse 跑同一頁,看它報的 CLS 跟你 observer 量到的差多少。差得很多才是正常的——這證實了字型 CLS 是 field 問題、lab 工具測不到。

🔗 延伸學習

  • Chrome for Developers:Improved font fallbacks——size-adjust 與三個度量覆寫描述符的完整算式、avgCharacterWidth 的近似方法、以及各引擎在 macOS / Windows 上讀哪一組垂直度量的那張九宮格表。今天「更正二」與「更正四」都出自這一篇。
  • web.dev:Best practices for fonts——@font-face 不觸發下載、preload 忽略 unicode-rangefont-display 五個值的 block / swap period 表格、以及「block 仍會造成位移」那段註記。
  • W3C:Incremental Font Transfer——IFT 的規範本體,tech(incremental) opt-in、patch map、URI Template、independent 與 dependent patch 的定義都在這裡。想看動機與被放棄的兩個前身,讀同 repo 的 IFT-Explainer.md
  • Web Almanac 2025:Fonts——font-display 各值的真實採用率、resource hint 使用率、檔案大小分布、WOFF2 與 glyf/CFF 佔比、變數字型採用率(2025 年桌機 39.4% / 行動 41.3%)。今天所有「現實世界的比例」都來自這一章。

💬 問 AI

我在做一個多語系網頁產品(繁中 + 英文 + 阿拉伯文 + 印地文),正在處理 webfont 造成的 CLS
與文字延遲渲染。請針對以下我目前的理解逐條判斷對錯,並在你「查不到可靠來源」的地方
直接說查不到,不要推測補齊。
 
我目前的主張:
1. @font-face 本身不觸發字型下載;只有當該 family 被頁面上實際套用的樣式引用時才下載。
   因此瀏覽器必須等所有 CSS 載入完成才能確定需要哪些字型。
2. font-display 的三段時間軸是 block period(隱形 fallback 佔位)→ swap period(可見
   fallback)→ failure period。規格建議值:block 3s、swap 極短(100ms 以下)、
   fallback 100ms + 3s、optional 100ms + 無 swap period。
3. font-display: block 不能避免 CLS,因為隱形文字佔的是 fallback 字型的位。只有 optional
   結構性地沒有 swap 位移。
4. 單次位移分數 = impact fraction × distance fraction;CLS 用 session window(1 秒無位移
   關閉、單窗上限 5 秒),取最大視窗而非總和;門檻 0.1 / 0.25,看第 75 百分位。
5. 度量覆寫加 size-adjust 時,三個 override 都必須再除一個 size-adjust,因為 override
   相對的是 size-adjust 之後的 em,不是原始 font-size。請幫我驗證這條。
6. preload 會忽略 unicode-range,所以 preload 分片式 @font-face 會下載用不到的分片;
   而且字型必須走 CORS,preload 少了 crossorigin 會下載兩次。
7. unicode-range 分片之間垂直度量相同,所以行框高度不會因為跨分片而跳;只有 fallback
   進來才會跳。
8. document.fonts.ready 只在「字型載入完成 + layout 完成 + 不再需要進一步字型載入」三個
   條件都成立時 resolve;因為 @font-face 不觸發下載,它 resolve 不代表所有宣告的字型
   都下載好了。
9. @supports 測不到 @font-face 的描述符(例如 ascent-override),因為 @supports 的
   declaration condition 測的是屬性。要測字型能力得用 font-tech() / font-format()。
 
我不確定、需要你查證後給出「現況 + 來源 + 查證日期」的部分:
A. font-display: swap 的 block period 到底是 0ms 還是「極短(100ms 以下)」?請引用
   css-fonts-4 的原文,並說明 Chrome / Firefox / Safari 各自的實作值。Chromium issue
   383078471 的現況是什麼(已修、won't fix、還是仍開著)?
B. 到 2026-09,Safari 是否仍不支援 ascent-override / descent-override / line-gap-override?
   WebKit bug 219735 的最新狀態?size-adjust 在 Safari 的最低版本是不是 17?
C. 各引擎在 macOS / Windows 上讀哪一組垂直度量(hhea / typo / win)?Chrome 官方部落格
   「Improved font fallbacks」那張表格是 2023-02 的資料,2026 年還成立嗎?特別是
   Firefox 在 macOS 上是否仍是唯一尊重 USE_TYPO_METRICS 的引擎?
D. 我驗算 Chrome 該篇部落格的 Poppins 範例,發現 ascent-override 給 164.3358416%,但照
   它自己的式子 ascent/(upem × size-adjust) = 1050/(1000 × 0.6085099821) 應為
   172.55263%(差正好 1.05 倍);descent 與 line-gap 都對得上。Roboto 那組同樣的誤差。
   請幫我確認這是文件錯誤,還是我漏掉了什麼(例如 Poppins 實際適用的 ascent 是 1000)。
E. Incremental Font Transfer 在 2026-09 的精確狀態:規格階段與日期、Chrome 是否已從
   Intent to Prototype 進到 Intent to Ship(哪一版)、Gecko 與 WebKit 的 standards-positions
   有沒有更新、有沒有實作報告或 WPT 測試。tech(incremental) 與 font-tech(incremental)
   的語法是否已定案。
F. font-tech() / font-format() / at-rule() 在 2026-09 的支援狀態(含桌機 Safari),以及
   Baseline 是 newly 還是 widely available、判定日期。
G. 對 CJK(特別是繁體中文)的 webfont 交付,在 IFT 還不能用的前提下,2026 年有哪些實際
   可用的策略?請具體到「分片粒度怎麼切」、「常用字表用哪一份」、以及各策略在
   unicode-range 已知的 malformed text 問題上的風險。
 
輸出格式:先給「判定表」(每條標 正確 / 部分正確 / 錯誤 / 查不到 + 一句理由),
再給 A–G 的查證結果(每項附連結與你看到的日期),最後列出「你認為我漏掉、但在
多語系產品的字型交付上會實際造成 CLS 或文字延遲的三件事」。