站上的标题用衬线体。首次访问的总下载量是 1808KB,其中字体一个人占了 1692KB。
英文站不会有这个问题 —— 一套 Latin 字体几十 KB。中文的现实是:思源宋体一个字重就是 1.7MB 起步,而标题上真正用到的字,可能不到八百个。
只收会用到的字
思路很直白:扫一遍源码,把所有会被衬线渲染的字收集起来,只对这些字做子集。
关键在「会被衬线渲染」这个限定。全站所有中文有三千多字,但衬线只用在标题、引用、几个特定位置上。收多了等于白压。
所以收集器是按渲染位置来的,不是按文件:
// md 里:一到三级标题、引用块、frontmatter 的 title
/^#{1,3} /, /^> /, /^title: /
// astro 里:h1-h3、blockquote、title/lead/eyebrow 这几个 prop
跑下来 728 个字。按每个字四百多字节算,两百多 KB —— 数量级对了。
变量字体比两个静态字重更省
标题要用到常规和加粗两个字重。直觉上是压两个静态子集,各取所需。
实测:
| 方案 | 体积 |
|---|---|
| 两个静态子集(400 + 700) | 333KB |
| 一个变量字体(400–700 轴) | 150KB |
const buf = await subsetFont(await fs.readFile(base), text, {
targetFormat: 'woff2',
variationAxes: { wght: { min: 400, max: 700 } },
});
变量字体只存一套字形轮廓加一组插值数据,而两个静态字重是把同一批字的轮廓存了两遍。字数越多,差距越大。
顺带把两个 @font-face 合成一个:
@font-face {
font-family: PaperSerif;
src: url(/fonts/paper-serif.woff2) format('woff2');
font-weight: 400 700; /* 一个区间,不是一个值 */
font-display: swap;
}
我猜错的一次
压完之后我提了个假设:源字体是 TTF(二次曲线),换成 CFF/OTF(三次曲线)轮廓点更少,woff2 应该能再小一截。听起来很有道理。
量了一下:
| 源格式 | 每字节数 |
|---|---|
| OTF | 440 B |
| TTF | 419 B |
差 5%。而且方向和我预期的相反。在中日韩字形这个量级上,woff2 的熵编码已经把曲线表示的差异吃掉了大半,省下来的那点还不够折腾一趟的。
这个假设写在提交信息里会很好看,可惜它是错的。量完就删掉了。
差点漏掉一个字
子集化最危险的失败模式是漏字:页面照样 200,控制台一声不吭,只有那一个字变成了系统字体,或者干脆是豆腐块。谁也不会天天盯着标题看。
所以加了一条断言,量的是真实覆盖:
ok('衬线子集没漏字', missing.length === 0, `字表 ${chars.length} 字`);
它当场就抓到一个:「月」。
页面上到处是「2026 年 8 月」,但源码里搜不到这个字 —— 它是 cstYearMonth() 拼出来的:
export function cstYearMonth(d: Date) { return `${y} 年 ${m} 月`; }
收集器只扫了 md 和 astro,没扫 ts。补上 .ts 里的中文字符串字面量,还有那一串量词单位:
'年月日时分秒条本部张篇个第共约'
代码生成的文字不在任何模板里。 这类字最容易漏,也最难靠肉眼发现。
结果
| 之前 | 之后 | |
|---|---|---|
| 字体 | 1692KB | 150KB |
| 首次访问总量 | 1808KB | 375KB |
自托管还有个隐形的坑:--font-display 这个变量在全局样式里被定义了两次,后面那次没带 PaperSerif,结果自托管的字体从来没被请求过。看页面完全正常,因为回退字体也是衬线。
发现它靠的还是量:查计算后的 --font-display 里有没有 PaperSerif,以及有没有真的发出 woff2 请求。修法是把两处合并成一处,而不是调换顺序 —— 调换顺序下次再有人加一行照样坏。
三条
- 中文字体必须子集化,而且要按「哪里会用到这个字体」收集,不是全站扫一遍。
- 多字重优先变量字体,中文场景下差距是成倍的。
- 压完要验覆盖,验的是渲染结果不是文件大小。 漏字不会报错,只会悄悄难看。