Web 字体加载优化完全指南:从 FOIT 到丝滑体验
你精心设计的网站,用户打开后却看到一片空白文字区域,几秒后才"闪现"出漂亮的字体。这种体验,足以让用户在等待中流失。字体加载,是前端性能优化中最容易被忽视,却又影响深远的环节。
字体加载的"隐形杀手"
FOIT 和 FOUT 是什么
当浏览器发现页面使用了自定义字体,会在字体加载完成前采取两种策略:
FOIT (Flash of Invisible Text) 文字完全不可见,直到字体加载完成。用户看到的是空白区域。
FOUT (Flash of Unstyled Text)
先用系统字体显示,字体加载完成后再替换。用户会看到"闪烁"效果。
FOIT 时间线:
[========空白========] → [自定义字体显示]
3 秒等待 突然出现
FOUT 时间线:
[系统字体显示] → [切换] → [自定义字体显示]
可读但丑 闪烁 完美
哪种更好?从用户体验角度,FOUT 显然更友好——至少用户能阅读内容。
字体加载对性能指标的影响
| 指标 | 影响 |
|---|---|
| LCP | 如果最大内容是文本,字体阻塞会直接影响 LCP |
| CLS | 字体切换时的尺寸变化会导致布局偏移 |
| FCP | 在某些浏览器中,字体会阻塞首次内容绘制 |
font-display:你的第一道防线
font-display 属性让你控制字体的加载行为,它是优化字体体验的起点。
五种策略详解
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
/* 选择一种策略 */
font-display: auto | block | swap | fallback | optional;
}
auto(默认) 浏览器自行决定,通常表现为 FOIT,阻塞期约 3 秒。
block 强制 FOIT,阻塞期最长可达 3 秒。适合图标字体等必须等待的场景。
swap(推荐) 立即使用回退字体,字体加载完成后替换。无限等待字体加载。
/* 正文字体推荐使用 swap */
@font-face {
font-family: 'ArticleFont';
src: url('/fonts/article.woff2') format('woff2');
font-display: swap;
}
fallback 短暂阻塞(约 100ms),如果字体 3 秒内未加载完成,放弃使用。
/* 装饰性字体可以使用 fallback */
@font-face {
font-family: 'FancyTitle';
src: url('/fonts/fancy.woff2') format('woff2');
font-display: fallback;
}
optional 极短阻塞(约 100ms),由浏览器决定是否使用字体。适合非关键字体。
策略选择指南
关键正文字体 → swap
- 用户需要立即阅读内容
品牌标题字体 → swap 或 fallback
- 品牌一致性重要,但内容可读性更重要
图标字体 → block
- 图标显示错误比延迟显示更糟糕
装饰性字体 → optional
- 没有也不影响功能
预加载:让字体抢跑
默认情况下,浏览器只有在解析 CSS 并发现字体引用后才开始加载字体。使用 preload 可以让字体提前加载。
基础预加载
<head>
<!-- 在 CSS 之前预加载关键字体 -->
<link
rel="preload"
href="/fonts/main.woff2"
as="font"
type="font/woff2"
crossorigin
>
<!-- 然后是样式表 -->
<link rel="stylesheet" href="/styles/main.css">
</head>
注意事项:
crossorigin属性是必须的,即使字体在同域- 只预加载最关键的 1-2 个字体文件
- 预加载过多字体会适得其反
智能预加载策略
// utils/font-loader.js
export function preloadCriticalFonts() {
const fonts = [
{ href: '/fonts/main-regular.woff2', weight: '400' },
{ href: '/fonts/main-bold.woff2', weight: '700' },
];
// 只在支持 woff2 的浏览器中预加载
if (!document.createElement('link').relList?.supports('preload')) {
return;
}
fonts.forEach(font => {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.type = 'font/woff2';
link.crossOrigin = 'anonymous';
link.href = font.href;
document.head.appendChild(link);
});
}
// 检测字体加载状态
export function onFontLoad(fontFamily) {
return document.fonts.ready.then(() => {
return document.fonts.check(`16px ${fontFamily}`);
});
}
字体子集化:只加载需要的字符
一个完整的中文字体可能有 20MB+,但你的页面可能只用到了几百个汉字。子集化就是只保留需要的字符。
使用 unicode-range
/* 只加载中文常用字 */
@font-face {
font-family: 'ChineseFont';
src: url('/fonts/chinese-common.woff2') format('woff2');
unicode-range: U+4E00-9FFF; /* CJK 统一表意文字 */
font-display: swap;
}
/* 数字和英文用另一个字体 */
@font-face {
font-family: 'ChineseFont';
src: url('/fonts/latin.woff2') format('woff2');
unicode-range: U+0000-00FF; /* 基本拉丁字母 */
font-display: swap;
}
Google Fonts 的分片策略
Google Fonts 已经内置了分片机制,这是它的工作原理:
/* Google Fonts 返回的 CSS 示例 */
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu5mxKKTU1Kvnz.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Roboto';
src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA;
}
使用工具生成子集
# 使用 pyftsubset(fonttools)
pip install fonttools brotli
# 从原始字体生成子集
pyftsubset "SourceHanSansCN-Regular.otf" \
--text-file=chars.txt \
--output-file="subset.woff2" \
--flavor=woff2 \
--layout-features='*'
# chars.txt 包含你页面用到的所有字符
// 提取页面用到的字符
const content = document.body.textContent;
const uniqueChars = [...new Set(content)].join('');
console.log(uniqueChars);
可变字体:一个文件,无限可能
可变字体(Variable Fonts)是字体技术的革命。一个字体文件包含了从细到粗、从窄到宽的所有变体。
基础使用
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-VariableFont.woff2') format('woff2-variations');
font-weight: 100 900; /* 支持 100-900 的任意权重 */
font-display: swap;
}
/* 使用任意权重值 */
.thin { font-weight: 100; }
.regular { font-weight: 400; }
.medium { font-weight: 550; } /* 传统字体做不到! */
.bold { font-weight: 700; }
.black { font-weight: 900; }
可变字体的轴
可变字体支持多个可变轴:
.text {
font-family: 'Roboto Flex', sans-serif;
/* 使用 font-variation-settings 控制各个轴 */
font-variation-settings:
'wght' 450, /* 字重 */
'wdth' 100, /* 字宽 */
'slnt' -10, /* 倾斜度 */
'GRAD' 50, /* 灰度(可选轴) */
'opsz' 24; /* 光学尺寸 */
}
/* 或使用对应的 CSS 属性 */
.text {
font-weight: 450;
font-stretch: 100%;
font-style: oblique -10deg;
}
可变字体的性能优势
| 场景 | 传统字体 | 可变字体 |
|---|---|---|
| Regular + Bold | 2 个文件 (~50KB) | 1 个文件 (~30KB) |
| 4 种权重 | 4 个文件 (~100KB) | 1 个文件 (~30KB) |
| 8 种权重 | 8 个文件 (~200KB) | 1 个文件 (~30KB) |
/* 如果只需要特定权重范围,可以指定 */
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
/* 只声明实际使用的范围,帮助浏览器优化 */
font-weight: 400 700;
}
回退字体优化:减少 CLS
字体切换时,自定义字体和回退字体的尺寸差异会导致布局偏移。
使用 size-adjust
/* 调整回退字体大小,匹配自定义字体 */
@font-face {
font-family: 'Adjusted Arial';
src: local('Arial');
size-adjust: 106%; /* 调整大小 */
ascent-override: 90%;
descent-override: 20%;
line-gap-override: 0%;
}
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
}
body {
font-family: 'CustomFont', 'Adjusted Arial', sans-serif;
}
使用 @font-face-metric-override
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
/* 度量覆盖 */
ascent-override: 92%;
descent-override: 22%;
line-gap-override: 0%;
}
自动计算工具
// 使用 fontpie 自动计算回退字体调整值
// npm install fontpie
const { generateFallbackCSS } = require('fontpie');
const css = await generateFallbackCSS({
font: './fonts/CustomFont.woff2',
fallback: 'Arial',
});
console.log(css);
// 输出调整后的 @font-face 规则
自托管 vs CDN
自托管优势
# nginx 配置:启用缓存和压缩
location /fonts/ {
expires 1y;
add_header Cache-Control "public, immutable";
# 预压缩
gzip_static on;
brotli_static on;
}
- 完全控制缓存策略
- 无第三方依赖
- 隐私合规(GDPR)
- 可以与页面资源同域,减少连接开销
CDN 优势
<!-- Google Fonts 使用示例 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
- 自动子集化和分片
- 全球 CDN 加速
- 跨站点缓存共享
- 无需维护
混合策略
<head>
<!-- 首屏关键字体自托管,预加载 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<!-- 非关键字体使用 CDN -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
</head>
实战:完整优化方案
Next.js 优化示例
// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google';
import localFont from 'next/font/local';
// Google 字体(自动优化)
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
// 代码字体
const firaCode = Fira_Code({
subsets: ['latin'],
display: 'swap',
variable: '--font-fira-code',
});
// 本地中文字体
const notoSansSC = localFont({
src: [
{
path: '../public/fonts/NotoSansSC-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/NotoSansSC-Bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
variable: '--font-noto-sc',
});
export default function RootLayout({ children }) {
return (
<html lang="zh" className={`${inter.variable} ${firaCode.variable} ${notoSansSC.variable}`}>
<body>{children}</body>
</html>
);
}
Nuxt 优化示例
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
link: [
// 预加载关键字体
{
rel: 'preload',
href: '/fonts/inter-var.woff2',
as: 'font',
type: 'font/woff2',
crossorigin: 'anonymous',
},
],
},
},
// 使用 @nuxtjs/fontaine 自动优化回退字体
modules: ['@nuxtjs/fontaine'],
fontMetrics: {
fonts: [
{
family: 'Inter',
fallbacks: ['Arial'],
},
],
},
});
/* assets/css/fonts.css */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var-cyrillic.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
unicode-range: U+0400-045F, U+0490-0491;
}
性能检测与监控
使用 Font Loading API
// 检测字体加载状态
document.fonts.ready.then(() => {
console.log('所有字体加载完成');
});
// 检测特定字体
document.fonts.load('16px Inter').then(() => {
console.log('Inter 字体加载完成');
document.body.classList.add('fonts-loaded');
});
// 监控字体加载时间
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.initiatorType === 'css' && entry.name.includes('.woff2')) {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
}
}
});
observer.observe({ entryTypes: ['resource'] });
Lighthouse 字体审计
// 检查字体是否阻塞渲染
// Lighthouse 会报告以下问题:
// - Ensure text remains visible during webfont load
// - Avoid enormous network payloads
// - Minimize main-thread work
检查清单
在上线前,用这个清单检查你的字体优化:
- 所有字体都设置了
font-display: swap - 关键字体使用了
preload - 字体文件使用 WOFF2 格式
- 中文字体进行了子集化
- 配置了合适的回退字体
- 回退字体使用了
size-adjust减少 CLS - 字体文件启用了长期缓存
- 使用可变字体替代多个字重文件
- 非关键字体延迟加载
结语
字体加载优化,是一个投入产出比很高的优化方向。通过本文介绍的技术:
- font-display 消除 FOIT
- preload 提前加载
- 子集化 减小体积
- 可变字体 一个文件走天下
- 回退字体优化 消除 CLS
你可以让用户在几百毫秒内看到完美的排版,而不是盯着空白页面发呆。
字体是设计的灵魂,加载速度是用户体验的基石。两者兼得,才是真正的优秀。


