第一轮:用系统默认字体

刚开始搭博客,正文字体直接用了 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 环境下测试,文字会在两种字体之间切换两三次,看起来很业余。

当时想了几个方案:

  1. font-display: optional,让浏览器只在字体快速可用时才用,否则一直用系统字体—这等于白引入了 Google Fonts
  2. preload 强制提前加载字体文件—增加了首屏阻塞时间
  3. 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 脚本解决这件事:

  1. 请求 Google Fonts 的 CSS API,拿到 @font-face 规则
  2. 解析出 src: url(...) 中的字体文件 URL
  3. 下载 woff2 文件到本地 public/fonts/ 目录
  4. 生成一份本地 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 在中国能不能访问。

这种”可控性”,是我在技术选型里越来越看重维度。

附:我的下载脚本

如果你想用自托管方案,可以用类似思路。核心步骤是:

  1. 请求 Google Fonts CSS API,拿到 @font-face 列表
  2. 解析每个 @font-facesrc URL 和 unicode-range
  3. 下载 woff2 文件到本地
  4. 生成本地 fonts.css,指向本地文件

Python 实现大概 80 行,主要用 requests 请求 + 正则解析 CSS。如果你用是 Astro,可以把 fonts.css 放到 public/fonts/ 目录下,页面直接引用 /fonts/fonts.css 即可。

如果你想要这个脚本,可以给我留言,我把完整代码贴出来。

种下你的想法

在花园里留下一条评论,和这篇文章一起生长。

COMMENTS