Web 字体优化实战:从 FOIT 到 CLS 的完整性能调优指南

By | 2026年9月21日

为什么你的网站还在被字体拖慢?

在 Web 性能优化中,字体往往是最容易被忽视的一环。一个 300KB 的中文字体文件,可能让首屏渲染延迟 1 秒以上;一个未优化的 @font-face 声明,可能导致文字长时间不可见(FOIT)或突然跳动(FOUT)。更隐蔽的是,字体切换引发的布局偏移(CLS)会直接拉低 Core Web Vitals 评分。

本文将从实际场景出发,带你一步步完成字体性能优化。读完你将掌握:

  • 如何精准分析字体加载瓶颈
  • 字体子集化与格式选择的最佳实践
  • 预加载、font-display 与 CSS 度量覆盖的组合拳
  • 动态字体的按需加载策略

1. 诊断:你的字体到底慢在哪里?

优化之前,先测量。打开 Chrome DevTools 的 Network 面板,筛选 Font 类型,观察:

  • 字体文件大小(通常中文字体 2-10MB,英文字体 20-100KB)
  • 加载时间与 TTFB
  • 是否阻塞了文本渲染

更专业的方式是使用 Lighthouse 的“避免巨大的网络负载”和“确保文本在 Web 字体加载期间保持可见”审计项。

关键指标:

  • FOIT(Flash of Invisible Text):字体加载期间文本不可见,通常由 font-display: block 引起。
  • FOUT(Flash of Unstyled Text):先用系统字体显示,字体加载后替换,可能导致布局偏移。
  • CLS(Cumulative Layout Shift):字体替换时字符宽度变化导致的布局移动。

2. 字体子集化:只加载用到的字符

中文字体动辄数 MB,但页面可能只用了 500 个汉字。子集化是最高效的优化手段。

2.1 使用 fontmin 进行子集化

安装:


npm install fontmin --save-dev

创建一个 Node.js 脚本 subset.js:


const Fontmin = require('fontmin');
const fs = require('fs');

// 提取页面中用到的所有字符
const text = fs.readFileSync('./src/index.html', 'utf8');
const usedChars = Array.from(new Set(text.replace(/\s/g, ''))).join('');

const fontmin = new Fontmin()
  .src('./fonts/SourceHanSans-Regular.ttf')
  .use(Fontmin.glyph({
    text: usedChars, // 只保留这些字符
    hinting: false   // 移除 hinting 减小体积
  }))
  .use(Fontmin.ttf2woff2()) // 转换为 woff2 格式
  .dest('./dist/fonts/');

fontmin.run((err, files) => {
  if (err) throw err;
  console.log('子集化完成,生成文件:', files.map(f => f.path));
});

运行 node subset.js,你会得到一个只有几十 KB 的 woff2 文件。

💡 注意:如果页面内容是动态的(如用户评论),子集化可能遗漏字符。此时可以保留一个完整的后备字体,或使用 unicode-range 分片加载。

2.2 使用 unicode-range 按需分片

对于大型多语言站点,可以将字体按 Unicode 范围拆分为多个文件:


@font-face {
  font-family: 'MyFont';
  src: url('myfont-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
  font-display: swap;
}
@font-face {
  font-family: 'MyFont';
  src: url('myfont-chinese.woff2') format('woff2');
  unicode-range: U+4E00-9FFF;
  font-display: swap;
}

浏览器只会下载当前页面实际用到的字符所在的分片,极大减少流量。

3. 预加载关键字体

字体通常是在 CSS 解析后才发现并请求的,这会导致延迟。使用 提前触发下载:


<link rel="preload" href="/fonts/myfont-subset.woff2" as="font" type="font/woff2" crossorigin>

重要:crossorigin 属性必须加,即使字体同源。否则浏览器会发起两次请求。

💡 注意:只预加载首屏必需的字体,避免与关键 CSS/JS 争抢带宽。

4. 选择合适的 font-display 策略

font-display 决定字体加载期间文本的显示方式:

| 值 | 行为 | 适用场景 | |—-|——|———-| | auto | 浏览器默认(通常类似 block) | 不推荐 | | block | 短暂不可见(约 3s),然后回退 | 图标字体 | | swap | 立即用回退字体显示,加载后替换 | 正文文本 | | fallback | 极短不可见(约 100ms),然后回退 | 平衡型 | | optional | 极短不可见,若未加载则使用回退且不替换 | 性能优先 |

推荐:正文使用 swap,标题使用 optional 或 fallback。


@font-face {
  font-family: 'BodyFont';
  src: url('body.woff2') format('woff2');
  font-display: swap;
}

5. 消除 CLS:使用 CSS 度量覆盖

即使使用 swap,字体替换时字符宽度不同仍会导致布局偏移。解决方案是让回退字体的度量与自定义字体接近。

5.1 使用 size-adjust 和 ascent-override


@font-face {
  font-family: 'BodyFont';
  src: url('body.woff2') format('woff2');
  font-display: swap;
  size-adjust: 105%; /* 调整整体大小 */
  ascent-override: 90%; /* 调整上伸部 */
  descent-override: 22%; /* 调整下伸部 */
  line-gap-override: 0%;
}

/* 回退字体定义 */
@font-face {
  font-family: 'BodyFont-Fallback';
  src: local('Arial');
  size-adjust: 105%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'BodyFont', 'BodyFont-Fallback', sans-serif;
}

5.2 自动生成度量覆盖

手动计算很麻烦,可以使用 fontaine 或 @capsizecss/metrics 自动生成。

安装:


npm install @capsizecss/metrics

示例脚本:


const { createFontStack } = require('@capsizecss/core');
const { Roboto } = require('@capsizecss/metrics/roboto');
const { Arial } = require('@capsizecss/metrics/arial');

const { fontFamily, fontFaces } = createFontStack([Roboto, Arial]);

console.log(fontFamily); // 生成的 font-family 值
console.log(fontFaces);  // 对应的 @font-face 规则

将生成的 CSS 插入样式表即可。

6. 动态字体按需加载(进阶)

对于用户生成内容或富文本编辑器,可以监听字体加载事件,动态调整:


document.fonts.ready.then(() => {
  console.log('所有字体加载完成');
  document.body.classList.add('fonts-loaded');
});

// 或者监听特定字体
document.fonts.load('1em BodyFont').then(() => {
  console.log('BodyFont 已加载');
});

配合 CSS:


body {
  font-family: 'BodyFont-Fallback', sans-serif;
}
.fonts-loaded body {
  font-family: 'BodyFont', 'BodyFont-Fallback', sans-serif;
}

7. 完整示例:优化一个中文博客的字体

假设我们有一个中文博客,使用思源黑体。

步骤 1:提取页面用到的字符,用 fontmin 子集化,得到 sourcehan-subset.woff2(约 80KB)。

步骤 2:HTML 中预加载:


<link rel="preload" href="/fonts/sourcehan-subset.woff2" as="font" type="font/woff2" crossorigin>

步骤 3:CSS 定义:


@font-face {
  font-family: 'SourceHan';
  src: url('/fonts/sourcehan-subset.woff2') format('woff2');
  font-display: swap;
  size-adjust: 100%;
  ascent-override: 88%;
  descent-override: 12%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'SourceHan-Fallback';
  src: local('PingFang SC'), local('Microsoft YaHei');
  size-adjust: 100%;
  ascent-override: 88%;
  descent-override: 12%;
  line-gap-override: 0%;
}

body {
  font-family: 'SourceHan', 'SourceHan-Fallback', sans-serif;
}

步骤 4:使用 Lighthouse 验证 CLS 从 0.25 降至 0.02。

8. 常见坑与最佳实践

  • 坑 1:忘记 crossorigin 导致重复下载。
  • 坑 2:font-display: block 导致 FOIT 时间过长,用户看到空白。
  • 坑 3:子集化时未包含标点符号,导致显示为方框。
  • 坑 4:使用 local() 时,不同系统字体差异大,需测试多平台。
  • 最佳实践:始终提供系统字体回退栈,并确保回退字体与自定义字体度量接近。
  • 最佳实践:使用 font-display: swap + 预加载 + 度量覆盖,三者结合效果最佳。

总结

Web 字体优化不是单一技巧,而是一套组合策略:子集化减小体积 → 预加载提前请求 → font-display 控制渲染 → 度量覆盖消除 CLS。每一步都能带来可量化的性能提升。

建议你从今天开始,用 Lighthouse 审计自己的网站,按照本文步骤逐一优化。字体虽小,却直接影响用户体验和 SEO 排名。

延伸阅读: