中文字体子集化:从 4MB 到 150KB

一、中文网站字体为什么这么慢

一个拉丁字体的 woff2 通常只有 20 到 50KB,而一个完整的中文黑体动辄 3 到 8MB。原因很直接:常用汉字有三千多个,GB18030 覆盖两万七千多字,每个字都要有独立的轮廓数据。如果你的站点用了自定义中文字体,不做任何处理就挂上去,等于让每个访客在首屏多等几秒钟。

我做过一次实测:某个中文博客引入思源黑体 Regular 全量 woff2(约 4.2MB),在 4G 网络下首屏文字渲染时间从 0.9 秒涨到 4.8 秒,移动端的 LCP 直接从"良好"掉进"需要改进"。这就是为什么绝大多数中文站点最终都回到了系统字体栈——不是不想用好字体,是全量字体代价实在太高。

但"用系统字体"和"用好自定义字体"之间并不是二选一。真正可行的路径是:只加载页面上真正用到的那几百个字。这就是字体子集(subset)的核心思路。本文把整条链路讲清楚:怎么切、怎么加载、怎么防回退、怎么自动化。

二、先确认你到底需不需要自定义字体

动手之前先算一笔账。如果你的站点是资讯、教程类以正文阅读为主的,系统字体栈在中文环境下已经足够好——iOS 用苹方,Android 用思源/思源黑,Windows 用微软雅黑,渲染质量和一致性都在可接受范围内。优先方案永远是系统字体栈:

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
               "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
               "Source Han Sans SC", sans-serif;
}

真正需要自定义字体的场景有三类:品牌站需要统一视觉识别、标题需要特殊字体(衬线、异形、手写)、以及数字和英文需要等宽或专用字。这三类里,第二类最适合子集化——标题加粗字体通常只用在 h1、h2 和 Logo 上,用到的字数可能只有几十个。

所以第一判断不是"怎么优化字体",而是"这个字体用在哪里"。正文用系统字体 + 标题用子集字体,这个组合通常能把字体总下载量控制在 100KB 以内,同时获得品牌感。

三、子集化到底做了什么

字体文件内部有一张"字形映射表",把 Unicode 码位对应到具体的字形。子集化就是只保留你指定的那些码位的字形数据,其余全部丢掉,其余部分(如 hinting、部分元数据)也可以裁掉。

一个完整中文字体 4.2MB,只保留 500 个常用字后通常能压到 60 到 200KB,压缩率超过 95%。这个数字看起来惊人,但完全符合预期——因为大部分体积都在你用不到的两万多字上。

常用的子集工具是 fonttools 包里的 pyftsubset,Python 环境装好之后一条命令就能切:

pyftsubset SourceHanSansSC-Regular.otf \
  --text-file=chars.txt \
  --flavor=woff2 \
  --layout-features='*' \
  --no-hinting \
  --desubroutinize \
  --output-file=font-subset.woff2

几个参数值得说明。--text-file 指定要保留的字符清单,这个清单的来源决定成败,后面单独讲。--flavor=woff2 输出 woff2,比 woff 再小约 30%,现代浏览器覆盖率已经很高。--no-hinting 去掉微调指令,在小字号下可能略有影响,但省下不少体积。--layout-features='*' 保留全部 OpenType 特性,如果字体带连字或旧式数字,删掉会出问题。

也可以用字符区间而不是字符清单:

pyftsubset font.ttf --unicodes=U+4E00-9FFF --output-file=out.woff2

但 U+4E00-9FFF 是两万多个字,切完还是很大,实际意义不大。按实际用字切才是正解。

四、字符清单从哪来:三种思路对比

思路一:静态常用字表。直接从 GB2312 一级字库(3755 个常用字)或者"通用规范汉字表"的一级字(3500 字)里取。好处是不依赖内容,一次生成永久可用;坏处是 3500 个字切出来依然有 300 到 600KB,而且你站点上不一定会用到那么多。适合"内容会频繁更新、无法预知用字"的场景。

思路二:按站点实际用字提取。把所有文章正文抓出来,统计字符集合,只保留出现过的字。这是压缩率最高的做法——一个两百篇文章的站点,实际用字量通常在一千五到两千五百之间,切出来 150KB 左右。缺点是内容更新后新出现的字会缺字。

思路三:动态生成。用户请求页面时,服务端扫出这一页的字符集,实时调用字体切分服务返回子集。Google Fonts 的 text= 参数就是这么做的(只支持拉丁字符),国内也有类似的自建方案。好处是永远不缺字,坏处是每次请求都要切一遍,需要缓存,且首次响应慢。

对个人站长最现实的是思路二 + 兜底策略:按全站用字切,同时保留 unicode-range 兜底和一个完整的 fallback 字体。这样体积可控,缺字时也不会显示成方块。

提取用字的脚本很简单,用 Python 遍历所有 HTML 文件,剥掉标签后取字符集:

import re, glob
chars = set()
for f in glob.glob('/www/wwwroot/site/**/*.html', recursive=True):
    html = open(f, encoding='utf-8').read()
    text = re.sub(r'<[^>]+>', '', html)
    chars.update(c for c in text if ord(c) > 0x2E7F)
# 加上常用标点和数字英文
chars.update(',。!?;:、()《》""''0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
open('/tmp/chars.txt', 'w', encoding='utf-8').write(''.join(sorted(chars)))
print('unique chars:', len(chars))

数字和英文一定要显式加入。很多字体子集化失败的案例,就是中文字都在、数字变成了别的字体,视觉上非常突兀。标点同理——中文标点在 Unicode 里和英文标点不是同一个码位。

五、unicode-range:把一个大字体拆成几块

除了"只保留需要的字",还有一种更优雅的方案:按 Unicode 区间把字体拆成多个文件,浏览器只下载包含页面用字的那些。这是 Google Fonts 处理中文的方式(他们给中文拆了约 100 个分片,每个 30 到 80KB)。

原理靠 CSS 的 unicode-range 声明。浏览器渲染页面时,先看这个页面上出现了哪些字符,只下载覆盖这些字符的分片:

@font-face {
  font-family: 'MyCJK';
  src: url('/fonts/cjk-001.woff2') format('woff2');
  unicode-range: U+4E00-4E7F;
  font-display: swap;
}
@font-face {
  font-family: 'MyCJK';
  src: url('/fonts/cjk-002.woff2') format('woff2');
  unicode-range: U+4E80-4EFF;
  font-display: swap;
}
/* 后面继续,按 128 个码位一片 */

好处是"永远不缺字",且第一页只需下载一两个分片。坏处是分片多了之后 HTTP 请求数量上升,而 HTTP/2 下这个代价可以被接受(同一域名并发)。如果你的 CDN 支持 HTTP/2 或 HTTP/3,这条路很值得走。

生成分片脚本可以用 fonttools 按区间循环切:

from fontTools.subset import main as subset_main
import sys

for start in range(0x4E00, 0x9FFF + 1, 128):
    end = min(start + 127, 0x9FFF)
    idx = (start - 0x4E00) // 128 + 1
    sys.argv = ['pyftsubset', 'SourceHanSansSC-Regular.otf',
                '--unicodes=U+%04X-U+%04X' % (start, end),
                '--flavor=woff2', '--no-hinting', '--desubroutinize',
                '--output-file=/www/wwwroot/site/fonts/cjk-%03d.woff2' % idx]
    subset_main()

注意:从 U+4E00 切到 U+9FFF 大约会产出 160 个分片。这么大的数量对个人站长来说维护成本偏高,折中方案是只对 GB2312 常用区(U+4E00 到 U+9FA5 里的一级二级字)做分片,按字形复杂度或使用频次重排后切成 20 到 30 片,其余交给 fallback 字体。这样请求数和覆盖率都能接受。

六、加载策略:font-display 与 preload

字体文件下载是"阻塞渲染"的:浏览器在字体没到之前,不知道该用哪个字形,可能推迟文字绘制。控制这个行为的属性是 font-display,四个取值各有取舍:

取值行为适用
block等待最多 3 秒,期间文字不可见几乎不用,会导致 FOIT
swap先用 fallback 渲染,字体到了再替换正文首选,保证内容立即可读
fallback等约 100ms,超时先用 fallback 渲染 3 秒折中,适合标题
optional只在网速快时用,慢就直接用 fallback 到底追求性能优先

中文站点强烈建议用 swap。理由是:中文字体的 fallback 差异通常比拉丁字体小得多(苹方和思源黑形状接近),切换时的"跳字"(CLS)不明显,而"内容不可见"的代价要大得多。

如果字体是首屏关键资源(比如 Logo 用的品牌字体),可以加 preload 让它更早开始下载:

<link rel="preload" href="/fonts/logo-subset.woff2"
      as="font" type="font/woff2" crossorigin>

crossorigin 属性不能省——字体请求按 CORS 规则处理,即使同源,不带这个属性会导致 preload 下载的和实际请求的是两份不同的资源,白白多下载一次。这是一个非常常见的错误。

反过来,不要 preload 正文用的子集字体。正文字体往往只有几十 KB 但优先级不高,preload 会跟关键 CSS 抢带宽,反而拖慢首屏。

七、缓存与版本控制

字体文件不应该被频繁访问,所以缓存策略可以很激进:Cache-Control: public, max-age=31536000, immutable。但前提是文件名带内容哈希,否则你改了字体文件,用户拿的还是旧缓存。

正确的做法是 cjk-001.a1b2c3d4.woff2 这种形式,哈希由文件内容计算。每次重新生成子集后哈希变化,URL 变化,浏览器自然拉新文件。千万不要用 ?v=20261101 这种手工版本号——改一次忘一次是常态。

Nginx 配置:

location ~* \.(woff2|woff|ttf)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "*";
    access_log off;
}

Access-Control-Allow-Origin 在跨域引用字体时必须带上,否则 Chrome 会拒绝使用(Firefox 相对宽松,所以会出现"Firefox 正常、Chrome 字体不生效"的诡异现象)。

八、常见故障与排查

现象一:控制台报 "Failed to decode downloaded font"。最常见的原因是 woff2 文件被当成文本处理了——服务器返回的 Content-Type 不是 font/woff2。检查 Nginx 的 mime.types 里有没有这一行,没有就手工加:

types {
    font/woff2   woff2;
    font/woff    woff;
}

现象二:字体加载成功但不生效。三个原因按概率排序:font-family 名字写错(尤其是名字里有空格和引号);子集里缺字但恰好那个字是页面主标题;以及 unicode-range 写错,导致浏览器认为这个分片不覆盖当前字符,压根没下载。

现象三:只有部分字显示成方块。这就是"缺字"。原因可能是提取用字时漏了动态内容(用户评论、数据库里新加的文章),也可能是标点符号没包进去。排查办法是打开 DevTools 的 Network 面板,过滤 woff2,看实际加载了哪几个分片,再看 document.fonts 的状态。

现象四:切了子集反而更慢了。如果用了大量小分片(比如 160 片),而 HTTP/1.1 环境下并发只有 6 个,会出现请求排队。判断方法很简单:看 Network 里 woff2 的等待时间。HTTP/1.1 下不要拆太细,直接用单片全站用字子集;HTTP/2 下才适合分片。

九、把子集化接进发布流程

手动切字体的最大问题是"内容更新后忘了重切"。解决办法是把它接进发布脚本——每次发布新文章后,自动重扫用字、重切子集、重算哈希、更新 CSS。整个流程大概三步:

  1. 发布完成后,运行用字扫描脚本,生成新的 chars.txt;
  2. 和上一次的字符集做 diff,如果没有新增字符就跳过(绝大多数文章用的都是已有字);
  3. 有新增字符才重切,写入带哈希的新文件名,并替换 CSS 里的 src。

第二步的 diff 是省时间的关键。实测一个两千篇文章的站点,每月新增约 15 到 30 个新汉字,也就是说一个月只需要重新生成一到两次。加一行 cmp -s old.txt new.txt && exit 0 就能跳过绝大多数无意义的构建。

最后再强调一个 fallback 的重要性:无论子集做得多完整,永远要在 font-family 后面接上系统字体栈。用户输入的生僻字、从别处粘贴进来的特殊符号、未来新增的内容,都可能不在子集里。有了 fallback,最坏情况只是那个字用了别的字体,而不是显示成豆腐块。

十、小结

中文字体优化的核心不是"压缩率",而是"只加载该加载的"。三步走:先用系统字体栈覆盖正文,再把品牌字体限定在标题和 Logo 上,最后按实际用字做子集化并接进发布流程。体积从 4MB 降到 150KB 是这个方案最直观的收益,但真正难的部分在于防缺字——字符清单要包含标点、数字、英文,要留 fallback,要处理内容更新。做到这几点,自定义中文字体就不再是性能负担。

Last modification:November 1st, 2026 at 08:25 pm

Leave a Comment