Web 字体优化:从加载到渲染的全链路调优

Web 字体这东西,看着简单,用起来坑不少。我早期做的一个内容型网站,就因为引了一款花哨的英文字体,首屏加载硬生生多了 3 秒。用户反馈说「页面白了好久,然后字突然闪了一下」——嗯,那就是典型的 FOIT 和 FOUT 问题。

这一章,咱们把 @font-face 规则、字体子集化、WOFF2 压缩,还有加载策略串起来讲。说白了,就是让字体文件变小、加载变快、渲染不闪。

@font-face 规则:别只写个 src 就完事

很多人写 @font-face 就一行 src,然后扔个 woff2 上去。其实这里面有不少细节。

先看一个我推荐的基础写法:

@font-face {
  font-family: 'MyFont';
  src: url('myfont.woff2') format('woff2'),
       url('myfont.woff') format('woff');
  font-display: swap;
  font-weight: 400;
  font-style: normal;
  unicode-range: U+4E00-9FFF; /* 仅中文字符 */
}

这里有几个关键点:

  • src 顺序:WOFF2 放前面,WOFF 做降级。浏览器会按顺序下载第一个能识别的格式。
  • font-display:控制字体加载期间的渲染行为。我后面会细讲。
  • unicode-range:限定字符集范围。这个对中文字体特别有用。
注意: 不要同时声明多个 font-weight 和 font-style 变体却不指定具体值。浏览器会尝试合成,结果往往很丑。每个变体单独写一个 @font-face 块。

字体子集化:把 20MB 的中文字体砍到 200KB

中文字体为什么大?因为常用汉字就有 6000 多个,加上标点、符号,一个完整的 GB2312 字库至少 7000+ 字符。英文字体才 200 多个字符,你说差距大不大?

字体子集化,就是只保留你页面实际用到的字符。我在一个电商项目中,把一款 12MB 的思源黑体,子集化后只剩 180KB——因为那个页面只用了 300 多个汉字。

常用的子集化工具有:

工具 适用场景 特点
glyphhanger 命令行,适合 CI/CD 自动扫描 HTML/JS 提取字符
fonttools (pyftsubset) Python 生态 灵活,支持高级选项
FontSquirrel Webfont Generator 在线工具,快速验证 可视化操作,适合新手
subfont Node.js 生态 自动集成到构建流程

我用 glyphhanger 比较多,命令很简单:

# 先扫描页面用到的字符
glyphhanger https://example.com --spider --css

# 然后子集化
pyftsubset source.woff2 --unicodes-file=glyphhanger.txt --output-file=subset.woff2
经验之谈: 子集化时保留常用标点符号和数字。我遇到过只保留汉字,结果价格「¥199」里的 ¥ 符号显示成方框的尴尬情况。

WOFF2 压缩:比 WOFF 再小 30%

WOFF2 是当前 Web 字体格式的终点站。它用 Brotli 算法做预处理,压缩率比 WOFF 高 30% 左右。而且所有现代浏览器都支持了,没必要再纠结 EOT 或 SVG 字体。

转换工具推荐:

  • Google woff2:官方工具,命令行操作
  • FontForge:图形界面,支持批量
  • 在线转换:transfonter.org,方便快捷

命令行转换示例:

# 安装 woff2 工具
brew install woff2

# TTF 转 WOFF2
woff2_compress myfont.ttf

# WOFF2 转 TTF(调试用)
woff2_decompress myfont.woff2

你想想看,一个 500KB 的 TTF 文件,转成 WOFF2 可能只剩 150KB。再配合子集化,最终可能只有 30-50KB。这个优化空间,值得花时间做。

字体加载策略:FOUT vs FOIT

这两个概念,我当年踩过坑才真正理解。

  • FOIT (Flash of Invisible Text):字体加载期间文字不可见。用户看到的是空白。
  • FOUT (Flash of Unstyled Text):先用后备字体显示,等 Web 字体加载完再切换。用户看到的是字体「闪变」。

哪个更好?说实话,没有绝对答案。但我个人更倾向 FOUT。为什么?因为用户至少能先看到内容,而不是盯着白屏等字体加载。

控制策略靠 font-display 属性:

值 行为 适用场景
auto 浏览器默认(通常是 FOIT) 不推荐,行为不可控
block FOIT,最多等 3 秒 品牌字体,必须显示
swap FOUT,立即用后备字体 内容优先,推荐
fallback FOIT 短时间(约 100ms),然后 FOUT 折中方案
optional 如果字体没缓存,就用后备字体 非关键字体

我一般这样用:

/* 正文用 swap,保证内容可读 */
body {
  font-family: 'MyFont', system-ui, sans-serif;
}

/* 标题用 fallback,稍微等一下品牌字体 */
h1, h2, h3 {
  font-family: 'MyFontDisplay', serif;
}
核心原则: 内容可读性 > 字体一致性。用户是来看内容的,不是来看字体的。

完整优化流程:一张图说清楚

下面这张图,是我在实际项目中总结的字体优化流程。从原始字体文件到最终上线,每一步都有明确目标。

Web 字体优化全流程 原始字体文件 字体子集化 WOFF2 压缩 @font-face 声明 font-display 策略 上线 & 监控 各阶段目标 ① 原始文件:TTF/OTF ② 子集化:只保留用到的字符 ③ WOFF2:Brotli 压缩 ④ @font-face:声明字体族 ⑤ font-display:控制渲染 ⑥ 上线后监控 FOUT/FOIT 文件大小 ↓ 80-90% 加载时间 ↓ 60-80% 渲染体验 无闪烁

避坑指南:我踩过的几个坑

讲几个真实案例,你遇到了能少走弯路。

坑一:子集化后字体文件损坏
我曾经用某个在线工具子集化一款商业字体,结果生成的文件在 iOS 上完全显示不出来。排查了半天,发现是工具没正确处理字体中的 hinting 信息。后来改用 fonttools 的 pyftsubset,问题解决。
坑二:font-display: swap 导致布局偏移
后备字体和 Web 字体宽度不一致,加载完成后文字重排,页面内容「跳」了一下。解决方案:用 size-adjust 属性调整后备字体的度量值,或者选择宽度相近的后备字体。
坑三:忽略 unicode-range 的浏览器兼容性
老版本 Safari 对 unicode-range 支持有问题。我的做法是:先不加 unicode-range 做全量加载,等页面稳定后再逐步加上。或者用 @supports 做特性检测。

总结一下

Web 字体优化,核心就三件事:

  1. 让文件变小:子集化 + WOFF2,这是最立竿见影的优化
  2. 让加载变快:合理的 @font-face 声明 + 预加载提示
  3. 让渲染不闪:font-display 策略 + 后备字体匹配

做完这三步,你的字体加载体验基本就合格了。剩下的就是上线后监控,看看有没有异常情况。