Skip to content

font-display 與字型預載:消除文字閃爍與版面跳動

font-display 與字型預載:消除閃爍與跳動

font-display 決定 字型還沒載入完時,文字怎麼顯示。這一個屬性同時影響 LCPCLS,對 中文網站 尤其關鍵,因為中文字型動輒數 MB。

css
@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: swap;
}

兩個時間窗

要理解五個值,先理解瀏覽器把載入期間切成的兩段:

期間說明
阻塞期(block period)這段時間內若字型未載入完,文字 完全不顯示
交換期(swap period)這段時間內若字型載入完,立刻換上
之後字型才載入完的話,這次造訪就不用了

五個值的差別就是這兩段各有多長。

五個值

阻塞期交換期行為
auto依瀏覽器依瀏覽器通常等同 block
block約 3 秒無限先空白,載入完換上
swap0無限立刻顯示備援,載入完換上
fallback約 100 毫秒約 3 秒短暫空白,超時就放棄換
optional約 100 毫秒0短暫空白,沒趕上就整次造訪不用

該選哪一個

中文網站預設選 swap

css
font-display: swap;

它保證 文字立刻顯示(用系統字型),字型載入完再換掉。使用者至少讀得到內容。

代價是換字時會閃一下、可能造成版面位移,那可以用下面的 度量對齊 解決。

不要用 block(或讓它變成 auto)

block 在字型載入完成前 完全不顯示文字,最長 3 秒。

英文字型可能只是一瞬間,但中文字型要載入數 MB,那段空白可能長達數秒。使用者面對一片空白的頁面,而 LCP 也會一併被算進去(文字沒繪製,就沒有最大內容元素)。

auto 在多數瀏覽器上的行為等同 block,所以 不要不寫這個屬性

optional:完全不要位移

css
@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: optional;
}

這是 CLS 最安全的選擇

optional 的交換期是 0,字型如果在極短時間內沒載入完,這次造訪就一直用備援字型,之後也不會換。

所以 完全不會有換字造成的版面位移。載入完的字型會存進快取,下次造訪就用得到。

代價是使用者第一次造訪可能看不到您的品牌字型。品牌識別沒那麼要緊的話,這是很划算的取捨。

fallback:兩者之間

短暫空白(約 100 毫秒,通常察覺不到),之後給 3 秒的交換期。3 秒內載入完就換,沒趕上就放棄。

css
font-display: fallback;

適合「希望盡量用品牌字型,但不要讓使用者等太久」的情況。

預載字型

字型的下載通常晚一步,瀏覽器要先解析 CSS、算出有元素用到這個字型,才會開始下載。

html
<link
  rel="preload"
  as="font"
  type="font/woff2"
  href="/fonts/myfont.woff2"
  crossorigin
>

crossorigin 是必填,即使是同網域

html
<!-- 錯誤:會下載兩次 -->
<link rel="preload" as="font" href="/fonts/myfont.woff2">

字型的請求 一律以匿名跨來源模式發出(這是規格規定,即使字型放在同一個網域)。預載時不加 crossorigin,瀏覽器會認為預載的請求與實際使用的請求是兩個不同的請求,結果下載兩次,完全失去預載的意義。

只預載一個

預載多個字重會反效果

html
<!-- 四個字重互相搶頻寬,LCP 反而變差 -->
<link rel="preload" as="font" href="/fonts/regular.woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/bold.woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/light.woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/italic.woff2" crossorigin>

只預載首屏一定會用到的那一個,通常是正文的常規字重。標題用的粗體通常不必,標題文字量少,晚一點換字影響很小。

詳見 資源提示 preload、preconnect

用第三方字型服務時

html
<!-- 提早建立連線 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- 字型 CSS 帶上 display 參數 -->
<link
  href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&display=swap"
  rel="stylesheet"
>

網址的 display=swap 參數

Google Fonts 產生的 CSS 會依這個參數在每個 @font-face 裡加上 font-display一定要加,不加的話預設是 auto(也就是 block)。

減少換字時的版面跳動

備援字型與目標字型的 字寬與行高不同,換字的瞬間文字重新排列,把下方內容推移,這計入 CLS

解法是宣告一個 調整過度量的備援字族

css
/* 以系統字型為來源,調整度量讓它接近目標字型 */
@font-face {
  font-family: "FallbackAdjusted";
  src: local("Microsoft JhengHei"), local("PingFang TC");
  size-adjust: 97%;
  ascent-override: 88%;
  descent-override: 12%;
  line-gap-override: 0%;
}

body {
  /* 目標字型 → 調整過的備援 → 一般備援 */
  font-family: "MyFont", "FallbackAdjusted", sans-serif;
}
屬性調整什麼
size-adjust整體字級的縮放比例
ascent-override基線以上的高度
descent-override基線以下的高度
line-gap-override行距

怎麼算出這些數字

理想做法是量測兩種字型的實際度量再算比例。實務上可以:

  1. 用瀏覽器開發者工具比較兩種字型下同一段文字的高度。
  2. 調整 size-adjust 直到兩者的行數與總高度一致。
  3. 用 Rendering 面板的 Layout Shift Regions 確認換字時不再閃藍色。

也有工具能自動產生這組數值。

三個屬性一起用

一份完整的字型設定:

html
<head>
  <!-- 1. 預載首屏的一個字型檔 -->
  <link rel="preload" as="font" type="font/woff2" href="/fonts/main-400.woff2" crossorigin>
  <link rel="stylesheet" href="/styles.css">
</head>
css
/* 2. 目標字型,用 swap 保證文字立刻顯示 */
@font-face {
  font-family: "MyFont";
  src: url("/fonts/main-400.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
  /* 只載入實際用到的字碼範圍 */
  unicode-range: U+4E00-9FFF, U+0020-007E;
}

@font-face {
  font-family: "MyFont";
  src: url("/fonts/main-700.woff2") format("woff2");
  font-weight: 700;
  font-display: swap;
  unicode-range: U+4E00-9FFF, U+0020-007E;
}

/* 3. 調整過度量的備援,減少換字時的位移 */
@font-face {
  font-family: "FallbackAdjusted";
  src: local("Microsoft JhengHei"), local("PingFang TC");
  size-adjust: 97%;
  ascent-override: 88%;
  descent-override: 12%;
}

body {
  font-family: "MyFont", "FallbackAdjusted", sans-serif;
}

unicode-range 的分片做法見 中文字型子集化

不要用 JavaScript 控制字型載入

這是過時的做法

早期沒有 font-display 時,常見的做法是用程式碼偵測字型載入完成再加上一個類別:

js
// 過時:現在用 font-display 就好
document.fonts.ready.then(() => {
  document.documentElement.classList.add('fonts-loaded');
});

現在 font-display 已經涵蓋這個需求,而且不需要額外的 JavaScript。多一段程式碼反而增加 INP 的風險。

document.fonts API 仍有用途,例如:要在字型載入後才畫 canvas 文字。但純粹的文字顯示不需要它。

這個站的做法

這個站完全不用網頁字型

css
body {
  font-family:
    system-ui, -apple-system, "Segoe UI", "Noto Sans TC",
    "PingFang TC", "Microsoft JhengHei", sans-serif;
}

用系統字型堆疊的結果:

好處說明
沒有額外請求不必下載任何字型檔
沒有 FOIT 或 FOUT文字第一次繪製就是最終樣子
沒有換字造成的位移CLS 完全不受字型影響
不必設 font-display沒有 @font-face 就沒這個問題
不必預載少一個要調的優先權

這是一個文件站可以做的取捨,內容的可讀性遠比字型的獨特性重要。省下來的力氣放在 圖片 與內容上,回報更大。

有品牌字型需求的專案則照上面「三個屬性一起用」的組合做。

檢查清單

項目標準
每個 @font-face 都有 font-display必備
值是 swapoptional,不是 autoblock必備
預載的字型有 crossorigin必備
只預載一個字型檔必備
第三方字型服務的網址帶 display=swap必備
有處理備援字型的度量落差建議
preconnect 提早連線(第三方字型)建議
沒有用 JavaScript 控制字型顯示建議
用 Layout Shift Regions 驗證過換字不位移建議

常見問題

五個值該選哪一個?

中文網站預設選 swap,它保證文字立刻顯示,只是之後會換字。極度在意 版面穩定度 的話選 optional,它完全不會有換字造成的位移,代價是首次造訪可能看不到品牌字型。

為什麼不建議用 block?

它在字型載入完成前完全不顯示文字。英文字型可能只是一瞬間,但中文字型動輒數 MB,那段空白可能長達數秒,使用者面對的是一片空白的頁面,載入速度指標 也會一併被算進去。

預載字型一定要加 crossorigin 嗎?

一定要,即使是同網域的字型。字型的請求一律以匿名跨來源模式發出,預載時不加這個屬性會讓瀏覽器認為是兩個不同的請求而下載兩次,完全失去預載的意義。

要預載幾個字型檔?

只預載首屏一定會用到的那一個,通常是正文的常規字重。預載了四個字重等於讓它們互相搶頻寬,反而拖慢載入速度指標。標題用的粗體字重通常不必預載。

怎麼減少換字時的版面跳動?

size-adjust 這類度量覆寫屬性讓備援字型的字寬與行高接近目標字型。宣告一個以系統字型為來源的備援字族,調整它的縮放比例與上下高度,再把它放在字族清單裡目標字型的後面。

延伸閱讀

參考資料:web.dev:font-display