字体优化
概述
字体是网页设计中不可或缺的元素,它直接影响着页面的可读性和视觉体验。然而,字体文件往往是较大的资源,不当的加载策略会导致页面加载变慢甚至出现糟糕的用户体验。你可能经历过这样的情况:打开一个网页后,文字先是显示为某种系统默认字体,然后突然跳变成自定义字体——这种闪烁现象被称为 FOUT(Flash of Unstyled Text)。或者更糟糕的情况是,文字完全不可见,要等待字体文件下载完成才能看到——这是 FOIT(Flash of Invisible Text)。
字体优化的挑战在于平衡美学和性能。自定义字体能够提升品牌形象和可读性,但字体文件的大小可能从几十KB到几MB不等。中文字体尤其棘手,一个完整的中文字库可能超过 10MB,直接加载几乎不可接受。
本节将系统讲解字体加载的完整流程、Web 字体格式的选择、字体子集化技术、font-display 策略、字体预加载,以及自托管与 CDN 的权衡。我们会深入探讨每种技术的原理和适用场景,帮助你构建既美观又高性能的字体加载策略。
目标
- 理解字体加载的完整流程与浏览器行为
- 掌握
font-display属性的各种策略及其适用场景 - 学会使用字体子集化技术大幅减少字体体积
- 掌握字体预加载和自托管的最佳实践
知识体系
1. Web 字体加载流程
理解字体加载的性能问题,首先需要理解浏览器加载字体的完整过程。
当浏览器解析 HTML 遇到 <link rel="stylesheet"> 或 CSS 中的 @font-face 规则时,会开始下载字体文件。下载完成后,浏览器不会立即使用新字体渲染文本,而是遵循一个特定的渲染策略。这个策略由 font-display 属性控制。
字体加载时间线:
请求发起 ──────────────────────── 字体加载完成
│ │
├── Block Period ──┤── Swap Period ──┤
│ │ │
│ 文本不可见(FOIT) │ 使用后备字体 │ 使用 Web 字体
浏览器将字体加载过程分为几个阶段。Block Period(阻塞期)内,浏览器会使用后备字体渲染文本,如果此时字体未加载完成,文本保持不可见状态(FOIT)。Swap Period(交换期)内,如果字体还未加载,浏览器会使用后备字体渲染文本,一旦字体加载完成则立即切换(FOUT)。Swap Period 结束后,如果字体仍未加载,浏览器会继续使用后备字体,直到字体最终加载完成。
font-display 策略
font-display 属性控制浏览器在字体加载期间的行为,有五个可选值:
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 推荐用于正文字体 */
}
/*
auto — 浏览器默认行为(通常是 block)
block — 短暂隐藏文本(3秒),然后切换
swap — 立即显示后备字体,加载完成后切换
fallback — 极短隐藏(100ms),短暂 swap 期(3秒),之后不再切换
optional — 极短隐藏(100ms),如果未加载完成则放弃使用
*/
不同策略的选择应该基于字体的重要性:
/* 正文字体:使用 swap,确保内容始终可见 */
@font-face {
font-family: 'BodyFont';
src: url('/fonts/body.woff2') format('woff2');
font-display: swap;
}
/* 图标字体:使用 block,避免显示乱码 */
@font-face {
font-family: 'IconFont';
src: url('/fonts/icons.woff2') format('woff2');
font-display: block;
}
/* 非关键装饰字体:使用 optional,不影响性能 */
@font-face {
font-family: 'DecoFont';
src: url('/fonts/deco.woff2') format('woff2');
font-display: optional;
}
正文字体使用 swap 是因为内容可读性至关重要,即使有字体切换的视觉跳跃也好过长时间看不到内容。图标字体使用 block 是因为图标通常是单个字符,切换时如果显示后备字体的图标会造成混乱。装饰性字体使用 optional 表示如果字体未准备好,浏览器可以放弃使用,这适合非关键的性能优先场景。
2. 字体格式与压缩
选择正确的字体格式可以显著减少文件体积。
格式优先级
现代浏览器支持多种字体格式,应该按优先级提供:
@font-face {
font-family: 'CustomFont';
src:
/* WOFF2:最佳压缩率,优先使用 */
url('/fonts/custom.woff2') format('woff2'),
/* WOFF:兼容旧浏览器 */
url('/fonts/custom.woff') format('woff');
/* 2024+ 已无需提供 TTF/EOT/SVG 格式 */
font-weight: 400;
font-style: normal;
}
| 格式 | 压缩率 | 浏览器支持 | 推荐度 |
|---|---|---|---|
| WOFF2 | 最高(~30% 优于 WOFF) | >97% | ⭐⭐⭐ |
| WOFF | 高 | >99% | ⭐⭐ |
| TTF | 无压缩 | >99% | ❌ 不推荐 |
| EOT | 一般 | IE only | ❌ 已淘汰 |
WOFF2 使用 Brotli 压缩,比 WOFF 的 gzip 压缩率更高。2024 年后,WOFF2 的支持率已经足够高,大多数项目可以只提供 WOFF2 格式。
可变字体(Variable Fonts)
可变字体是一种革命性的字体格式,它将多个字重和样式合并到单个文件中。
/* 可变字体:一个文件包含多种字重和样式 */
@font-face {
font-family: 'InterVariable';
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
font-weight: 100 900; /* 支持范围 */
font-style: normal;
font-display: swap;
}
/* 使用任意字重 */
.heading { font-weight: 700; }
.subheading { font-weight: 550; } /* 传统字体不支持的中间值 */
.body { font-weight: 400; }
.light { font-weight: 300; }
/* 对比传统方案:需要加载多个文件 */
/* Inter-Regular.woff2 ~90KB */
/* Inter-Medium.woff2 ~90KB */
/* Inter-Bold.woff2 ~90KB */
/* 总计 ~270KB */
/* 可变字体方案:一个文件 */
/* Inter-Variable.woff2 ~130KB */
/* 节省 ~50% 且功能更强 */
可变字体的优势在于:单个文件支持多个字重和样式,文件体积比多个独立字体文件小很多。对于需要多种字重的应用,可变字体是最佳选择。
3. 字体子集化
字体子集化是将字体文件缩减为只包含页面实际使用字符的过程。对于西文字体,这可能将文件从几百KB减少到几十KB;对于中文字体,效果更加显著。
基于 Unicode Range 的子集化
Unicode Range 允许你指定字体应该覆盖的字符范围:
/* 按语言拆分字体 */
@font-face {
font-family: 'NotoSans';
src: url('/fonts/NotoSans-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153; /* 拉丁字符 */
font-display: swap;
}
@font-face {
font-family: 'NotoSans';
src: url('/fonts/NotoSans-chinese.woff2') format('woff2');
unicode-range: U+4E00-9FFF; /* CJK 统一汉字 */
font-display: swap;
}
浏览器会根据页面内容自动选择需要下载的字体变体。如果页面只包含英文字符,就不会下载中文字体文件。
使用工具生成子集
自动化子集化需要使用专门的工具:
# 使用 pyftsubset(fonttools)生成子集
pip install fonttools brotli
# 仅保留拉丁字符
pyftsubset input.ttf \
--output-file=output-latin.woff2 \
--flavor=woff2 \
--unicodes="U+0000-00FF,U+0131,U+0152-0153" \
--layout-features='kern,liga'
# 仅保留页面中使用的字符
pyftsubset input.ttf \
--output-file=output-subset.woff2 \
--flavor=woff2 \
--text-file=used-chars.txt \
--layout-features='kern,liga'
// 使用 glyphhanger 自动分析页面使用的字符
// npx glyphhanger http://localhost:3000 --subset=fonts/input.ttf --formats=woff2
// Node.js 方式:使用 subset-font
import subsetFont from 'subset-font';
import fs from 'fs/promises';
async function createSubset(inputPath, outputPath, text) {
const fontBuffer = await fs.readFile(inputPath);
const subset = await subsetFont(fontBuffer, text, {
targetFormat: 'woff2',
});
await fs.writeFile(outputPath, subset);
}
// 仅包含项目中实际使用的中文字符
const usedChars = '前端性能优化字体子集化加载策略';
await createSubset('NotoSansSC.ttf', 'NotoSansSC-subset.woff2', usedChars);
glyphhanger 是一个自动化工具,它会访问页面并分析实际使用的字符,然后生成对应的子集字体。这比手动指定字符范围更方便,但需要注意 JavaScript 动态生成的内容可能无法被分析到。
中文字体特殊处理
中文字体的子集化尤其重要,因为完整的中文字库通常在 5-15MB 之间。有几种处理策略:
// 中文字体通常 5-15MB,必须进行子集化或按需加载
// 方案一:使用 Google Fonts 的自动分片
// Google Fonts 将中文字体拆分为 100+ 个小文件,按需加载
// 方案二:使用 cn-font-split 工具
// npx cn-font-split -i input.ttf -o output/
// 方案三:动态加载
async function loadFontForText(text) {
const uniqueChars = [...new Set(text)].join('');
const response = await fetch(
`/api/font-subset?chars=${encodeURIComponent(uniqueChars)}`
);
const fontData = await response.arrayBuffer();
const font = new FontFace('DynamicFont', fontData);
await font.load();
document.fonts.add(font);
}
Google Fonts 的自动分片是最方便的方案,但需要 CDN 支持。cn-font-split 是国内开发者维护的工具,适合自托管场景。动态加载方案最灵活,但实现复杂度也最高。
4. 字体预加载
对于关键的字体文件,预加载可以显著减少等待时间。
<head>
<!-- 预加载关键字体 -->
<link
rel="preload"
href="/fonts/body-font.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!--
注意事项:
1. crossorigin 属性是必须的(即使同源)
2. 只预加载首屏需要的字体
3. 不要预加载所有字体,否则浪费带宽
-->
</head>
crossorigin 属性是必须的,因为字体请求需要 CORS 处理。缺少这个属性会导致字体加载失败。
5. 后备字体匹配优化
字体切换时,后备字体与 Web 字体的尺寸差异会导致布局偏移(影响 CLS)。size-adjust 等属性可以帮助平滑这种过渡。
/* 使用 size-adjust 匹配后备字体和 Web 字体的尺寸 */
/* 减少字体切换时的 CLS */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
}
/* 定义与 CustomFont 尺寸匹配的后备字体 */
@font-face {
font-family: 'CustomFont Fallback';
src: local('Arial');
size-adjust: 105%; /* 调整整体大小 */
ascent-override: 90%; /* 调整上升线 */
descent-override: 20%; /* 调整下降线 */
line-gap-override: 0%; /* 调整行间距 */
}
body {
font-family: 'CustomFont', 'CustomFont Fallback', sans-serif;
}
size-adjust 修改后备字体的整体缩放比例。ascent-override 和 descent-override 分别控制上升线和下降线的比例。这些值需要根据具体字体来调整,可以通过测量两种字体的 x-height 或 cap-height 来计算。
Google 的 next/font 和 Fontsource 库会自动计算这些调整值,如果你使用这些方案,可以自动获得优化的后备字体。
6. 自托管 vs CDN
字体可以存放在自己的服务器上(自托管)或使用第三方 CDN(如 Google Fonts)。两者各有优劣。
| 方面 | 自托管 | Google Fonts CDN |
|---|---|---|
| 控制权 | 完全控制 | 依赖第三方 |
| 隐私 | 无第三方请求 | 发送用户数据到 Google |
| 性能 | 同域加载,无额外 DNS | 需要额外 DNS + 连接 |
| 缓存 | 长期缓存控制 | CDN 缓存不再跨站共享 |
| 维护 | 需要自行更新 | 自动更新 |
# 使用 google-webfonts-helper 下载自托管文件
# https://gwfh.mranftl.com/fonts
# 或使用 fontsource
npm install @fontsource-variable/inter
// 在项目中导入
import '@fontsource-variable/inter';
// 或按需导入
import '@fontsource-variable/inter/wght.css';
Fontsource 提供了流行的开源字体的 npm 包,可以像其他 npm 包一样管理和构建。它会自动处理子集化(只包含你使用的字符),文件体积远小于完整字体。
实战练习
练习 1:字体加载策略对比
实现三种 font-display 策略(swap、fallback、optional)的演示页面。对比各策略下的 CLS 差异,以及用户感知的内容可用时间。使用 Lighthouse 测量不同策略下的性能指标。
练习 2:中文字体子集化
对一个包含中文内容的页面进行字体子集化处理,使用 pyftsubset 或 glyphhanger 生成只包含页面使用字符的子集。对比子集化前后的字体文件大小。
练习 3:零 CLS 字体切换
使用 size-adjust、ascent-override 等 CSS 属性实现字体切换时零布局偏移。通过测量和计算两种字体的尺寸差异,调整后备字体的参数直到布局完全稳定。
延展阅读
- web.dev — Web Font 最佳实践 — Google 官方的 Web 字体优化指南
- CSS Font Loading API — MDN 上的 Font Loading API 文档
- Variable Fonts 指南 — Google 官方的可变字体介绍和使用指南
- Fontsource — 开源字体的 npm 包集合
关键术语
| 术语 | 解释 |
|---|---|
| FOIT | Flash of Invisible Text,字体加载期间文本不可见 |
| FOUT | Flash of Unstyled Text,字体切换时的文本闪烁 |
| WOFF2 | Web Open Font Format 2,当前最优的 Web 字体格式 |
| Variable Font | 可变字体,单个文件包含多种字重/样式 |
| Subsetting | 字体子集化,仅保留需要的字符 |
| unicode-range | CSS 属性,指定字体覆盖的字符范围 |
| size-adjust | CSS 属性,调整后备字体大小以匹配 Web 字体 |
| font-display | CSS 属性,控制字体加载期间的显示行为 |