中文字体子集化实战:从 9MB 裁到 200KB,以及 FOIT、FOUT 与补字兜底

站点的中文标题字体文件,动不动就是 5MB 到 12MB。一个内容站为了「标题有设计感」引入一套思源宋体或者站酷字体,首屏时间能从 800ms 掉到 2.6 秒。很多站长第一反应是「换个更小的字体」,但换字体治标不治本——真正的问题是:一个页面上用到的汉字,其实只有几百个,而你的字体文件里塞着两万多字。

字体子集化(subsetting)就是把这件错配纠正过来:只保留页面实际用到的字形,其余全部丢掉。做得好,一个中文字体文件可以从 8MB 压到 60KB 以内,而且视觉上和完整版没有任何区别。

这篇文章只讲这一件事:怎么把中文字体裁到真正需要的字形,怎么处理「以后新增的字怎么办」这个必然遇到的问题,以及怎么避开字体加载过程中最容易出的两个显示故障——FOIT 和 FOUT。

一、先量一遍:你的字体到底拖了多少时间

动手之前先用数据确认瓶颈在字体上,不然容易优化错对象。打开浏览器的开发者工具,切到网络面板,按「大小」排序,看第一个字体文件的传输字节数。再用 curl 直接测一次下载耗时:

curl -s -o /dev/null -w 'size=%{size_download} time=%{time_total} ttfb=%{time_starttransfer}
' \
  https://www.example.com/fonts/SourceHanSerifSC-Bold.otf

我在自己的站点上第一次跑这条命令,得到的数字是 9.4MB / 3.1 秒,服务器在国内、CDN 没接管字体目录。注意这里的 3.1 秒是单次下载耗时,但实际影响远不止此:如果这个字体是在 CSS 里用 @font-face 声明的,并且没有设置 font-display,浏览器会先把文字藏起来等字体,这段时间页面看起来是「一片空白但有布局」——这就是后面要讲的 FOIT。

同时要弄清字体格式。OTF 和 TTF 是未压缩的轮廓格式,WOFF 是第一代网页封装(内部用 zlib),WOFF2 是第二代(内部用 Brotli,压缩率比 WOFF 再高 30% 左右)。同一套中文字体,格式不同,体积差异很大:

# 先看格式和体积的对应关系(假设你已经装了完整版)
ls -lh SourceHanSerifSC-Bold.*
# 9.4M  SourceHanSerifSC-Bold.otf     <- 元数据里带大量 hinting 与全字符集
# 5.8M  SourceHanSerifSC-Bold.woff
# 4.2M  SourceHanSerifSC-Bold.woff2

结论很清楚:只换格式最多把 9.4MB 降到 4.2MB,仍然是「不能用」的量级。换格式是第一步,子集化才是主刀。

二、子集化工具怎么选:pyftsubset 与 fonttools

现在最通用的方案是 Google 维护的 fonttools,里面自带一个 pyftsubset 命令行工具,支持 OTF、TTF、WOFF、WOFF2 的输入输出,用的是 Python,装起来只要一条命令:

python3 -m venv /opt/fonttools
/opt/fonttools/bin/pip install fonttools brotli
/opt/fonttools/bin/pyftsubset --help | head -20

注意必须额外装 brotli,否则输出 WOFF2 时会报错 ModuleNotFoundError: No module named 'brotli',而很多人看错误信息以为是 fonttools 版本问题,去降级,绕了一大圈。

最基础的一次子集化,是直接给一串字符:

/opt/fonttools/bin/pyftsubset SourceHanSerifSC-Bold.otf \
  --text="一个草根站长的博客" \
  --flavor=woff2 \
  --output-file=serif-bold-subset.woff2 \
  --layout-features='*' \
  --no-hinting \
  --desubroutinize

几个参数值得解释。--layout-features='*' 保留全部 OpenType 排版特性,中文标点挤压、竖排、连字这些都靠它,不保留的话标点在中文排版里会显得非常松散。--no-hinting 丢掉字形微调信息,屏幕渲染靠操作系统的字体光栅器就够,能省 5%~10%。--desubroutinize 把 CFF 子程序展开成直线,浏览器解析更快,代价是体积略增——在中文字体上通常是值得的,因为子程序查找的开销比多出来的几百字节更明显。

但显然不能用「--text=标题」这种方式,手写字符集不可维护。真正可用的做法是从站点现有的内容里把字符集抽出来。

三、从真实内容里抽取字符集:这一步决定了成败

我的做法是:把站点上所有页面渲染后的纯文本抓下来,去重成一个字符集文件。不要用「常用汉字 3500 字表」这种猜测,因为它既可能缺字(比如你写了「龘」「甪」这种),又会塞进大量你永远用不到的字。

抽取脚本不复杂,一个 Python 脚本几十行就能完成:

import os, re, sys
from html.parser import HTMLParser

class TextOnly(HTMLParser):
    def __init__(self):
        super().__init__()
        self.buf, self.skip = [], 0
    def handle_starttag(self, tag, attrs):
        if tag in ('script', 'style'):
            self.skip += 1
    def handle_endtag(self, tag):
        if tag in ('script', 'style') and self.skip:
            self.skip -= 1
    def handle_data(self, data):
        if not self.skip:
            self.buf.append(data)

chars = set()
for root, _, files in os.walk('/srv/site/html'):
    for f in files:
        if not f.endswith('.html'):
            continue
        p = TextOnly()
        p.feed(open(os.path.join(root, f), encoding='utf-8', errors='ignore').read())
        chars.update(''.join(p.buf))

# 去掉空白与 ASCII,只留 CJK 与中文标点
keep = sorted(c for c in chars if ord(c) > 0x2E80)
open('charset.txt', 'w', encoding='utf-8').write(''.join(keep))
print('unique CJK chars:', len(keep))

一个实际有 600 篇中文文章的站点,跑出来通常是 2400 到 3200 个不重复汉字加标点。这个量级很关键——它不是 2 万字,也不是 300 字,正好落在子集化之后 200KB 到 400KB 的区间。再配合 WOFF2 的 Brotli 压缩,最终传输量通常在 120KB 到 200KB 之间,比原始的 9.4MB 小两个数量级。

如果字数还是太多,可以按字符出现频率排序,只取前 3000 个覆盖 99.9% 的正文,剩下的靠「动态补充」机制兜底(后面第五节讲)。但我的经验是没必要,中文正文 3000 字的上限已经足够,硬砍只会让个别页面出现方块字。

四、FOIT 和 FOUT:两个必须分开处理的显示故障

字体文件变小了,但加载仍然需要时间。这段时间页面怎么显示,由 font-display 这一个 CSS 属性决定,它有三个关键值,行为完全不同:

block 是默认行为。浏览器在字体加载完成前(最长 3 秒)把文字完全藏起来,页面呈现为「空白但布局正确」。3 秒还没加载完,才回退到系统字体。这就是 FOIT(Flash of Invisible Text)——用户看到的是空白,体验最差,但在字体加载快的场景下视觉最干净。

swap 先用系统字体立刻显示文字,字体加载完成后替换,最多可以用系统字体撑 100 毫秒的延迟。这是 FOUT(Flash of Unstyled Text)——没有空白,但会看到字体「跳变」一下,中文场景下字宽变化明显,会导致段落重排。

optional 是给性能敏感站点的。浏览器极短暂(约 100ms)等待字体,超时就用系统字体并且这次页面浏览不再替换,字体只在缓存中备好供下次使用。它的效果是:首次访问者看到系统字体,第二次起看到设计字体,全程零跳动、零空白。

我现在的选择是:正文与 UI 用 optional,因为它对 CLS(累积布局偏移)的影响最小;而站点的 logo 文字这类「必须精确呈现」的元素,用 swap 加内联 SVG 保底。写法如下:

@font-face {
  font-family: 'SiteSerif';
  src: url('/fonts/serif-bold-subset.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: optional;
  unicode-range: U+3000-303F, U+4E00-9FFF, U+FF00-FFEF;
}

unicode-range 这一行常被忽略,但在中文站上价值很大。它的作用是告诉浏览器「这个字体只对这些码位负责」,于是浏览器可以在字体还没下载完时,先用系统字体渲染 ASCII 数字和字母,只等中文部分。多个 @font-face 用不同 unicode-range 切分(拉丁、CJK 基础区、标点),浏览器会按需只下载用得上的那几段。

五、新增字怎么办:三层兜底,别指望一劳永逸

子集化的核心矛盾是:字体是按「当前内容」裁的,明天你写了一篇含有新字的文章,那个字就没了。这个坑我在上线后第三周就踩到了——正文里出现一个「龘」,浏览器显示成方块。

三层兜底可以彻底解决这个问题。第一层是 字体栈里的系统字体回退:

font-family: 'SiteSerif', 'Source Han Serif SC', 'Noto Serif CJK SC',
             'Songti SC', 'SimSun', serif;

子集字体缺字时,浏览器会自动往下找。缺点是系统字体和绘制字体的字形风格不一致,一个方块字混在正文里会显得突兀,但至少不是豆腐块。

第二层是 定期增量重跑。把字符抽取脚本挂进发布流程:每次有新文章发布,就重跑一次抽取、比较字符集差异,有差异就重新生成子集文件并带新的哈希文件名上线。这样延迟最多一次发布周期,成本是多一次几十秒的构建。

# 发布流水线里的钩子示例
new_hash=$(sha256sum charset.txt | cut -c1-8)
if [ "$new_hash" != "$(cat .font-charset-hash 2>/dev/null)" ]; then
  /opt/fonttools/bin/pyftsubset base.otf --text-file=charset.txt \
    --flavor=woff2 --output-file=public/fonts/serif-bold-$new_hash.woff2
  echo "$new_hash" > .font-charset-hash
fi

第三层是 保留一个不常用字的补充字体。做法是:把 GB2312 全集(6763 字)减去你已经子集化的字符,生成一个「补字字体」文件(约 1.5MB),用 unicode-range 单独声明,并且用 preload 的 media 条件让它只在需要时才加载——实际上浏览器只有在页面里真出现那个码位时才会请求它。这样绝大多数页面零额外开销,个别含生僻字的页面也能正确显示。

六、验证:四个必须看的指标

子集化做完,不能只看「页面看起来正常」。四个指标缺一个都可能藏问题。

第一个是体积。用 curl 抓一次真实请求,确认 size_download 落在预期区间,并且响应头里 content-encoding 不是 gzip(WOFF2 已经是 Brotli 压缩过的,Nginx 再压一遍纯属浪费 CPU,可以在 gzip_types 里把 font/woff2 去掉)。

第二个是字形完整性。把字符集文件里的每一个字都渲染到一个测试页面上,用开发者工具的「字体」面板确认没有字回退到系统字体。更直接的办法是对比每个字的宽度:如果某个字明显比其他字窄,多半是回退了。

第三个是LCP 是否受影响。如果标题用的是自定义字体,并且 font-display 设成了 block,LCP 会被推迟到字体加载完成。改成 optional 之后,用 Lighthouse 或者浏览器性能面板确认 LCP 元素的时间点提前了。

第四个是CLS。字体替换如果改变了字宽,会导致段落重排、布局偏移。中文字体的字宽通常是等宽的(全角),但标点挤压和西文字符的字宽差异仍然存在。font-display: optional 能规避这个问题,swap 则不能。

七、一份可以直接抄的落地清单

把上面七节压缩成一份可执行的顺序,我每次给新站配字体都照这个走:

1. curl 量原始字体体积与下载耗时,确认是否真的是瓶颈
2. 装 fonttools + brotli,用 pyftsubset 做一次最小可行子集
3. 写字符抽取脚本,从全站 HTML 抽出 CJK 字符集(约 3000 字)
4. 生成 WOFF2 子集(--layout-features='*' --no-hinting --desubroutinize)
5. 生成 GB2312 补字字体,单独用 unicode-range 声明
6. 字体栈写全系统回退,避免方块字
7. font-display: optional 给正文字体,swap 给必须精确呈现的元素
8. Nginx 里从 gzip_types 移除 font/woff2,设 Cache-Control 一年
9. 字符集哈希进构建流程,新增字自动触发重跑
10. 线上用 curl 验体积与编码,性能面板验 LCP 与 CLS

最后一句经验:中文字体优化最大的误区是把它当成「压缩问题」。压缩只能把 9.4MB 变成 4.2MB,真正解决问题的是「只下载需要的字形」这个结构性调整。同样,font-display 的选择比文件体积更影响用户的实际感受——一个 200KB 的字体配 block,用户看到的空白时间可能比一个 2MB 的字体配 optional 还长。

Last modification:November 14th, 2026 at 07:29 pm

Leave a Comment