50 个 @font-face 分片,首屏却拉了 1.3MB 字体:一次真实的字体加载治理

6 阅读1分钟

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

卡口这一步才是真正决定"三个月后会不会回到原点"的东西。


四、结果(真实前后对比)

指标治理前治理后变化
首页总传输3315KB1444KB−56%
字体传输3234KB1380KB−57%
字体请求数2117−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

如果你也在做中文字体加载治理,照这个顺序走:数清楚 → 收敛组合 → 按页面组出包 → 上卡口。四步里没有一步是"上工具",但四步都能省下真实的字节。

(完)