Skip to main content

字體載入效能

竟然沒有任何一篇繁體中文文章說字體要怎麼載入效能更高,中文不是受到字體載入影響最大的語言嗎?怎麼會一篇文章都沒有提到效能最佳化問題?太扯了吧。

以 Google font 為例,如果按照 Google font 給你的指令照做,在 head 設定 preconnect,只做這種預設的載入方式,你的 PageSpeed Insights 是可以瞬間從 100 分掉到 60 分的,就算原本滿分,只要載入一份字體就足以造成毀滅性的分數打擊。本文簡單描述該怎麼正確載入。

Google Font Tracking

先講隱私權問題,網路有很多說法是 Google font 會追蹤用戶資訊,這是錯的,Google 取得到的是網路傳輸必要的資訊:IP、標頭等基本資訊,沒有這些資訊你也沒網路可以用了。

在歐洲 GDPR 隱私規範極度嚴格的情況下才會有問題,但是是網站被告不是 Google 本身被告,原因是網站本身沒有告訴用戶他使用 Google Font,這代表在嚴格的 GDPR 規範中,基礎連線資訊被不知情的傳到 Google,所以被告的是網站而不是 Google。

Claude 上網搜尋整理的資訊

https://www.lhr-law.de/magazin/datenschutzrecht/google-fonts/

被告是網站經營者,不是Google。

這位使用者多次瀏覽被告的網站,被告在該網站上使用了Google Fonts,字型從一個去中心化的資料庫載入。載入網站時會與Google伺服器建立連線,並將原告的動態IP位址等資訊傳送給Google。原告事前並未同意這項傳輸。被告則主張自己使用Google Fonts是基於正當利益(legitimate interest)作為依據。

判決結果方面,法院判被告須支付原告100歐元的損害賠償,並依據德國民法第823條第1項與第1004條類推適用,禁止被告未來再將原告的IP位址揭露給Google。

法院的法理基礎是,被告未經授權將原告的動態IP位址揭露給Google,構成對一般人格權中「資訊自決權」的侵害,這項權利包含個人有權決定並揭露自己個人資料的使用方式。法院也說明,IP位址之所以構成個人資料,是因為網站經營者可以結合其他資訊利用這筆資料來確認網路使用者的身分。

值得一提的是,這個判決後續在奧地利跟德國引發了大量的濫訴案件。有心人士利用網路爬蟲自動搜尋仍在動態載入Google Fonts的網站,然後寄發警告信要求對方支付170歐元和解金,這演變成一種詐騙手法。慕尼黑地方法院在2023年3月30日的後續判決中,一方面確認動態整合Google Fonts且未取得使用者同意確實可能構成資訊自決權的侵害,但同時也澄清,只有資料實際上被非自願地傳送到Google伺服器的人才有資格主張GDPR求償,沒有親自造訪過該網站的人不受此保護。

所以整體脈絡是:原告是網站的真實訪客,被告是網站經營者,Google作為資料接收方雖然涉入其中,但在這起訴訟裡並非被告本身,責任落在選擇使用該服務且未取得同意的網站經營者身上。

檔案格式

有 TTF/OTF,WOFF,WOFF2 幾種格式,用 WOFF2 就對了,檔案更小載入更快。

糟糕的載入方式

@import url

@import url('https://fonts.googleapis.com/css2?family=Roboto');

最糟糕,阻塞 CSS 的解析,瀏覽器必須先下載並解析這個 import,才能繼續處理後面的樣式。

缺少 font-display

@font-face 若缺少 font-display 會造成 FOIT(Flash of Invisible Text),文字完全隱藏直到字體下載完成,使用者會看到一片空白。

webfontloader

typekit/webfontloader 在2026的瀏覽器規範中已經沒有使用的意義了,你也可以看到專案更新是十年前。

Claude 搜尋整理的資訊

基本上意義已經很小了。原因如下:

原生方案已足夠成熟

CSS font-display(swap、optional 等)現在已有 95%以上的瀏覽器支援,可以單靠 CSS 控制字型載入時的顯示行為,不需要 JS 介入。

原生 CSS Font Loading API 從 2020 年 1 月起就已被主流瀏覽器廣泛支援,能用 document.fonts.load() 這類原生方法直接偵測字型載入狀態,效果與 Web Font Loader 當年提供的 wf-loadingwf-active class 機制幾乎相同,但不需要載入這支額外的 polyfill 腳本。

這支 library 的歷史定位

它是 2016 年為了因應舊瀏覽器(IE、舊版 Safari)不支援原生字型偵測而設計的 polyfill,同時封裝了 Google Fonts、Typekit、Fontdeck、Monotype 等多家服務商的載入邏輯,簡化跨平台開發者的串接工作。

2026 年還可能用到的情境

  • 仍需支援極舊瀏覽器(雖然這類需求已經非常少見)
  • 專案沿用舊架構,尚未遷移到原生 API 或 font-display
  • 需要它整合多家字型服務商 API 的便利包裝功能

如果是新專案,直接用 font-display: swap 搭配原生 Font Loading API 會是更輕量、更現代的做法,不需要載入這支 2016 年的 polyfill。

好的方式

要同時做到以下幾點。

font-display: swap

@font-face 搭配 font-display: swap

@font-face {
font-family: 'MyFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}

先用系統字體顯示文字,字體載入完再替換,避免 FOIT,但會有 FOUT(Flash of Unstyled Text)造成版面跳動。

搭配 preload 和 preconnect

preload 告訴瀏覽器提前下載這個資源,等到真正解析到 <link rel="stylesheet"> 或 CSS 裡的 @font-face 時就能直接使用,不用重新發一次請求;preconnect 則是提前跟該網域完成 DNS 查詢、TCP 連線、TLS 握手,等真正要抓資源時省去這段建立連線的時間。

這也是 Google font 官方的做法,用起來像是這樣:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Noto+Serif+TC:wght@200..900&family=Noto+Serif+JP:wght@200..900&family=Noto+Serif+SC:wght@200..900&display=swap"
rel="stylesheet">

非阻塞載入 CSS

fonts.googleapis.com 載入的 CSS 還是阻塞性的,這代表要到外部載入一個上百 KB 的字體索引 CSS。既然本身都會 swap 顯示了,更乾脆的做法是連 CSS 都設定非阻塞,反正都會 swap。

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Noto+Serif+TC:wght@200..900&family=Noto+Serif+JP:wght@200..900&family=Noto+Serif+SC:wght@200..900&display=swap"
onload="this.onload=null;this.rel='stylesheet'">

子集化

把字體拆成 subset 不一次載入全部。只要你用到 woff2 字體應該都已經自動有這件事情,無須額外操心。

自架字體

自行提供字體檔案,連 preconnect 那段都不需要,再少一輪網路請求。自架也很簡單,除了直接下載 Google Font 字體自行 host 以外,也可以用 npm 套件完成,比如 @fontsource-variable1,使用方式請看官方文檔

在 Hugo 網站自架字體

Hugo 是自己一套生態系因此特別介紹 Hugo 如何用 @fontsource-variable

  1. 安裝

    pnpm add @fontsource-variable/noto-serif-tc
    pnpm add @fontsource-variable/noto-serif-jp
    pnpm add @fontsource-variable/noto-serif-sc
  2. 設定 CSS

    /* assets/css/font-face.css */
    @import "@fontsource-variable/noto-serif-tc";
    @import "@fontsource-variable/noto-serif-jp";
    @import "@fontsource-variable/noto-serif-sc";
  3. 設定 HTML 引用

    {{ with resources.Get "css/font-face.css" | css.Build }}
    {{ if hugo.IsDevelopment }}
    <link rel="stylesheet" href="{{ .RelPermalink }}">
    {{ else }}
    {{ with . | minify | fingerprint }}
    <link
    rel="preload"
    as="style"
    onload="this.onload=null;this.rel='stylesheet'"
    href="{{ .RelPermalink }}"
    integrity="{{ .Data.Integrity }}"
    crossorigin="anonymous">
    <noscript>
    <link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" crossorigin="anonymous">
    </noscript>
    {{ end }}
    {{ end }}
    {{ end }}

    由於 css.Build 背後是 esbuild,因此 woff2 檔案會自動打包以及加上 hash。

  4. 設定 _headers

    由於字體有 hash,因此應該設定長快取,這樣也會避免閃爍問題。以 Cloudflare 為例,可以在輸出根目錄,也就是 Hugo 的 static/_headers 設定

    /css/*.woff2
    Cache-Control: public, max-age=31536000, immutable

    除了字體,你應該也同時把 JS/CSS 檔案也都設定長快取。

css.Build 是 Hugo 唯一一個 streamline 使用 node_modules 字體的方式,否則只能手動 vendor 字體或是用 module mount,前者會增加 repo 大小,後者不會,兩者都需要手動將字體路徑對齊 @font-face url 設定。

Footnotes

  1. 能用 variable 版本就用 variable 版本,因為 @fontsource-variable 是單一字型檔案內建整個字重軸(例如 200-900),@fontsource 則是每個字重獨立切出來(400.css、700.css 各自對應一個檔案),後者網路負擔更大。