中文字体子集化实录:3.2MB → 200KB 的完整脚本、踩坑清单与三条反直觉经验

中文字体子集化实录: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 里,部分浏览器仍会预取该字体(尤其在 preloadfont-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 抽取后单独加载即可。


四、三条反直觉经验

  1. "通用子集"是最贵的偷懒。 网上流传的"常用 3500 字"子集看着通用,但一个页面的实际用字往往只有它的 1/5。按页面实际文案裁剪,比通用子集再小 3--5 倍------而且不会缺字(因为字就是页面自己的)。

  2. 不是越小越好,是"够用且不掉排版"最好。 保留 --layout-features='*' 会多花约 10% 体积,换来的是中英文混排间距、标点挤压、数字字形一致。这部分体积如果省了,用户看到的是"字变难看了",而你省下的只有几十 KB。

  3. 首屏字体要 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 里到底有多少字符,答案通常就在那里。

相关推荐
弈栈录1 小时前
LangChain Agent 实战:快速构建可调用工具的智能体
python·架构
Htr_1 小时前
Cortex 使用指南:开源 API 知识层
前端·数据库·人工智能·重构·计算机外设
计算机魔术师1 小时前
Dario Amodei 发文呼吁 AI 行业放慢前沿速度并公布三步计划
前端
EatFan1 小时前
前后端分离项目中 Token 到底应该怎么设计?Access Token + Refresh Token 实战
java·前端·后端
dsyyyyy11011 小时前
Typescript装饰器
前端·javascript·typescript
天远API2 小时前
零信任架构实战:基于天远全能消金报告构建自动化消费分期网关
运维·人工智能·架构·自动化
雪碧聊技术2 小时前
DeepSeek V4.1 Flash 架构全面拆解——552B MoE、KV Cache 压缩至 1/4、API 价格腰斩背后的技术账
架构
一拳不是超人3 小时前
一个没测暗色模式的 Bug,吃掉了我一半 谷歌扩展用户
前端·javascript·程序员
代码方舟3 小时前
零信任架构实战:基于天远全能消金报告构建自动化信用合规网关
运维·人工智能·架构·自动化