字体优化

概述

字体是网页设计中不可或缺的元素,它直接影响着页面的可读性和视觉体验。然而,字体文件往往是较大的资源,不当的加载策略会导致页面加载变慢甚至出现糟糕的用户体验。你可能经历过这样的情况:打开一个网页后,文字先是显示为某种系统默认字体,然后突然跳变成自定义字体——这种闪烁现象被称为 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-overridedescent-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-adjustascent-override 等 CSS 属性实现字体切换时零布局偏移。通过测量和计算两种字体的尺寸差异,调整后备字体的参数直到布局完全稳定。


延展阅读


关键术语

术语 解释
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 属性,控制字体加载期间的显示行为