中文字型子集化:把數 MB 的字型縮到 100KB
中文字型的體積問題 只有一個真正的解法:不要載入用不到的字。
| 字形數量 | woff2 壓縮後 | |
|---|---|---|
| 完整的中日韓字集 | 20,000+ | 4MB 以上 |
| 繁體中文常用字 | 約 13,000 | 1-3MB |
| 一篇文章實際用到 | 約 800-1,500 | 80-150KB |
一篇文章用到的字不到常用字的十分之一,這就是子集化的空間。
兩種策略
| 策略 | 做法 | 適合 |
|---|---|---|
| 字碼範圍分片 | 切成多個檔案,瀏覽器挑用得到的下載 | 內容會變動 |
| 依實際用字產生子集 | 掃過所有文字,只留出現過的字 | 內容固定的靜態站 |
判斷方式:內容會變嗎?
- 部落格、電商、有使用者留言 → 分片(使用者可能載入任何一個字)
- 文件站、官網、產品頁 → 依用字產生(能壓到最小)
內容會變動的網站硬用第二種會 缺字。
策略一:unicode-range 分片
把字型切成多個檔案,每個檔案宣告自己負責的字碼範圍。瀏覽器只下載頁面實際用到的那幾片:
@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-303F 與 U+FF00-FFEF 這兩段裝的是全形標點。漏掉的話中文內容的逗號、句號、引號會退回備援字型,混著兩種字型看起來很怪。
分片會不會讓請求變太多
會多幾個請求,但總下載量少很多
一篇中文文章通常只涵蓋 兩到四個分片。假設每片 100KB,總共 400KB,相較於整份 3MB 的字型,仍然省了非常多。
在支援多工的連線協定下,多幾個小請求的成本很低。
策略二:依實際用字產生子集
掃過網站所有文字,只留 實際出現過的字:
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 的文字 |
| 元件裡的固定文字 | 按鈕標籤、提示訊息 |
| frontmatter | title、description |
| 錯誤頁與空狀態 | 平常看不到但存在 |
一定要保留備援字型堆疊
body {
font-family: "MySubsetFont", "Microsoft JhengHei", "PingFang TC", sans-serif;
}缺字時會退回備援字型顯示,這比顯示成空白方框好得多。這是子集化的安全網,不要省。
woff2 是必須的
| 格式 | 相對體積 | 建議 |
|---|---|---|
| TTF / OTF | 100% | 不要直接用在網頁上 |
| WOFF | 約 80% | 已無必要 |
| WOFF2 | 約 70% | 只提供這個就好 |
@font-face {
font-family: "MyFont";
/* 只需要 woff2,支援度已經很完整 */
src: url("/fonts/myfont.woff2") format("woff2");
font-display: swap;
}不必再提供舊格式備援
早期的寫法會列出四五種格式。現在 woff2 的支援度已經很完整,新專案只提供它就好,少幾行設定,也不必產生多份檔案。
自架還是 Google Fonts
| Google Fonts | 自架 | |
|---|---|---|
| 設定成本 | 低(一行 <link>) | 高(要自己切片與部署) |
| 分片 | 已切好(上百片) | 自己決定 |
| 能壓到多小 | 依它的分片 | 可以更小(依用字) |
| 跨網域連線 | 多一次 | 無 |
| 快取控制 | 不能自訂 | 完全掌控 |
| 第三方依賴 | 有 | 無 |
<!-- 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 的中文字型已經切成上百片,做得很細。但 自架加上依用字產生的子集通常能壓得更小,因為它切的是通用範圍,您切的是自己真正用到的字。
另外自架少一次跨網域連線,也能設自己的快取策略。細節見 資源提示 。
完整的自動化流程
以靜態文件站為例:
// 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`);
}{
"scripts": {
"font:subset": "node scripts/build-font-subset.mjs"
}
}內容改了要重跑
新增文章可能引入新字。把這一步放進建置流程(或至少在 CI 檢查有沒有缺字),不要靠人記得。
{
"scripts": {
"font:subset": "node scripts/build-font-subset.mjs",
"prebuild": "npm run font:subset"
}
}怎麼驗證
| 檢查 | 怎麼做 |
|---|---|
| 實際下載了哪些分片 | DevTools 的 Network 面板篩選 font |
| 有沒有缺字 | 全站瀏覽,看有沒有字退回備援字型 |
| 子集的大小 | 直接看檔案大小 |
| 字型有沒有被用到 | DevTools 的 Coverage 面板 |
缺字的視覺特徵
子集裡沒有的字會用備援字型顯示,中文的話通常是字重、字寬明顯不同的一個字夾在句子裡,看起來很突兀。
比對前後兩個版本的截圖是最快的檢查方式。
這個站的做法
這個站沒有做子集化,因為沒有用網頁字型
body {
font-family:
system-ui, -apple-system, "Segoe UI", "Noto Sans TC",
"PingFang TC", "Microsoft JhengHei", sans-serif;
}用系統字型的結果是 完全不需要這一整篇的技術,沒有字型檔要下載、要切片、要預載,也不會缺字。
這是文件站合理的取捨。 子集化是有品牌字型需求時的必要手段,不是每個專案都得做的功課。
如果之後要引入品牌字型,這個站會走「自架 + 依用字產生子集」那條路,因為它是內容固定的靜態站,正好是那個策略最適合的情況。
檢查清單
| 項目 | 標準 |
|---|---|
| 有做子集化(若用網頁字型) | 必備 |
| 格式是 woff2 | 必備 |
| 分片有涵蓋全形標點的字碼範圍 | 必備 |
| 保留完整的備援字型堆疊 | 必備 |
每個 @font-face 有 font-display | 必備 |
| 依用字產生時,掃描來源涵蓋所有顯示文字 | 必備 |
| 子集化納入建置流程 | 建議 |
| 只預載首屏的一個字型檔 | 建議 |
| 已用 Network 面板確認下載的分片 | 建議 |
| 全站瀏覽確認沒有缺字 | 建議 |
常見問題
兩種子集化策略該選哪一個?
內容固定的靜態網站選依實際用字產生子集,能壓到最小。內容會變動的網站選字碼範圍分片,因為使用者可能載入任何一個字。有動態內容的網站硬用前者會出現缺字。
分片會不會讓請求變太多?
會多幾個請求,但瀏覽器只會下載實際用到的分片。一篇中文文章通常只涵蓋兩到四個分片,總下載量遠低於整份字型。在支援多工的連線協定下,多幾個小請求的成本很低。
woff2 一定要用嗎?
是,它是目前壓縮率最好的網頁字型格式,比未壓縮的字型檔小約三成。支援度也已經很完整,新專案不必再提供舊格式的備援。
依用字產生子集要怎麼避免缺字?
掃描來源時要涵蓋所有會顯示的文字,包含標記檔案、設定檔的標題與描述、元件裡的固定文字。並且務必保留完整的備援字型堆疊,缺字時才不會顯示成空白方框。
Google Fonts 已經做過子集化了嗎?
有,它的中文字型會切成上百個分片並用字碼範圍宣告。省事而且分片切得很細,但仍多一次跨網域連線。自架加上依用字產生的子集通常能壓得更小。