中文字体子集化实录:3.2MB → 200KB 的完整脚本、踩坑清单与三条反直觉经验
上一篇《自托管中文字体从 3.2MB 压到 200KB》发出来后,评论区(和私信)问得最多的是两件事:脚本能不能给一份 、为什么我照着做只压到 1MB。
这篇把完整流程摊开:可直接复制的脚本、五个真实的坑、以及三条反直觉的经验。所有数字都来自我在真实项目里跑出来的结果,不是估算。
一、先看结论
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 字体类传输 | 3,234 KB / 21 个请求 | 226 KB / 3 个请求 |
| 首屏总传输 | 3,315 KB | 约 780 KB |
| 首访文字可见时间 | 等字体下完(4G 估算 16s+) | 与首屏渲染同步 |
关键不是"用了什么高级压缩",而是把字体裁到页面真正用到的字。中文字体和西文字体的成本结构完全不同:西文字体 100KB 能覆盖全部字形,中文字体全量动辄 5--15MB,而一个页面的实际唯一用字,往往只有 400--800 个。
二、完整脚本
第 1 步:提取页面真实用字
最容易踩的坑在这里------不要只抓 HTML 源码,很多文案是 JS 渲染出来的,抓不到就会缺字。
bash
# 方案 A:静态页面(最快)
python3 -c "
import re, pathlib
h = pathlib.Path('dist/index.html').read_text(encoding='utf-8')
# 去标签、去注释后再统计,否则会把属性名里的字母混进来
h = re.sub(r'<!--.*?-->', '', h, flags=re.S)
text = re.sub(r'<[^>]+>', '', h)
chars = sorted(set(text) - set('\n\r\t '))
pathlib.Path('subset.txt').write_text(''.join(chars), encoding='utf-8')
print('唯一字符数:', len(chars))"
javascript
// 方案 B:动态渲染页面(推荐,能覆盖 JS 文案)
// 在浏览器控制台执行,把结果覆盖到 subset.txt
(() => {
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let t = '', n;
while ((n = walk.nextNode())) t += n.nodeValue;
// 顺便把常用属性里的文案也收进来
document.querySelectorAll('[placeholder],[title],[aria-label],[alt]').forEach(e => {
t += (e.placeholder || '') + (e.title || '') + (e.getAttribute('aria-label') || '') + (e.alt || '');
});
const chars = [...new Set(t.replace(/\s/g, ''))].sort();
console.log(chars.join(''));
console.log('唯一字符数:', chars.length);
})();
实测:内容型首页通常 400--800 个唯一字符;工具型页面(大量按钮、表单标签、报错文案)会到 1,200--2,000 个。超过 2,500 就该按页面组拆分了(见第 3 步)。
第 2 步:裁剪 + 转 woff2
bash
pyftsubset NotoSansSC-Regular.ttf \
--text-file=subset.txt \
--flavor=woff2 \
--layout-features='*' \
--desubroutinize \
--no-hinting \
--output-file=dist/fonts/NotoSansSC-home.woff2
参数说明(这几个是我试出来的,不是照抄文档):
--layout-features='*':必须保留。去掉它体积还能再小 10% 左右,但标点挤压、数字间距、连字会变样------中文排版里最明显的是中英文混排的间距变化,肉眼能看出来。--desubroutinize:对 CFF 轮廓字体能再省几个百分点,兼容性风险很低。--no-hinting:屏幕渲染由系统接管,省体积;如果项目要兼容很老的低分屏设备,先测再上。
第 3 步:按页面组出多套子集
全站共用一套子集不是最优。首页一套、内容页一套、工具页一套,各自只带自己的字,总收益更大:
bash
for pair in "index:dist/index.html" "post:dist/post.html" "tool:dist/tool.html"; do
name="${pair%%:*}"; file="${pair##*:}"
node -e "/* 或复用第 1 步的 python */" > "subset-$name.txt"
pyftsubset NotoSansSC-Regular.ttf --text-file="subset-$name.txt" \
--flavor=woff2 --layout-features='*' \
--output-file="dist/fonts/NotoSansSC-$name.woff2"
done
CSS 侧这样挂:
css
@font-face {
font-family: "NotoSansSC-Subset";
src: url("/fonts/NotoSansSC-home.woff2") format("woff2");
font-display: swap; /* 首屏文字先渲染,字体到位后替换 */
unicode-range: U+4E00-9FFF, U+3000-303F, U+FF00-FFEF; /* 只接管中文与全角标点 */
}
三、五个真实的坑
坑 1:缓存会让你的优化"看起来已经完成"
这是最坑的一个。我在做另一个站点审查时,第一次测出来的数据是"AI 页面首屏只有 12KB,字体 0KB"------看着像已经优化过了。真实原因是那次访问命中了浏览器缓存。
换成禁用缓存重测,同一页面的首屏传输是 1,354KB,其中字体类 1,209KB / 10 个请求。
所以:任何性能结论必须标注缓存状态。用 CDP 明确关掉:
javascript
const cdp = await page.target().createCDPSession();
await cdp.send('Network.enable');
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
坑 2:残缺的字体文件会拖慢首屏,而且很难发现
线上残留了若干被截断的 woff/woff2(可能是构建中断产生的),文件名正常、HTTP 200、体积只有 0.5--2KB。它们不会被 404 报错暴露,但会占住请求队列。
排查方法:按 transferSize 升序列出所有字体请求,任何小于 5KB 的中文字体文件都值得看一眼。
坑 3:删了 CSS 引用,但没删 @font-face 本身
只要 @font-face 还在 CSS 里,部分浏览器仍会预取该字体(尤其在 preload 或 font-display 场景下)。没用的字重、没用到的字体家族,要连声明一起删掉,不能只删引用。
坑 4:unicode-range 写错,中文走了回退字体
unicode-range 里的码位写漏(比如忘了全角标点 U+FF00-FFEF、中文标点 U+3000-303F),会出现"汉字用了子集字体、标点用了回退字体"的混排------字重和字宽都会不一致,看起来像排版 bug。
坑 5(跨领域):不是所有场景都能用 CFF 字体
顺手记一条做文档导出时踩的坑:把渲染好的 PDF 做合并、盖章时,某些 Python 库无法嵌入 CFF(PostScript 轮廓)字体 ,会静默回退到默认字体------结果就是页脚中文变成一排方框,而且不报错。
解决办法是换用 TrueType 轮廓的中文字体(例如 wqy-zenhei.ttc)。如果需要从 TTC 字体集合里取某一个语言子字体,用 fontTools 抽取后单独加载即可。
四、三条反直觉经验
-
"通用子集"是最贵的偷懒。 网上流传的"常用 3500 字"子集看着通用,但一个页面的实际用字往往只有它的 1/5。按页面实际文案裁剪,比通用子集再小 3--5 倍------而且不会缺字(因为字就是页面自己的)。
-
不是越小越好,是"够用且不掉排版"最好。 保留
--layout-features='*'会多花约 10% 体积,换来的是中英文混排间距、标点挤压、数字字形一致。这部分体积如果省了,用户看到的是"字变难看了",而你省下的只有几十 KB。 -
首屏字体要 preload,非首屏要 swap。 首屏字体
<link rel="preload" as="font" crossorigin>提前抢带宽;其余一律font-display: swap,让文字先出来。同时回退字体栈必须写全:
css
font-family: "NotoSansSC-Subset", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
否则字体在 swap 的瞬间字宽变化,会引发明显布局位移(CLS 回弹)。
五、怎么验证优化真的生效了
不要只看构建产物大小,要看真实首访的网络传输:
javascript
const res = await page.evaluate(() => {
const entries = performance.getEntriesByType('resource');
const by = {};
entries.forEach(r => {
const t = r.initiatorType || 'other';
by[t] = by[t] || { n: 0, kb: 0 };
by[t].n++;
by[t].kb += Math.round((r.transferSize || 0) / 1024);
});
const total = entries.reduce((s, r) => s + (r.transferSize || 0), 0);
return { totalKB: Math.round(total / 1024), by };
});
再加一道缺字抽样检查:从页面正文随机取 200 个汉字,用子集字体渲染并与系统字体比对字符覆盖,比"看两眼没发现问题"可靠得多。
写在最后
字体优化是前端里少有的"投入一两小时、收益立刻可见"的事:一次裁剪,换来首屏少下载 3MB。它的门槛不在技术,而在愿不愿意把每个数字量出来。
上面所有命令和参数,都是我在真实项目里跑通后才写进来的。如果你照做之后压不下去,大概率是第 1 步的用字提取漏了动态渲染的文案------先量一下 subset.txt 里到底有多少字符,答案通常就在那里。