📋 目錄
如果你的網站在文字還沒載入時顯示的是 Arial,載入後突然變成了 Noto Sans CJK——那不只是「醜」的問題,這是 CLS(Cumulative Layout Shift,累積版面位移)在作祟。CLS 是 Google Core Web Vitals 之一,會直接影響搜尋排名。2026 年的 HTTP Archive 數據顯示,72% 的字體相關失敗來自 Swap Timing——也就是字體下載完成後、置換的那一刻發生的版面位移。Font Loading API 提供了程式化的控制能力,讓你可以在字體載入的不同階段做精確的處理。
為什麼字體會造成版面位移
font-display 的預設行為
當瀏覽器遇到 <link rel="stylesheet"> 載入字體時,有一個「等待期」(block period):
等待期(block period):
- 如果字體還沒下載完成,瀏覽器有兩個選擇:
- 等待(block)
- 馬上用 fallback 顯示(swap)
font-display 屬性決定了這個行為:
@font-face {
font-family: 'NotoSansCJK';
src: url('/fonts/NotoSansCJK.woff2') format('woff2');
font-display: swap; /* 用 fallback 顯示,等下載完再置換 */
}
swap 的問題
font-display: swap 是多數開發者推薦的策略——讓用戶盡快看到文字內容。但置換的那一刻,如果 fallback 字體和目標字體的 metrics(高、寬、間距)差異大,就會造成 CL:
/* fallback 字體(Arial) */
/* 字重不同、高度不同、字符寬度不同 */
/* 載入後置換成 Noto Sans CJK */
/* 整個版面突然跳了一下——這就是 CLS */
/* 72% 的字體失敗發生在這裡:swap timing */
Font Loading API:程式化控制
Font Loading API 讓你用 JavaScript 控制字體的載入過程。
FontFace:定義新字體
// 建立一個 FontFace 物件
const notoSans = new FontFace(
'NotoSansCJK',
'url(/fonts/NotoSansCJK-Regular.woff2) format("woff2")',
{
style: 'normal',
weight: '400',
}
);
// 註冊到 document.fonts
document.fonts.add(notoSans);
// 開始下載
notoSans.load();
document.fonts.load():主動下載
// 主動下載字體,下載完成後執行回調
document.fonts.load('16px "NotoSansCJK"').then((loadedFonts) => {
console.log(`載入了 ${loadedFonts.length} 個字體`);
// 可以這裡加 class 或執行其他邏輯
});
監聽載入狀態
// 監聽特定字體的載入完成
document.fonts.addEventListener('loadingdone', (event) => {
event.fontfaces.forEach((fontFace) => {
if (fontFace.family === 'NotoSansCJK') {
console.log('NotoSansCJK 載入完成');
document.documentElement.classList.add('fonts-loaded');
}
});
});
// 監聽所有字體載入完成
document.fonts.ready.then(() => {
console.log('所有字體載入完成');
});
零 CLS 的實作策略
策略 1:Fallback Metrics Matching
讓 fallback 字體的 metrics 與目標字體盡可能接近:
@font-face {
font-family: 'NotoSansCJK-Fallback';
src: local('Arial'); /* 使用系統字體作為 fallback */
/* 透過 ascent-override、descent-override 調整 metrics */
ascent-override: 90%;
descent-override: 25%;
line-gap-override: 0%;
size-adjust: 98%;
}
@font-face {
font-family: 'NotoSansCJK';
src: url('/fonts/NotoSansCJK-Regular.woff2') format('woff2');
font-display: swap;
/* 為 fallback 版本定義同樣的 override */
ascent-override: 90%;
descent-override: 25%;
line-gap-override: 0%;
size-adjust: 98%;
}
這些 *-override CSS 屬性讓你調整 fallback 字體的 metrics,使其與目標字體接近。工具如 Fontaine 可以自動生成這些值。
策略 2:程式化載入 + Class 控制
// 當關鍵字體載入完成後,才顯示對應文字
document.fonts.load('bold 700 16px "NotoSansCJK"').then(() => {
document.documentElement.classList.add('fonts-ready');
});
/* 預設:使用系統字體,避免下載前的 FOUT */
body {
font-family: system-ui, -apple-system, sans-serif;
}
/* 當字體載入完成後,才切換到 Noto Sans CJK */
.fonts-ready body {
font-family: 'NotoSansCJK', system-ui, -apple-system, sans-serif;
}
這個策略完全消滅了 swap 帶來的 CLS——因為文字一開始就使用正確的字體渲染,只不過是系統字體。字體下載完成後,瀏覽器才切換到 Noto Sans CJK,metrics 完全一致所以不會有位移。
策略 3:Progressive Loading + Unicode Range
只下載需要的字符,減少下載量:
<!-- 只下載 Latin 和基本標點符號 -->
<link rel="preload" href="/fonts/noto-sans-latin.woff2" as="font" type="font/woff2" crossorigin>
<!-- CJK 延後載入 -->
<script>
// 當用戶滾動到 CJK 內容區時,再載入 CJK 字體
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadCJKFonts();
observer.disconnect();
}
});
});
observer.observe(document.querySelector('.cjk-content'));
</script>
/* Unicode range:只下載需要的字符 */
@font-face {
font-family: 'NotoSansCJK';
src: url('/fonts/noto-sans-cjk-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
/* Latin 字符使用這個 */
}
策略 4:Preload 關鍵字體
<!-- 預先下載最重要的字體 -->
<link rel="preload" href="/fonts/noto-sans-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<!-- 讓瀏覽器盡快開始下載 -->
<link rel="preconnect" href="https://fonts.example.com" crossorigin>
font-display 各策略詳解
| 策略 | 等待期行為 | 置換行為 | CLS 風險 |
|---|---|---|---|
block | 隱藏文字(最多 3s) | 無置換 | 低 |
swap | 顯示 fallback | 下載後置換 | 高 |
fallback | 極短隱藏(~100ms) | 不置換 | 低 |
optional | 網路決定 | 不置換 | 最低 |
/* 根據字體重要性選擇 */
@font-face {
/* 重要標題:swap,但做好 metrics matching */
font-family: 'Brand-Font';
src: url('/fonts/brand-bold.woff2') format('woff2');
font-display: swap;
ascent-override: 95%;
descent-override: 25%;
}
@font-face {
/* 次要裝飾字體:optional,讓瀏覽器自行決定 */
font-family: 'Decorative';
src: url('/fonts/decorative.woff2') format('woff2');
font-display: optional;
}
72% Swap Timing 失敗的解決方案
HTTP Archive 2025 的數據顯示,72% 的字體失敗發生在 swap timing——字體下載完成後、置換的那一刻。這個失敗的根源是 fallback 字體和目標字體的 metrics 差異過大。
快速檢測
// 測量字體置換帶來的 CLS
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.sources) {
entry.sources.forEach((source) => {
if (source.node && source.node.style) {
console.log('Layout shift:', entry.value, source.node);
}
});
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
優先解決高影響的字體
// 找出哪些字體造成最大的版面位移
// 這些是需要優先優化的
實際工作流
Step 1:識別需要優化的字體
// 列出所有載入的字體
for (const fontFace of document.fonts) {
console.log({
family: fontFace.family,
weight: fontFace.weight,
style: fontFace.style,
status: fontFace.status,
});
}
Step 2:分類字體策略
// 關鍵字體(LOGO、標題):swap + metrics matching
// 正文字體:swap + metrics matching
// 裝飾/Icon 字體:optional
Step 3:實施優化
// 1. 預先載入關鍵字體
const criticalFonts = ['/fonts/heading.woff2', '/fonts/body.woff2'];
criticalFonts.forEach((url) => {
const font = new FontFace('Critical', `url(${url}) format("woff2")`);
document.fonts.add(font);
font.load();
});
// 2. 延後載入非關鍵字體
document.fonts.ready.then(() => {
loadSecondaryFonts();
});
// 3. 監控並修正 CLS
結語:字體效能是被低估的優化點
字體效能優化在多數前端效能工作裡優先級不高,但它對 CLS 的影響是直接而且可測量的。72% 的字體失敗來自 swap timing,這意味著多數的字體 CLS 問題可以透過 fallback metrics matching 解決。
Font Loading API 給了你程式化的控制能力——你可以決定什麼時候下載、什麼時候置換、置換時做什麼處理。配合 font-display 策略和 ascent-override 等 CSS 屬性,你可以做到真正的零 CLS 字體載入。
延伸閱讀
- Lighthouse Core Web Vitals 完整教學 — LCP、CLS、FID 優化攻略
- Interop 2026 瀏覽器大一統 — 瀏覽器相容性狀態
本文是「2026 Web 效能優化」系列文章之一。