中文 webfont 的尺寸问题
中文站点用衬线字体做标题,第一件事就会撞上体积。一套完整的思源宋体有二十多兆,全量减到常用的 GB2312 字符集也有几兆。任何「把整个字体文件丢进 public/」的做法,都会让首屏多背几秒的白屏。
常见的对策是两步:
- 子集化:扫描站点上的实际用字,只保留这些字,生成一份几万字节的字体。代价是新增文章时可能要重新生成。
- 分片:把子集再按 codepoint 切成若干块,每块用
@font-face的unicode-range声明「我负责这一段码位」。浏览器下载页面时会解析页面上实际出现的字符,只取覆盖到这些字符的片。
第二步的吸引力在于它是按需的:首页只用了三百个不同的字,那就只下覆盖这三百个字的那几片,剩下十几片永远不进入网络请求。
我的实现
站点用三套字重(400 / 500 / 700)。生成脚本把每个字重切成 7 片,共 21 个 woff2 文件,输出到 public/fonts/slices/,同时打印一份 @font-face 规则供人贴进样式表。
第 0 片被设计成「关键片」——脚本里叫 CRITICAL,收集首屏用字加 ASCII 与常用标点,单独成片,理论上应该最先需要、最该被提前加载:
CRITICAL = set(base64.b64decode(CRITICAL_B64).decode("utf-8"))
CRITICAL |= set("当前待机思考中和空气小狗打招呼很高兴见聊聊天")
CRITICAL |= set(ASCII_AND_PUNCTUATION)
rest = sorted(all_chars - CRITICAL)
slices = [rest[i : i + SLICE_SIZE] for i in range(0, len(rest), SLICE_SIZE)]
slices = [sorted(ord(c) for c in CRITICAL)] + slices # slice 0 = criticalrest 是从全量用字里减掉 CRITICAL 得到的,所以按这段代码,第 0 片和后面的片之间不应该有任何重叠。
量一下
设计意图是一回事,线上是另一回事。我清掉浏览器缓存,打开首页,记录所有 .woff2 请求:
noto-serif-sc-400-s1.woff2 20628
noto-serif-sc-400-s2.woff2 29892
noto-serif-sc-400-s3.woff2 40288
noto-serif-sc-400-s4.woff2 40212
noto-serif-sc-400-s5.woff2 44132
noto-serif-sc-400-s6.woff2 9440
noto-serif-sc-500-s1.woff2 21028
noto-serif-sc-500-s2.woff2 29800
noto-serif-sc-500-s3.woff2 40292
noto-serif-sc-500-s4.woff2 40420
noto-serif-sc-500-s5.woff2 44620
noto-serif-sc-500-s6.woff2 9588
noto-serif-sc-700-s1.woff2 2139613 个请求,383KB。
两个数字和预期不符。第一,代价是 383KB 而不是设计里那个「几十 KB 的关键片」;第二,往下看请求列表——s0 一次都没出现。三个字重,一个都没下。
那个被专门设计成首屏关键的 44KB 切片,从头到尾没被用过。
为什么 s0 没被下载
我写了段脚本解析样式表里全部 21 条 @font-face,把每条声明的 unicode-range 展开,然后按声明顺序统计覆盖关系。以 400 字重为例:
noto-serif-sc-400-s0.woff2 码位 204
noto-serif-sc-400-s1.woff2 码位 110 (109 个码位被后面覆盖)
noto-serif-sc-400-s2.woff2 码位 110 (20 个码位被后面覆盖)
noto-serif-sc-400-s3.woff2 码位 110 (24 个码位被后面覆盖)
noto-serif-sc-400-s4.woff2 码位 110 (18 个码位被后面覆盖)
noto-serif-sc-400-s5.woff2 码位 110 (13 个码位被后面覆盖)
noto-serif-sc-400-s6.woff2 码位 25 (10 个码位被后面覆盖)s1 声明了 110 个码位,其中 109 个已经被 s0 声明过。四个字重的统计完全一致,这不是巧合。
问题在于 CSS 的解析规则:同一个字体族下有多条 @font-face 覆盖到同一个码位时,生效的是最后声明的那一条。也就是说,s0 声明过的字,只要后面某片又声明了一次,实际就会从后面那片取。
拿首屏标题「把复杂的想法,做得清晰。」试一下。把(U+628A)在 s0 里有,在 s3 里也有 → 从 s3 取。清(U+6E05)在 s0 和 s4 里都有 → 从 s4 取。的(U+7684)同理。
这正好解释了网络日志:s3、s4 被下载了,s0 没有。
s0 只在页面用到「只有它声明过的码位」时才会被拉取。看统计它确实还独占着一些码位,但显然首页没有用到它们——所以整个文件连同那 204 个码位的子集数据,成了死重。
追到生成逻辑
脚本里 rest = all_chars - CRITICAL 明确做了减法,不可能产生这种重叠。所以样式表里的范围和字体文件的生成参数不是同一次运行的产物。
最可能的情形是:CRITICAL 后来又补过内容(脚本注释里就写着「更新 FIRST_SCREEN 字符,或从渲染后的页面重新采集」),字体文件重新生成了,但贴回样式表的那一步没有完整替换——或者反过来,样式表手工改过而文件没重生成。脚本注释把「把输出的 CSS 块贴回并替换旧的 @font-face 规则」写成了第 3 步,恰恰说明这一步是靠人记的,而人不会每次都记。
还有一个和这份样式表对不上的地方:项目笔记里写着「首屏关键片 45KB preload」,但我检查了产出的 HTML,<link rel="preload"> 只有两条,都是图片,没有任何字体 preload。那条 preload 不知何时被移除了,笔记没跟着更新。
教训
这个优化失效了将近两周,没有任何报错、没有任何告警、Lighthouse 也不会有意见——它只是安静地多下了 300 多 KB。性能优化如果不配一个能重复执行的测量,就跟没做差不多,因为你无法区分「按设计工作」和「完全没生效」。
如果要在这条路上继续走下去,需要的东西不多:
- 一个能在 CI 里跑的断言:清缓存加载首页,把
.woff2请求列表和总量打印出来。数字变了就该有人看一眼。 - 分片生成和数据两件事要绑在一起。脚本已经同时产出字体文件和 CSS 了,把「贴回样式表」改成脚本直接改写文件,人手就不可能忘记同步。
- 别让声明范围重叠。生成时加一句校验:所有片的
unicode-range求并集后,交集的基数必须是 0。
至于眼下这 383KB,真正该回答的问题其实更靠前:一个以中文字为主的站点,值不值得为衬线标题加载三套字重、共 21 个分片的定制字体?如果答案是「值」,那分片要修对;如果「不值」,那更省事的做法是用系统自带的衬线字体兜底,把这份预算留给别的地方。