中文 Web 字体长期是性能优化的痛点——10 MB 起步的字库往 CDN 一扔,首屏要么白屏要么字体闪一下。子集化是唯一靠谱的解法——只留页面实际用到的字。这篇带你完整走一遍:从原字体到上线 8 KB 的子集 + 完整 CSS 配置。
为什么中文字体那么大
英文字体只需要覆盖 ASCII 95 个字符 + 一些扩展拉丁,几百 KB 足够。中文要覆盖几万个汉字:
| 字符集 | 字数 | 思源黑体单字重大小 |
|---|---|---|
| ASCII | 95 | < 1 KB |
| GB2312 一级 | 3755 | ~800 KB |
| GB2312 全集 | 6763 | ~1.5 MB |
| GB18030 基本集 | 27484 | ~6 MB |
| GB18030 全集 | 70244 | ~10-15 MB |
| 含扩展 ABCD | 90000+ | ~15-20 MB |
每个汉字平均要 100-300 字节存储字形数据(笔画越多越大)。GB2312 已经够覆盖中文 99% 日常用字。
子集化原理
字体文件是若干个表(table):
| 表 | 内容 |
|---|---|
cmap | 字符 → 字形 ID 的映射 |
glyf / CFF | 字形数据(曲线 / 笔画) |
head / hhea | 字体元数据 |
name | 字体名字符串 |
hmtx | 字符宽度 |
kern / GPOS | 字距调整 |
子集化做的事——
- 解析原字体所有表
- 按指定字符列表筛
cmap - 只保留对应的
glyf字形数据 - 重新生成
head/hmtx等关联表 - 重新打包成新字体文件
字符越少 → glyf 表越小 → 字体文件越小。50 字 vs 7000 字,体积差 30-100 倍。
完整案例:品牌网站 Hero 标题
需求:landing page 的 H1 用思源宋体,文字内容固定。
<h1 class="hero-title">
让每一份代码都简洁有力
</h1>
字符集 = 13 个汉字。我们做最小化子集。
步骤 1:收集字符
让每一份代码都简洁有力
加上备用——常见标点 + 数字字母兜底:
让每一份代码都简洁有力。,:;!?""''0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
总计约 90 字符。
步骤 2:跑子集化
打开 字体子集化:
- 拖入
SourceHanSerifSC-Bold.otf(约 14 MB) - 选”子集化”模式
- 粘贴上面 90 字符
- 输出格式选 WOFF2
- 下载得到
SourceHanSerifSC-Bold.subset.woff2(约 12 KB)
14 MB → 12 KB,压缩比 1166 倍。
步骤 3:CSS 配置
@font-face {
font-family: "SourceHanSerif";
src: url("/fonts/source-han-serif.subset.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
unicode-range: U+4E00-9FFF, U+0020-007E, U+3000-303F;
}
.hero-title {
font-family: "SourceHanSerif", "Source Han Serif SC", "Songti SC", serif;
font-weight: 700;
font-size: 48px;
}
关键配置说明:
format("woff2")——告诉浏览器这是 WOFF2 格式font-display: swap——加载期间立即用兜底字体显示,到位后切换。SEO 关键——避免空白期,Lighthouse 加分unicode-range——只对汉字 / ASCII / CJK 标点生效,避免英文段落不必要触发字体加载fallback链 ——子集字体没覆盖的字符 fallback 到系统宋体
font-display 的取舍
| 值 | 期间显示 | 字体到位后 | 用途 |
|---|---|---|---|
block | 空白(FOIT)3 秒 | 切换 | 默认,体验差 |
swap | 兜底字体(FOUT) | 切换 | 正文首选 |
fallback | 短 FOIT 100ms + 兜底 | 3 秒内切换 | 折中 |
optional | 短 FOIT 100ms + 兜底 | 不切换 | 装饰字体 |
实战推荐:
- 正文 / 标题 →
swap(内容立刻可读,体验最好) - 装饰性字体(特殊艺术字) →
optional(避免布局抖动) - 别用
block—— 用户被迫看 3 秒空白,Web Vitals 大幅扣分
用 unicode-range 拆分大字体
正文场景需要覆盖大量汉字时,按区段切多个子集:
/* ASCII */
@font-face {
font-family: "Noto Sans SC";
src: url("/fonts/noto-ascii.woff2") format("woff2");
unicode-range: U+0020-007F;
font-display: swap;
}
/* 常用 GB2312 */
@font-face {
font-family: "Noto Sans SC";
src: url("/fonts/noto-gb2312-common.woff2") format("woff2");
unicode-range: U+4E00-7FFF;
font-display: swap;
}
/* 扩展汉字 */
@font-face {
font-family: "Noto Sans SC";
src: url("/fonts/noto-extended.woff2") format("woff2");
unicode-range: U+8000-9FFF, U+3400-4DBF;
font-display: swap;
}
浏览器只下载页面实际用到字符所在的子集——一篇主要常用字的文章只下载第二个 50 KB,不会触发扩展汉字下载。这是 Google Fonts Noto Sans SC 的官方策略。
FOIT vs FOUT 的视觉效果
FOIT(block):[空白空白空白] → [字体到位,正常显示]
↑ 用户看不到字 3 秒
FOUT(swap): [兜底字体显示] → [切换到目标字体]
↑ 字体可能跳一下,但内容立刻可读
FOUT 的”跳一下”叫做 CLS(Cumulative Layout Shift)——如果兜底字体和目标字体的字宽差异大,会触发布局抖动。减轻方法:
.hero-title {
font-family: "TargetFont", system-ui, sans-serif;
font-size-adjust: 0.5; /* 兜底字体高度对齐 */
}
或用 size-adjust、ascent-override 等 @font-face descriptor 精细对齐字宽。
子集化的盲点:动态内容
子集化不适合字符不可控的场景:
- 用户输入(搜索框、评论、UGC)
- 动态数字(订单号、阅读量、价格)
- AI 生成内容
- 多语言切换
应对策略——
- 关键内容子集 + 全字体回退:标题 / 品牌字用子集字体;正文用系统字体
- 拆分加载:业务核心字符提前子集;UGC 不强制特殊字体
- 保留 ASCII 全集:子集里至少含 0-9 a-z A-Z + 常见标点,避免动态数字 / 字母豆腐
何时用浏览器工具,何时用 pyftsubset
| 场景 | 工具 |
|---|---|
| 一次性瘦身 / 试错 | 浏览器工具 |
| 商业字体不能上传 | 浏览器工具 |
| 设计师手动操作 | 浏览器工具 |
| 集成到 CI / build pipeline | pyftsubset |
| 可变字体 instancing | pyftsubset |
| 几十个字体批量处理 | pyftsubset |
| 需要精细控制(kerning / hinting) | pyftsubset |
两者互补——浏览器工具确定子集策略,确定后写进构建脚本用 pyftsubset 自动化。
上线 checklist
- ✅ 字体格式 WOFF2(不要传 TTF / OTF 上线)
- ✅
font-display: swap避免空白期 - ✅
unicode-range限制生效字符范围 - ✅ fallback 字体链
system-ui, sans-serif兜底 - ✅ 子集包含 ASCII + 常用标点防豆腐
- ✅ HTTP 头
Cache-Control: max-age=31536000, immutable强缓存 - ✅ Lighthouse 跑分确认 LCP / CLS 没扣分
- ✅ 商业字体看 EULA 确认子集化合规
配套工具
中文 Web 字体不是不能用,是要会用。10 MB 整包上线是反模式,子集 + WOFF2 + font-display 三件套是工业标准。