昨天在「更正三」留了兩個沒填的洞。第一個是「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 period | swap period | 規格用語 |
|---|---|---|---|
auto | UA 自訂 | 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 或以下) | 短(建議 3s) | short 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 值的比例是:
| 值 | 桌機 | 行動 |
|---|---|---|
swap | 49.6% | 50.1% |
block | 約 24.9% | 約 24.9% |
auto | 8.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 期間文字是隱形的,使用者看不到位移,所以 block 比 swap 安全。」前半句對,後半句錯。 web.dev 把這件事說得很清楚:block 的初始文字顯示被延後,但它仍然可以造成位移,因為文字其實是被「畫成隱形的」,佔的位是 fallback 字型的位;webfont 載入後需要的空間可能不同,於是就位移了。
這件事分成兩層要分開:
- 視覺上,
block的位移「比較不刺眼」,因為使用者沒有看到文字從一個字型跳到另一個字型——他看到的是文字從無到有,同時周圍內容移動。 - 指標上,位移一樣被記。CLS 量的是 layout shift,不是「文字外觀變化」。隱形文字佔位造成的後續位移照樣進 CLS。
web.dev 那張表下面的註記把四個值一起框起來:auto、block、swap、fallback 都有造成位移的可能;這四個裡面 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 就是被推動的元素,previousRect 與 currentRect 是位移前後的矩形。沒有 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 上有一個引擎是例外。
| macOS | Windows | |
|---|---|---|
| Chromium | 一律用 hhea | 設了 USE_TYPO_METRICS 用 typo,否則用 win |
| Firefox | 設了 USE_TYPO_METRICS 用 typo,否則用 hhea | 設了 USE_TYPO_METRICS 用 typo,否則用 win |
| Safari | 一律用 hhea | 設了 USE_TYPO_METRICS 用 typo,否則用 win |
也就是說:Firefox 是唯一在 macOS 上尊重 USE_TYPO_METRICS 的引擎。 Chromium 與 Safari 在 macOS 上一律讀 hhea,不管字型作者有沒有表態。直接後果是——一個設了 USE_TYPO_METRICS 且 hhea 與 sTypo* 不同值的字型,在同一台 Mac 上、同一份 CSS 下,Firefox 與 Safari 的行高會不同。昨天我把 macOS 當成單一情況處理,那是錯的。(這也解釋了昨天為什麼建議「hhea 應該設成跟 sTypo* 同值」:那個建議的真正作用是讓上面那張表的九格全部收斂到同一個答案。)
這張表也給出「什麼時候一組覆寫值可以跨作業系統通用」的判準。Chrome 部落格說,對絕大多數字型(例如 Google Fonts 託管的字型裡約 90%),度量覆寫值可以在不知道使用者作業系統的情況下安全使用——因為對這些字型來說,不管適用 hhea、typo 還是 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-adjust:538.0103768 / 884.1438804 = 0.6085099821 → 60.85099821%,對得上。
再驗其餘三個。1 / 0.6085099821 = 1.6433584。照文中自己給的式子:
descent-override = 350 / (1000 × 0.6085099821) = 0.35 × 1.6433584 = 0.5751754→ 57.51754%,對得上。line-gap-override = 100 / (1000 × 0.6085099821) = 0.10 × 1.6433584 = 0.1643358→ 16.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.8011739;descent-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.8912326 → 189.12326%。
兩組範例犯的是同一個系統性錯誤,所以這不是我算錯,是文件裡的算術與它自己列的式子不一致。(已標存疑) 我不能完全排除另一種解釋:Poppins 實際適用的那一組 ascent 恰好是 1000,而文中註解裡的 1050 是另一組度量(例如 typo 與 hhea 不同值)。但無論哪一種,這段文件內部是矛盾的:如果 ascent 真的是 1000,那 descent 用 350、line-gap 用 100 就得說明是從哪一組來的。
實務結論不是「Chrome 文件不可信」,而是:這類覆寫值一定要用工具產生並實測驗證,不要手抄部落格裡的數字。 這也正好解釋了為什麼 next/font、@nuxtjs/fontaine、Fontaine 這類工具存在的價值不只是省事——它們把「讀 hhea / typo / win 哪一組、要不要除 size-adjust、avgCharacterWidth 怎麼加權」這些一手一個坑的步驟固化成程式。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 等待。事件有 loading、loadingdone、loadingerror,其中 loadingdone 在字型集完成載入時觸發,而且在 Web Worker 裡可用。FontFace.status 有四個狀態:unloaded、loading、loaded,以及第四個表示失敗的狀態——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 裡依賴
textLength、getComputedTextLength()的排版。 - 用 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():前者測引擎是否支援某項字型技術(例如 variations、palettes、以及 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:
swap49.6%(桌機)/ 50.1%(行動)、block約 24.9%、auto8.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 issue383078471追蹤這個落差。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——要用
PerformanceObserver抓layout-shift並讀entry.sources(含node/previousRect/currentRect)。 - 度量覆寫路線一(只用 overrides):
ascent-override = ascent/upem、descent-override = descent/upem、line-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/2的xAvgCharWidth:subsetting 工具常不更新它,而且它的計算方法在規格歷史上改過。- 各引擎讀哪一組垂直度量(Chrome 官方表格):Chromium/mac 與 Safari/mac 一律
hhea;Firefox/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 bug219735)。size-adjust= Chrome/Edge 87、Firefox 89、Safari 17(2023-09),故 2023-09 起跨瀏覽器可用。 - Safari 只有等比的
size-adjust、沒有非等比的垂直修正,所以字型 swap 的垂直殘差在 Safari 上必然存在,只能靠分語言line-heighttoken 吃掉。 - Chrome 官方部落格的 Poppins 範例中
ascent-override與它自己列的式子不一致:size-adjust60.85099821%、descent-override57.51754455%、line-gap-override16.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(桌機):
preconnect18.3%、dns-prefetch14.6%、preload12.0%、prefetch0.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-face的src指向一般 OpenType(通常 WOFF2 包裝),以tech(incremental)或@supports的font-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、追蹤 bug498722796),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 重排更新的東西」:
canvas的fillText、SVG 的textLength/getComputedTextLength()、JS 量測寬度做的截斷與定位、虛擬清單快取的 item 高度。 - 兩階段
html.fonts-loadedclass 切換在 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 的 unitsPerEm、hhea 的 ascender / descender / lineGap、以及 OS/2 的 sTypo* 與 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_METRICS 且 hhea 與 sTypo* 不同值,Firefox 與 Chrome 在 macOS 上會給你不同的行高——這就是那張表的實證。
第六步:驗 preload 的 unicode-range 陷阱。 建三個 @font-face 指向三個不同分片(可以用 Google Fonts 的 CSS 直接抄 latin / latin-ext / cyrillic 三段),頁面上只放純 ASCII 內容。先不加 preload,看 Network 面板下載了幾個檔(應該只有 latin);再把三個都 <link rel="preload" as="font" crossorigin>,看下載了幾個。
自我檢查
- 四種
font-display之中,block那一區塊有沒有出現非零的layout-shiftentry?如果沒有,你的字型可能太快到達或還在 cache——把延遲拉到 2500ms、確認Disable cache有勾、再跑一次。這一題答對才算真的驗證了「更正一」。 -
optional那一區塊的位移是不是零?如果不是零,檢查兩件事:你有沒有 preload 它(沒 preload 的 optional 在舊行為下仍會 re-render 兩次),以及你的字型有沒有在 100ms 內到達(到達了就會被用,且 Chrome 83 後的優化讓它不位移)。 -
sources[].dy與sources[].dh是不是不同的元素在動?標題自己長高(dh)與下方方塊被推下去(dy)應該是兩筆不同的 source。如果只看到一筆,你的探針標記得不夠細。 - 把所有
layout-shiftentry 的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-range、font-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 或文字延遲的三件事」。