4.1 MB 的字体,不能原样塞进页面
这个博客的标题使用 Noto Serif SC。字重固定在 600、转成 woff2 后,源文件仍有 4,299,276 字节,约 4.1 MB。
它当然不能直接进入站点产物。最直觉的办法是做子集:把页面用到的字符找出来,只保留这些字形。博客刚上线时,收集器找到 151 个字符,切成 9 个文件后合计 36.5 KB。4.1 MB 到 36.5 KB,看起来已经可以收工。
第一版方案差点也是这么收工的:所有字符切进一个 woff2,文件名带 content hash,配上长期缓存。
体积解决了,缓存却被做坏了。
只要新文章的标题出现一个从未用过的字,字体文件内容就会变化,hash 随之变化。回访读者下一次打开页面,需要重新下载整个子集。站点内容越多,单个字体文件越大;发布越频繁,这份缓存越像一次性用品。
中文字体瘦身真正要处理的是两条边界:哪些文字值得使用这套字体,以及新增内容应该让多少旧缓存失效。
先限制字体出现的位置
子集化之前,先把衬线字体的使用范围收紧。
当前构建会收集站名、文章标题、标签名、Markdown 中的 ATX 标题、关于页标题,以及一份固定 UI 文案。正文、代码和文章元信息继续使用系统无衬线字体,不进入字符集。
收集逻辑本身没有什么玄学:
export function collectSerifCharacters(bundle) {
const parts = [SERIF_UI_TEXT, bundle.site?.name ?? '', '0123456789'];
for (const tag of bundle.tags ?? []) parts.push(tag.name ?? '');
for (const article of bundle.articles ?? []) {
parts.push(article.title ?? '');
parts.push(...headingText(article.contentMarkdown));
}
parts.push(...headingText(bundle.site?.aboutMarkdown));
const seen = new Set();
for (const part of parts) {
for (const ch of part) seen.add(ch);
}
for (const ch of [...seen]) {
if (ch.codePointAt(0) < 0x20) seen.delete(ch);
}
return [...seen].sort((a, b) => a.codePointAt(0) - b.codePointAt(0));
}“正文不用这套字体”直接决定了子集的数量级。中文正文很容易带来几千个不同字符;标题和少量展示文案只有几百个。subset-font 负责执行这条边界,却不能替站点决定哪些排版值得付下载成本。
这条边界也需要测试。仓库里有一条用例,确认标题和各级标题被收集,同时拿正文里的“段”“落”两个字做反例。否则未来一次看似普通的样式调整,就可能让字体从几十 KB 悄悄长到几百 KB。
单文件子集的问题会随内容一起长大
单文件方案并非完全错误。内容固定的活动页、Logo 或几乎不更新的站点,用一个小字体文件最省事,请求也最少。
这个博客不是那种页面。文章发布会持续改变字符集合,而静态资源又依赖 content hash 做不可变缓存。两者放在一起,单文件的更新粒度就太粗了:新增一个字,整个文件换地址。
上线初期的 151 个字符,合成单文件是 31.8 KB,分成 9 个文件是 36.5 KB。分桶当时多花了 4.7 KB。
2026 年 8 月 27 日,我用公开站正在服务的 Release 35 重新构建了一次。站点已有 4 篇文章,当前收集器得到 291 个字符:
| 方案 | 文件数 | 总体积 |
|---|---|---|
| 单文件子集 | 1 | 63.5 KB |
| 固定区段分桶 | 9 | 69.8 KB |
这组数字不该被当成长期指标。文章和界面继续增加,它们还会变。真正稳定的是更新范围:单文件方案让任何新增字符失效全部 63.5 KB;分桶方案只改变新字符所在的桶。
固定 Unicode 区段,让旧字符不要搬家
当前方案把字符放进这些桶:
base:拉丁字母、数字、西文标点、CJK 标点等常用基础区段;cjk-0到cjk-7:把 U+4E00–U+9FFF 固定均分成 8 段;ext:前面没有覆盖到的字符,例如扩展区字符。没有内容时不输出文件。
每个非空桶生成一份 woff2 和一条 @font-face。字体族、字重等描述保持一致,只用 unicode-range 区分覆盖范围:
@font-face {
font-family: "Noto Serif SC Subset";
font-weight: 600;
font-display: swap;
src: url("/fonts/serif-cjk-0-fdb7aed6.woff2") format("woff2");
unicode-range: U+4E00-U+583F;
}按照 CSS Fonts Module Level 4 的字体匹配流程,这些规则会组成一个复合字体;文本命中某个 unicode-range 时,浏览器才加载对应资源。Google Fonts 的开发文档也明确说明,支持 unicode-range 的浏览器会从可用子集中选择渲染当前文本所需的部分。
分桶最容易做错的地方,是根据“这次有哪些字”动态平分。
假设今天有 160 个字,平均塞进 8 个桶;明天新增一个字,再排序、再平分。新增字符可能把后面的字符依次挤进下一个桶,最后 8 个文件一起变化。文件是切开了,缓存失效仍然是全量的。
固定 Unicode 边界不会出现这种搬家。一个字今天属于 cjk-3,以后也只会属于 cjk-3。新文章通常只改变少数几个桶,其余文件的字节和 hash 保持不变。
为了稳定 hash,构建也必须稳定
固定区段只能保证字符不会跨桶移动,不能自动保证产物逐字节一致。构建脚本还做了几件不显眼但必要的事:
- 字符去重后按 code point 排序;
- 字体桶按名称排序输出;
- 每次运行前清空旧的字体目录;
- 根据字体文件内容计算 8 位 hash;
- 校验字体总量不超过默认的 600 KB 预算。
排序保证相同 Bundle 产生相同输入顺序。清空目录则是为了避免旧 hash 文件一直留在 public/fonts,最后跟着新产物一起部署。
9 条 @font-face 当前一共 1,804 字节,直接内联在 head。字体生成步骤没跑过时,这段 CSS 为空,字体栈回退到 Songti SC 或 Georgia;font-display: swap 则保证字体尚未下载时文字仍然可见。
分桶多出来的 6.3 KB,买的是更新粒度
woff2 文件不是纯字形集合,每个文件都有自己的结构和元数据。文件切得越多,重复开销越大。
上线初期,9 桶比单文件多 4.7 KB,约 15%;当前这轮复测多 6.3 KB,约 10%。所以分桶不是免费的,也不应该切得越碎越好。这里选择 8 个 CJK 桶,是在单桶增长速度、请求数和重复开销之间取一个足够用的中间值,不是通用最优解。
如果站点内容几乎不更新,单文件会更合适。如果正文也必须使用同一套中文字体,几百个按内容生成的字符很快会变成几千个,这套“只收集标题”的前提也就不存在了。那时应该重新比较系统字体、预制大区段字体和字体服务,而不是继续增加桶数。
现在最脆的已经不是分桶算法
这次重跑还翻出一个更现实的问题:字符收集范围和页面样式已经不同步。
首页和关于页的 authorBio 使用衬线字体,友链页后来新增的提示文案、友链名称也有衬线样式,但当前收集器没有显式纳入这些字段。它们当中的一部分字符碰巧在文章标题或固定文案里出现,剩下的会落到系统回退字体。同一行混着两种字形,不会报错,只会显得哪里不太对。
因此,291 字和 69.8 KB 只描述当前收集器的实际输出,不代表页面已经完整覆盖。分桶处理的是缓存更新范围;这里暴露的是另一个维护缺口:“样式声明”和“字符来源”分散在不同文件,彼此没有校验。
原来的手写 UI 清单在页面少的时候够用,页面继续增加后,它已经开始漏。下一步更值得做的是给所有衬线内容建立同一个可检查的数据入口,或者增加覆盖测试,让模板新增衬线文案时构建立刻失败。继续调压缩参数,反而碰不到真正的问题。
中文字体从 4.1 MB 切到几十 KB,只完成了第一次瘦身。一个会持续更新的内容站,还要问第二个问题:下一次只多一个字时,读者究竟需要重新下载多少旧东西。