50 个 @font-face 分片,首屏却拉了 1.3MB 字体:一次真实的字体加载治理
一个法律工具站的首屏传输,我测出来是 3315KB——其中 字体占 3234KB、21 个请求。
看到这个数字,第一反应几乎所有人都是同一个:"字体没做子集化,上 pyftsubset。"
我也这么想了,然后动手前先数了一遍。数完之后发现:如果当时直接开始切字体,我会白干一天,而且问题一点没解决。
这篇把完整的排查过程、四步治理、以及三条反直觉经验写清楚,所有数字和命令都可复现。
一、先别急着优化,先把事实数出来
优化字体之前,只需要三条命令就能知道你到底缺什么:
# 1) 字体样式表有多大
curl -s https://your-site.com/assets/fonts/fonts-local.css -o fonts.css
wc -c fonts.css
# 2) 里面有多少个 @font-face 声明
grep -c "@font-face" fonts.css
# 3) 其中有多少个带了 unicode-range(即"已经按字符区间分片")
grep -c "unicode-range" fonts.css
我当时的输出是:
188358 # fonts-local.css 体积
50 # @font-face 声明数
50 # 带 unicode-range 的声明数
50 个声明,50 个都带 unicode-range。
这句话什么意思?意思是这套字体早就按 Unicode 区间切好片了——浏览器只会下载"当前页面用得到的字符"所在的那几个分片,这正是子集化想达到的效果。
所以"字体没做子集化"这个判断是错的。我如果需要子集化,那才真是白干。
真正的症状是另一个:页面实际上把 21 个分片全都拉了下来。
分片机制没错,错的是"被拉下来的分片数量"。
二、三个真正的根因
顺着"为什么拉了 21 片"往下查,根因有三个:
根因 1:字体族 × 字重组合爆炸。 一个中文字体族,按 Unicode 区间切成若干片;而站点用了多个字重(比如 300 / 400 / 500 / 700 / 900)。每个字重都有自己的一套分片。 再叠加多个字体族(中文黑体、中文宋体、西文衬线、西文无衬线),组合数就是乘法关系:族 × 字重 × 分片。
根因 2:把所有分片都当成了首屏必需。
<link rel="preload"> 或等价的预加载写成了"加载整套字体",于是首屏在解析 CSS 之前就开始拉不该拉的片——首屏根本没用到宋体,也在拉宋体。
根因 3:没有"页面组"概念。 全站共用一份字体样式表。结果是首页(其实只用到无衬线、两个字重)也要为长文页(需要衬线)买单。
三个根因合起来,就是"早就切好片了,但每次拉一大堆"。
三、四步治理(可直接抄)
第 1 步:把字重收敛到两档
先做减法,把字重从 5 档砍到 400 / 700 两档:
/* 设计 Token:字重只保留两级 */
:root{
--fw-normal: 400;
--fw-bold: 700;
}
/* 历史写法全部映射过来,别改 DOM */
.fw-medium{ font-weight: var(--fw-normal); }
.fw-black { font-weight: var(--fw-bold); }
这一刀最"反设计直觉"——视觉上你会觉得少了层次,但中文网页的字重差异在正文里几乎不可辨,而少一档字重就等于少掉一整套分片。
第 2 步:按"页面组"出包,而不是全站一份
把字体样式表拆成按页面组:
/assets/fonts/fonts-home.css # 首页组:只用无衬线 + 2 个字重
/assets/fonts/fonts-app.css # 工具页组
/assets/fonts/fonts-article.css # 长文页组:才引入衬线族
首页只引 fonts-home.css,长文页才引衬线。这样首页的候选分片数直接下降一个量级。
第 3 步:只 preload 首屏真正要用的那 2–3 片
<!-- 只预加载首屏正文实际会用到的分片,其余交给浏览器按需拉 -->
<link rel="preload" as="font" type="font/woff2" crossorigin
href="/assets/fonts/noto-sans-sc-400-latin.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin
href="/assets/fonts/noto-sans-sc-400-cjk-1.woff2">
不要把整套字体写进 preload——那是把"按需"变回"全拉"。
第 4 步:测量口径写清楚,然后接进构建卡口
先说口径,因为这一步我踩过坑:测量必须禁用缓存。
我第一次测出"AI 页只有 12KB"时差点报喜,后来发现是缓存命中。无缓存复测是 1354KB。同一批数字,口径不同能差 100 倍。
用真实浏览器的 CDP 计量(我用的就是这段):
const client = await page.target().createCDPSession();
await client.send('Network.enable');
await client.send('Network.setCacheDisabled', { cacheDisabled: true });
let total = 0;
client.on('Network.loadingFinished', e => { total += e.encodedDataLength; });
await page.goto(url, { waitUntil: 'networkidle2' });
console.log(Math.round(total / 1024) + ' KB');
然后把预算写进构建,超了就失败:
# 构建期卡口(示例:字体类 ≤3 请求 且 <300KB)
font_budget:
max_requests: 3
max_bytes: 307200
fail_fast: true
卡口这一步才是真正决定"三个月后会不会回到原点"的东西。
四、结果(真实前后对比)
| 指标 | 治理前 | 治理后 | 变化 |
|---|---|---|---|
| 首页总传输 | 3315KB | 1444KB | −56% |
| 字体传输 | 3234KB | 1380KB | −57% |
| 字体请求数 | 21 | 17 | −4 |
说实话:还没到位。 目标本来是"首屏字体 ≤3 请求且 <300KB",现在 17 个请求说明"按页面组出包"只做了一半——剩下的空间在第 1 步(字重)能不能再收,以及首页是否还在引长文页的样式表。
但即便只做了前两步,首屏已经少拉了 1.8MB。而且最难的那件事其实不是优化,是"不要优化错的东西"。
五、三条反直觉经验
① 先验证,再动手。 "字体没优化"是最容易说出口、也最容易说错的结论。一条 grep -c "unicode-range" 就能救你一天。凡是"某某没做"的判断,都该先附一条能跑的命令。
② 工具能跑,不等于产物可用。 我在另一条线上吃过这个亏:用 pyftsubset/fontTools 给 PDF 做中文嵌入时,CFF 轮廓字体(NotoSansCJK-*.ttc)直接报 postscript outlines are not supported,然后静默回退成 Helvetica——结果 PDF 页脚的中文全变成了方框。后来固定改用 TrueType 轮廓的中文字体(wqy-zenhei.ttc)才稳定。
"命令跑通了"和"文件里中文是对的"是两件事,后者要渲染出来看。
③ 卡口比技巧重要。 所有字体优化技巧,都不如把"首屏字体 ≤3 请求 / <300KB,超了构建失败"写进 CI。技巧会被后人改回去,卡口不会。
六、可直接抄的自查清单
- 数清
@font-face数量与带unicode-range的数量(判断是否真的缺子集化) - 列出全站用到的字重,收敛到 2 档
- 列出全站用到的字体族,中文族收敛到 1 个
- 字体样式表按页面组拆分,首屏只引首屏组
-
preload只留首屏正文真正会用到的 2–3 片 - 测量时禁用缓存,并把口径写进报告
- 把字体预算接进构建,fail-fast
如果你也在做中文字体加载治理,照这个顺序走:数清楚 → 收敛组合 → 按页面组出包 → 上卡口。四步里没有一步是"上工具",但四步都能省下真实的字节。
(完)