Skip to content

中文字型子集化:把數 MB 的字型縮到 100KB

中文字型子集化:unicode-range 與 woff2

中文字型的體積問題 只有一個真正的解法:不要載入用不到的字

字形數量woff2 壓縮後
完整的中日韓字集20,000+4MB 以上
繁體中文常用字約 13,0001-3MB
一篇文章實際用到約 800-1,50080-150KB

一篇文章用到的字不到常用字的十分之一,這就是子集化的空間。

兩種策略

策略做法適合
字碼範圍分片切成多個檔案,瀏覽器挑用得到的下載內容會變動
依實際用字產生子集掃過所有文字,只留出現過的字內容固定的靜態站

判斷方式:內容會變嗎?

  • 部落格、電商、有使用者留言 → 分片(使用者可能載入任何一個字)
  • 文件站、官網、產品頁 → 依用字產生(能壓到最小)

內容會變動的網站硬用第二種會 缺字

策略一:unicode-range 分片

把字型切成多個檔案,每個檔案宣告自己負責的字碼範圍。瀏覽器只下載頁面實際用到的那幾片

css
@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont-1.woff2") format("woff2");
  unicode-range: U+0020-007E; /* 基本拉丁字母與標點 */
  font-display: swap;
}

@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont-2.woff2") format("woff2");
  unicode-range: U+4E00-4FFF; /* 中日韓統一表意文字第一段 */
  font-display: swap;
}

@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont-3.woff2") format("woff2");
  unicode-range: U+5000-5FFF;
  font-display: swap;
}

所有分片用同一個 font-family

瀏覽器把它們視為同一個字族的不同部分,依 unicode-range 自動挑。CSS 裡只要寫 font-family: "MyFont" 就好。

常見的中文字碼範圍

範圍內容
U+0020-007E基本拉丁字母、數字、標點
U+3000-303F中日韓標點符號(全形逗號、句號)
U+4E00-9FFF中日韓統一表意文字(主要區塊)
U+FF00-FFEF全形字元

實務上會把 U+4E00-9FFF 這個大區塊再切細,例如:每 1000 個字碼一片。

不要忘記標點符號

U+3000-303FU+FF00-FFEF 這兩段裝的是全形標點。漏掉的話中文內容的逗號、句號、引號會退回備援字型,混著兩種字型看起來很怪。

分片會不會讓請求變太多

會多幾個請求,但總下載量少很多

一篇中文文章通常只涵蓋 兩到四個分片。假設每片 100KB,總共 400KB,相較於整份 3MB 的字型,仍然省了非常多。

在支援多工的連線協定下,多幾個小請求的成本很低。

策略二:依實際用字產生子集

掃過網站所有文字,只留 實際出現過的字

js
import { readdir, readFile } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile);

// 1. 掃過所有 Markdown,收集用到的字
async function collectChars(dir) {
  const chars = new Set();
  const files = await readdir(dir, { recursive: true });

  for (const file of files.filter((f) => f.endsWith('.md'))) {
    const text = await readFile(`${dir}/${file}`, 'utf8');
    for (const char of text) chars.add(char);
  }

  return [...chars].join('');
}

// 2. 用 fonttools 產生子集
const text = await collectChars('docs/src');

await run('pyftsubset', [
  'fonts/source.ttf',
  `--text=${text}`,
  '--flavor=woff2',
  '--output-file=docs/src/public/fonts/subset.woff2',
  '--layout-features=*',
]);

掃描來源要涵蓋所有會顯示的文字

漏掉的地方會缺字:

容易漏掉說明
設定檔裡的標題與描述nav、sidebar 的文字
元件裡的固定文字按鈕標籤、提示訊息
frontmattertitle、description
錯誤頁與空狀態平常看不到但存在

一定要保留備援字型堆疊

css
body {
  font-family: "MySubsetFont", "Microsoft JhengHei", "PingFang TC", sans-serif;
}

缺字時會退回備援字型顯示,這比顯示成空白方框好得多。這是子集化的安全網,不要省。

woff2 是必須的

格式相對體積建議
TTF / OTF100%不要直接用在網頁上
WOFF約 80%已無必要
WOFF2約 70%只提供這個就好
css
@font-face {
  font-family: "MyFont";
  /* 只需要 woff2,支援度已經很完整 */
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: swap;
}

不必再提供舊格式備援

早期的寫法會列出四五種格式。現在 woff2 的支援度已經很完整,新專案只提供它就好,少幾行設定,也不必產生多份檔案。

自架還是 Google Fonts

Google Fonts自架
設定成本低(一行 <link>高(要自己切片與部署)
分片已切好(上百片)自己決定
能壓到多小依它的分片可以更小(依用字)
跨網域連線多一次
快取控制不能自訂完全掌控
第三方依賴
html
<!-- Google Fonts:省事,記得加 preconnect 與 display 參數 -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
  href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&display=swap"
  rel="stylesheet"
>

中文字型建議自架

Google Fonts 的中文字型已經切成上百片,做得很細。但 自架加上依用字產生的子集通常能壓得更小,因為它切的是通用範圍,您切的是自己真正用到的字。

另外自架少一次跨網域連線,也能設自己的快取策略。細節見 資源提示

完整的自動化流程

以靜態文件站為例:

js
// scripts/build-font-subset.mjs
import { readdir, readFile, mkdir } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'node:path';

const run = promisify(execFile);
const SRC_DIR = 'docs/src';
const OUT_DIR = 'docs/src/public/fonts';

// 收集所有會顯示的文字
async function collectChars() {
  const chars = new Set();

  // Markdown 內容與 frontmatter
  const files = await readdir(SRC_DIR, { recursive: true });
  for (const file of files.filter((f) => f.endsWith('.md'))) {
    const text = await readFile(path.join(SRC_DIR, file), 'utf8');
    for (const char of text) chars.add(char);
  }

  // 設定檔裡的 nav 與 sidebar 文字
  for (const config of ['config/nav.js', 'config/sidebar']) {
    const stat = await readdir(config).catch(() => null);
    const targets = stat ? stat.map((f) => path.join(config, f)) : [config];
    for (const target of targets) {
      const text = await readFile(target, 'utf8').catch(() => '');
      for (const char of text) chars.add(char);
    }
  }

  return [...chars].join('');
}

const text = await collectChars();
console.log(`收集到 ${[...new Set(text)].length} 個不重複字元`);

await mkdir(OUT_DIR, { recursive: true });

for (const weight of [400, 700]) {
  await run('pyftsubset', [
    `fonts/source-${weight}.ttf`,
    `--text=${text}`,
    '--flavor=woff2',
    `--output-file=${OUT_DIR}/subset-${weight}.woff2`,
    '--layout-features=*',
  ]);
  console.log(`✓ subset-${weight}.woff2`);
}
json
{
  "scripts": {
    "font:subset": "node scripts/build-font-subset.mjs"
  }
}

內容改了要重跑

新增文章可能引入新字。把這一步放進建置流程(或至少在 CI 檢查有沒有缺字),不要靠人記得。

json
{
  "scripts": {
    "font:subset": "node scripts/build-font-subset.mjs",
    "prebuild": "npm run font:subset"
  }
}

怎麼驗證

檢查怎麼做
實際下載了哪些分片DevTools 的 Network 面板篩選 font
有沒有缺字全站瀏覽,看有沒有字退回備援字型
子集的大小直接看檔案大小
字型有沒有被用到DevTools 的 Coverage 面板

缺字的視覺特徵

子集裡沒有的字會用備援字型顯示,中文的話通常是字重、字寬明顯不同的一個字夾在句子裡,看起來很突兀。

比對前後兩個版本的截圖是最快的檢查方式。

這個站的做法

這個站沒有做子集化,因為沒有用網頁字型

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

用系統字型的結果是 完全不需要這一整篇的技術,沒有字型檔要下載、要切片、要預載,也不會缺字。

這是文件站合理的取捨。 子集化是有品牌字型需求時的必要手段,不是每個專案都得做的功課。

如果之後要引入品牌字型,這個站會走「自架 + 依用字產生子集」那條路,因為它是內容固定的靜態站,正好是那個策略最適合的情況。

檢查清單

項目標準
有做子集化(若用網頁字型)必備
格式是 woff2必備
分片有涵蓋全形標點的字碼範圍必備
保留完整的備援字型堆疊必備
每個 @font-facefont-display必備
依用字產生時,掃描來源涵蓋所有顯示文字必備
子集化納入建置流程建議
只預載首屏的一個字型檔建議
已用 Network 面板確認下載的分片建議
全站瀏覽確認沒有缺字建議

常見問題

兩種子集化策略該選哪一個?

內容固定的靜態網站選依實際用字產生子集,能壓到最小。內容會變動的網站選字碼範圍分片,因為使用者可能載入任何一個字。有動態內容的網站硬用前者會出現缺字。

分片會不會讓請求變太多?

會多幾個請求,但瀏覽器只會下載實際用到的分片。一篇中文文章通常只涵蓋兩到四個分片,總下載量遠低於整份字型。在支援多工的連線協定下,多幾個小請求的成本很低。

woff2 一定要用嗎?

是,它是目前壓縮率最好的網頁字型格式,比未壓縮的字型檔小約三成。支援度也已經很完整,新專案不必再提供舊格式的備援。

依用字產生子集要怎麼避免缺字?

掃描來源時要涵蓋所有會顯示的文字,包含標記檔案、設定檔的標題與描述、元件裡的固定文字。並且務必保留完整的備援字型堆疊,缺字時才不會顯示成空白方框。

Google Fonts 已經做過子集化了嗎?

有,它的中文字型會切成上百個分片並用字碼範圍宣告。省事而且分片切得很細,但仍多一次跨網域連線。自架加上依用字產生的子集通常能壓得更小。

延伸閱讀

參考資料:web.dev:Reduce web font size