第一轮:用系统默认字体
刚开始搭博客,正文字体直接用了 CSS 的默认衬线族:
font-family: Georgia, 'Times New Roman', serif;
写几篇文章看下来,有几个问题让我不舒服。一个是 Windows 和 macOS 上显示效果差很多,Georgia 在 Windows 上渲染偏粗。另一个是代码块等宽字体,系统自带 Courier New 在高分屏上锯齿感很明显。
这个问题不严重,但每次打开自己博客都会注意到。就像房间里一盏灯忽明忽暗,问题不大,但一直存在。
第二轮:引入 Google Fonts
2023 年前后,Google Fonts 在国内访问还比较稳定。我选了两套字体:
- 正文用 Noto Serif SC(思源宋体),中文衬线,在屏幕上阅读体验比宋体好很多
- 代码用 JetBrains Mono,等宽,字符区分度高(0 和 O、1 和 l 的区分很明显)
引入方式很简单,在 HTML head 里加两行 <link>:
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
效果立竿见影。页面加载完之后,文字会从系统字体切换成 Noto Serif SC,平滑过渡。
但问题也跟着来了。
一个是加载延迟。Google Fonts 的 CSS 文件很小,但字体文件本身不小—Noto Serif SC 一个字重就 200KB+,两个权重加起来 400KB。页面已经渲染完了,字体还在加载,会出现 FOIT(Flash of Invisible Text,文字短暂消失)或者 FOUT(Flash of Unstyled Text,文字先显示默认字体再切换)。
另一个是依赖外部服务。Google Fonts 在国内偶尔抽风,有时候字体加载要等 3 秒以上,页面文字一直显示默认字体,体验很差。
第三轮:加上 display=swap
Google Fonts 的文档推荐加 display=swap 参数,让浏览器用系统字体先显示文字,等 Google Fonts 加载完再替换。
这解决了 FOIT 的问题,但 FOUT 还是存在—页面加载过程中文字会”闪”一下,从系统字体变成 Noto Serif SC。
这个闪动在慢网络下特别明显。我自己在 3G 环境下测试,文字会在两种字体之间切换两三次,看起来很业余。
当时想了几个方案:
- 用
font-display: optional,让浏览器只在字体快速可用时才用,否则一直用系统字体—这等于白引入了 Google Fonts - 用
preload强制提前加载字体文件—增加了首屏阻塞时间 - 用
font-display: fallback,给 100ms 的极短窗口等待字体,超时则用系统字体—效果不稳定
三个方案都不理想。
第四轮:自托管
最终让我下定决心自托管,是 Cloudflare Pages 的构建日志。
有一次我打开构建详情,发现每次部署都会重新从 Google Fonts 下载字体文件,@font-face 的 URL 指向外部 CDN,每次页面访问都要多两三个 HTTP 请求。
对于一个静态博客来说,这完全没必要。字体文件是静态资源,完全可以跟着项目一起部署。
自托管要解决第一个问题是:怎么把 Google Fonts 的字体文件下载下来?
Google Fonts 的 CSS API 返回的是 @font-face 规则,里面的 src 指向实际字体文件 URL。这些 URL 是动态,带 hash,不能直接下载。
我写了一个 Python 脚本解决这件事:
- 请求 Google Fonts 的 CSS API,拿到
@font-face规则 - 解析出
src: url(...)中的字体文件 URL - 下载 woff2 文件到本地
public/fonts/目录 - 生成一份本地
fonts.css,把@font-face指向本地文件
JetBrains Mono 有 6 个字重(400/500/600/700/800),Noto Serif SC 有 101 个字符集文件(每个对应一部分汉字),全部下载下来大约 3MB。
这 3MB 跟着 Git 仓库走,部署到 Cloudflare Pages 之后,字体文件和页面其他静态资源走同一个 CDN,加载速度比 Google Fonts CDN 快,而且不依赖外部服务。
实际效果
自托管之后,字体加载行为变得可预测了:
- 首次访问:浏览器下载
fonts.css+ woff2 文件,缓存到本地 - 后续访问:直接读缓存,0ms 延迟
- 离线状态:字体文件在本地,完全不受影响
FOUT 的问题也基本消失了。因为字体文件和 HTML/CSS 是同一域名下资源,浏览器可以并行加载,切换窗口比跨域请求短得多。
还有一个附带收益:可以用 font-display: swap 大胆地用,不怕外部 CDN 慢。因为本地字体文件加载延迟基本可以忽略(<50ms),swap 的行为于”几乎不闪”。
踩到的一个坑
自托管有个问题我一开始没意识到:Noto Serif SC 的 woff2 文件是按 Unicode 区间拆分。
也就是说,一个 400 字重的 woff2 文件只包含一部分汉字。浏览器会根据页面实际用到字符,按需下载对应 woff2 文件。
这个机制本身没问题,但 Cloudflare Pages 对每个文件都有单独 HTTP 请求。一篇正常中文博客文章,可能触发 5-8 个 woff2 文件的下载。
第一次听说这个问题时候我有点担心,后来用 Chrome DevTools 测了一下,发现实际影响很小:
- 每个 woff2 文件只有 10-30KB,比一张图片还小
- 浏览器会并行下载,5 个文件加起来耗时不到 200ms
- 下载完之后全部进缓存,后续访问不再请求
所以这个问题理论上存在,实际体验上可以忽略。
第五轮:加上 unicode-range 优化
自托管之后,我做了一步优化:在 @font-face 里显式声明 unicode-range。
Google Fonts 的 CSS API 已经帮你做了这件事,但我自己生成 fonts.css 一开始漏掉了这个字段,导致浏览器会下载全部 woff2 文件,不管页面用没用到。
加上 unicode-range 之后,浏览器只会下载当前页面需要字符集文件。对于一篇普通博客文章,这大概能省掉 60-70% 的字体文件下载量。
@font-face {
font-family: 'Noto Serif SC';
font-style: normal;
font-weight: 400;
src: url('/fonts/noto-serif-sc-400-001.woff2') format('woff2');
unicode-range: U+2e6-2e7, U+2000-206f, ...;
}
Google Fonts 的 CSS API 返回结果里已经包含了正确 unicode-range,我的 Python 脚本直接解析并写入即可,不需要自己算。
现在的方案
总结一下,现在的字体方案是:
| 项目 | 方案 |
|---|---|
| 正文字体 | Noto Serif SC 400/700,自托管 woff2 |
| 代码字体 | JetBrains Mono 400/700,自托管 woff2 |
| 标题字体 | 同正文字体(保持统一) |
| 加载策略 | font-display: swap,本地下载 |
| 文件大小 | 约 3MB(含全部字重和字符集) |
| 外部依赖 | 零 |
从引入 Google Fonts 到最终自托管,中间隔了大概一年。不是因为这件事很难,是因为它不紧急—字体加载慢两秒,博客照样能用,文章照样能看。
但折腾完之后回头看,这件事价值不只是”快了两秒”。更重要是,我把一个依赖外部服务部分,变成了完全可控部分。博客部署到任何地方,字体都能正常加载,不需要在乎 Google Fonts 在中国能不能访问。
这种”可控性”,是我在技术选型里越来越看重维度。
附:我的下载脚本
如果你想用自托管方案,可以用类似思路。核心步骤是:
- 请求 Google Fonts CSS API,拿到
@font-face列表 - 解析每个
@font-face的srcURL 和unicode-range - 下载 woff2 文件到本地
- 生成本地
fonts.css,指向本地文件
Python 实现大概 80 行,主要用 requests 请求 + 正则解析 CSS。如果你用是 Astro,可以把 fonts.css 放到 public/fonts/ 目录下,页面直接引用 /fonts/fonts.css 即可。
如果你想要这个脚本,可以给我留言,我把完整代码贴出来。
种下你的想法
在花园里留下一条评论,和这篇文章一起生长。