给博客做中文 WebFont 子集
记录本站 Noto Sans SC 的双轨字体方案:正文静态子集、Waline 评论 unicode-range 分块、容量监控、把评论字体 vendoring 进仓,以及 Windows autocrlf 导致 CI STRICT 校验失败的踩坑。

个人博客用中文字体,目标其实很朴素:跨设备看起来像同一套字,又别让访客为三份完整 Noto Sans SC 买单。完整简体源字体每个字重大约 1.1 MiB,三个字重合计约 3.5 MiB——对 GitHub Pages 上的静态站来说,这是明显的首屏负担。
这篇文章记录本站最终采用的做法:正文走「按站点文案生成的静态子集」,评论区走「完整覆盖但按 unicode-range 分块」的另一套字体族;评论产物提交进仓库并由 CI 做严格校验;静态体积则用脚本持续盯着,到了阈值再考虑拆分。文末链到更细的维护手册。
目标与边界
要解决的是两件事:
- 字形一致:页面与组件库共用锁定版本的 Noto Sans SC,而不是各端系统默认黑体各写各的。
- 体积可控:能枚举的文案尽量只打进子集;枚举不了的用户输入,也不能退回「整包下载」。
明确不覆盖的范围:
- emoji,以及源字体 CMap 里本来就没有的字符,仍走系统后备字体;
- 不扫描整个
node_modules。静态子集只认仓库src/里出现过的字。
字重保留 400 / 500 / 700。源字体来自锁定的 animal-island-ui。组件库自带的 CSS 会为 Noto Sans SC 声明完整中文 WOFF2;若原样打包,Vite 仍可能把那几份大文件打进 dist。本站在 astro.config.mjs 里挂了一个 Vite transform 插件,只处理 animal-island-ui/dist/index.css:
- 凡是
font-family: Noto Sans SC且引用noto-sans-sc-chinese-simplified的@font-face,整段删掉; - 其余同族规则保留,并补上拉丁范围
unicode-range: U+0000-024F,继续提供西文。
中文改由站点自己的静态子集 @font-face(写在 global.css)承接。源 WOFF2 仍留在 node_modules 里供生成器读取,只是打包后的 CSS 不再引用它们,浏览器也就不会下载。
这也带来一个真实缺陷:animal-island-ui 里少数组件带有写死的中文默认文案(例如 Modal 的「取消 / 确定」、Select 的「请选择」、Form 校验提示等)。这些字不在 src/ 里,就不会进静态子集;一旦页面真的渲染出它们,就会落到系统字体,和周围的 Noto 不一致。当前博客实际用到的 Button、Card、Tag 等,中文多半是页面自己传入的,会被扫描到,所以现状问题不大——但以后若启用更多带默认文案的组件,需要把那些字写进页面,或维护一份受控字符表。细节记在维护手册里。
为什么要分成两条轨
站点里的中文其实分两类。
一类是静态文案:文章、导航、按钮、布局里写死或条件渲染的字。它们都在仓库的 src/ 里,理论上可以在构建前收集完毕。
一类是用户输入:Waline 的昵称、评论正文、编辑器占位与按钮。访客可能打出任何字,事前无法用「当前文章用过的字」猜完。
若只做一套「按文章扫描」的子集,评论里冷门字会匹配失败,浏览器回退到系统字体,字形立刻分裂。若评论也共用那套小子集,问题一样。反过来,若正文也加载「完整覆盖」的评论字体,又会把首屏成本拉回去。
所以本站拆成两个字体族:
正文 Noto Sans SC | 评论 Noto Sans SC Comment | |
|---|---|---|
| 内容 | 扫描 src/ 得到的字符集 | 源字体 CMap 全量覆盖 |
| 形态 | 每字重一个静态 WOFF2 | 每字重 48 个带 unicode-range 的块,共 144 个文件 |
| 加载 | 几乎每页都可能用到 | 仅挂了 Waline,且评论区接近视口的页面 |
| 产物 | 本地生成,不提交 | 提交进仓库 |
评论不用正文那套子集,正文也不复用评论族:未知评论字符只会命中评论分块,不会误撞上「有限静态子集」再悄悄回退系统字体。
静态子集:扫仓库,而不是猜页面
生成器递归读取 src/ 下常见源码与内容扩展名,把文件里出现过的字符并进集合。它不解析「最终用户可见 DOM」,因此模板字符串、分支和注释里的中文也会进来——这是故意偏保守,避免漏字。
当前策略是全站共用三份静态文件,不按路由拆。代价是:打开技术分类页时,也可能下载到别的文章才用过的字。以现阶段内容为例,三个字重合计大约 0.6 MiB 量级,仍远低于完整中文三字重。
所有自托管字体使用 font-display: swap:先用后备字体画出字,下载完成后再切换。短暂的字形跳动,是为了不把文字渲染堵在字体请求上。
容量监控:先报警,再决定要不要拆
静态子集一定会随文章变多而涨。与其等到 LCP 已经难看才想起来,不如让构建自己报。
fonts:verify 会统计三份静态 WOFF2 的总字节数:
npm run dev前检查public/fonts;npm run build后检查dist/fonts;- 在 GitHub Actions 里超阈值会打出 warning annotation,不让构建失败。
阈值是维护触发条件,不是红线:
| 静态合计 | 含义 |
|---|---|
| < 1 MiB | 继续全站共享子集 |
| 1–2 MiB | 评估拆「公共 UI 字体」和「文章正文字体」 |
| ≥ 2 MiB,或线上字体已明显拖累 LCP | 应考虑按路由 / 按文章生成子集,并保留公共字符包 |
评论字体不随文章增长,也不纳入这套静态容量判断。评估时还要看真实传输、缓存命中和 CI 生成耗时,不能只看仓库里的文件大小。
评论字体:完整覆盖,但按块懒加载
评论侧需要「源字体有的字都能显示」,同时不能让浏览器一次拉齐 144 个文件。
做法是:
- 对每个字重,取出源字体 CMap 的全部码点;
- 用一份固定的常用简体优先级表,把常见字排到靠前的块(Waline UI 和短评论更容易命中小文件);
- 其余码点稳定排序后,按字符数量均分成 48 段(均衡的是码点数,不是 WOFF2 字节数);
- 每段生成独立 WOFF2,并在 CSS 里写上对应的
unicode-range。
优先级表不依赖文章内容,所以发新文不会打乱评论分块,也不会让访客缓存的评论字体无故失效。浏览器只请求与评论区实际字符匹配的块。
评论 CSS 会在 Waline 容器进入视口前约 600px 时才插入;分类页、首页等没有 Waline 的页面不会加载它。
把评论字体放进仓库:vendoring 与 STRICT
评论分块共 144 个文件,冷启动用 FontTools 子集化并不便宜。早期曾指望 GitHub Actions cache 加速「CI 现算」;后来改成更干脆的策略:
- 评论产物提交进 git:
public/fonts/comment/、comment-fonts.css,以及记录指纹与逐文件 SHA-256 的manifest-fragment.json; - 静态字体仍本地生成:只忽略
public/fonts/static/,Actions 继续缓存.cache/inexistence-fonts/与静态 WOFF2; - CI 打开
STRICT_COMMENT_FONT_VENDOR=1:指纹或文件哈希对不上就直接失败,禁止在 CI 里默默重算混过。
本地日常跑 fonts:ensure 即可:匹配则跳过,不匹配则重建并警告你去提交。改了源字体、生成器或分块策略时,也可以显式跑 fonts:vendor-comment。两者都能在过期时重建评论产物;差别主要是 ensure 还会处理静态子集。
正确性门禁放在 ensure(指纹 + 全量哈希);fonts:verify 负责产物是否齐全、页面是否误挂 / 漏挂评论 CSS,以及上面的静态体积警告。两条检查叠在一起,比把所有事都塞进一个脚本更清晰。
踩坑:同一 commit,Windows 绿、Linux CI 红
Vendoring 上线后,第一次线上部署就挂在了 STRICT 上,日志大意是:已提交的评论字体缺失、过期或被改过。
本地(Windows)却一切正常:fonts:ensure 显示评论字体 current,fragment 也在。仓库里的 WOFF2 二进制与 Linux 并无不同。
根因是换行符。
本机 core.autocrlf=true 时,工作区里的 .py、.css 往往是 CRLF,而 GitHub Actions 的 Ubuntu runner 检出的是 LF。评论指纹里包含了生成器脚本的内容哈希;fragment 里还记录了 comment-fonts.css 的 SHA-256。哈希按「磁盘上的原始字节」计算时:
- Windows 工作区:CRLF → 指纹 A、CSS 哈希 A′;
- Linux CI:LF → 指纹 B、CSS 哈希 B′;
- fragment 若在 Windows 上生成,CI 侧永远对不上 → STRICT 失败。
文件内容「看起来一样」,对 Git 来说也已是同一份 blob,但校验用的摘要不一致。这和「字体没提交」是两类问题,日志却长得很像。
修复并不复杂,但要两端同时稳住:
- 对文本类文件,哈希前先把换行规范成 LF;
- 用
.gitattributes把文本固定为eol=lf,字体等二进制标成binary; - 用新逻辑重写
manifest-fragment.json再提交。
之后同一 commit 在 macOS / Linux / Windows 上应得到相同的评论指纹与文本文件摘要。WOFF2 本身是二进制,本来就不受 autocrlf 影响。
一个附带现象:修复后重新 vendor,若子集结果与仓库里已有二进制一致,工作区里可能看不到 comment-fonts.css 或 woff2 的 diff——变的是「怎么算哈希」和 fragment,不是字形文件本身。
未来的优化:监控已经在,演进跟着告警走
前面说的容量监控,其实就是给「要不要继续改」留的挂钩。现阶段静态合计还在约 0.6 MiB,低于 1 MiB 的评估线,所以全站共享子集仍然够用;真正需要动手的信号,是 verify 开始报警,或线上字体请求已经明显拖累 LCP。
到那时,比较自然的下一步包括:
-
拆公共 UI 与文章正文(含按路由 / 按篇)
导航、页脚、跨页文案进较小的公共包;文章字符按路由或按篇生成。单页传输会下降,但产物数量、缓存 key 和维护成本都会上去——这也是现在还停在共享子集的原因。 -
让静态指纹更「认字」
今日指纹会随扫描到的源文件字节变化;注释或无关代码改动也可能触发重建。若 CI 生成耗时开始刺痛,可以把指纹改成主要依赖「字符集合」本身。 -
评论分块策略微调
例如调整优先级表、块数,或在「按码点数均分」之外兼顾单文件体积。这只会在改生成器 / 源字体时触发一次重新 vendor,不会每发一文就失效。
一句话:先靠监控知道该不该动,再选上面哪一条;不为了架构完整提前拆。
小结
本站的中文 WebFont 可以收成几句话:
- 静态文案 → 小子集,本地生成,用体积监控决定何时拆;
- 评论输入 → 完整覆盖的分块字体族,产物进仓,CI STRICT 校验;
- 两套字体族隔离,避免「半套子集 + 系统回退」的拼盘字形;
- 跨平台校验必须对文本换行不敏感,否则 Windows 与 Linux 会为同一份内容打出两套哈希。
日常开发继续 npm run dev / npm run build 即可。只有改评论字体策略或源字体时,才需要本地更新 comment 产物与 manifest-fragment.json 并提交;静态子集仍交给缓存与生成器。
延伸阅读
完整的生成流程、缓存路径、升级检查清单与排障步骤,见仓库里的维护手册:
字体子集技术方案(docs/font-subsetting.md)
若你也在 Astro + GitHub Pages 上自托管中文 WebFont,希望这篇里的双轨拆分和换行符坑能少让你踩一次。

聊聊这篇
欢迎留下想法。邮箱不会公开,只用来识别你的留言身份。