字体优化
问题
前端如何优化 Web 字体加载?什么是 FOIT 和 FOUT?如何进行字体子集化?
前端如何优化 Web 字体加载? 字体文件动辄几百 KB(中文上 MB),优化要从「格式、加载、子集」三方面下手:
- 格式:面向现代浏览器优先使用 WOFF2;如果产品仍支持旧环境,再按兼容矩阵提供 WOFF 等回退,不要为不存在的用户长期发送多套格式。
- 加载策略:正文通常用
font-display: swap或optional保证可读,品牌标题可根据视觉要求选择;只预加载首屏确定会使用的关键字重,并验证是否真的命中。 - 子集化:根据真实语料和授权范围保留字符与 OpenType 特性。中文字体可能从数 MB 降到数百 KB,但收益取决于字符集、字重和分片方式。
- 缓存:字体加
Cache-Control: max-age=31536000, immutable长期缓存;用font-family后备字体(Arial, sans-serif)兜底。
什么是 FOIT 和 FOUT? 是字体加载期间的两种文本表现:
- FOIT(Flash of Invisible Text):字体没下载好之前文本完全不可见——内容在那但用户看不到,影响 LCP。
font-display: block是这种行为。 - FOUT(Flash of Unstyled Text):先用后备字体显示,等 Web 字体加载完闪一下切换——视觉跳动,可能引起 CLS。
font-display: swap是这种行为。 - 策略要按内容选:正文常用
swap,弱网下不值得切换的装饰字体可用optional;用size-adjust/ascent-override/descent-override/line-gap-override让后备字体指标接近 Web 字体,减少布局偏移。 optional适合非核心字体(弱网下直接放弃用 Web 字体,体验最稳定)。
如何进行字体子集化? 两种主流方式:
- 静态子集化:构建时用
fonttools(pyftsubset) 命令行根据字符表或 Unicode 范围生成子集;中文常用范围U+4E00-U+9FFF(CJK 统一汉字)+ 基本 ASCII。 - 动态子集化:Google Fonts 的
?text=参数,或自建服务按页面文本实时切;适合标题、Logo 等少量字符。 unicode-range分片:把字体按 Unicode 范围切成多个文件,浏览器只下载当前文本涉及的范围。分片应来自生产语料统计,不能假设“常用 3500 字”覆盖用户昵称、地名、生僻字和动态内容。- 工具链:可以用 fonttools 等工具生成子集;自动扫描静态 HTML 只能覆盖构建时文本,动态内容必须有兜底字体或更完整的字符分片。
答案
Web 字体是影响页面加载性能和用户体验的关键因素。字体文件通常较大(100KB-1MB),如果处理不当会导致文本闪烁、布局偏移甚至内容不可见。
字体加载问题
FOIT 和 FOUT
| 现象 | 全称 | 描述 | 影响 |
|---|---|---|---|
| FOIT | Flash of Invisible Text | 字体加载时文本不可见 | 用户无法阅读内容 |
| FOUT | Flash of Unstyled Text | 先显示后备字体,后切换 | 视觉闪烁 |
| FOFT | Flash of Faux Text | 先显示合成样式,后替换 | 布局偏移 |
性能影响
font-display 属性
font-display 控制字体加载时的渲染行为。
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 关键属性 */
}
| 值 | 阻塞期 | 交换期 | 行为 |
|---|---|---|---|
| auto | 浏览器决定 | 浏览器决定 | 默认行为 |
| block | 较短 | 长 | 阻塞期先隐藏文本,之后显示后备并等待交换 |
| swap | 很短或无 | 长 | 立即显示后备字体,下载完成后可交换 |
| fallback | 很短 | 较短 | 给字体有限的交换机会 |
| optional | 很短 | 无 | 浏览器可结合网络状况决定是否使用 Web 字体 |
具体时长由浏览器实现决定,面试时应解释“阻塞期”和“交换期”的语义,不要把约 100ms 或约 3s 当作跨浏览器协议常量。
推荐策略
/* 正文字体:优先内容可见 */
@font-face {
font-family: 'BodyFont';
src: url('/fonts/body.woff2') format('woff2');
font-display: swap;
}
/* 图标优先使用 SVG;若历史项目仍使用图标字体,要提供无字体时的可理解兜底 */
/* 装饰字体:可选择性使用 */
@font-face {
font-family: 'FancyFont';
src: url('/fonts/fancy.woff2') format('woff2');
font-display: optional;
}
字体格式选择
| 格式 | 扩展名 | 压缩 | 兼容性 | 推荐度 |
|---|---|---|---|---|
| WOFF2 | .woff2 | Brotli | 现代浏览器 | ⭐⭐⭐ |
| WOFF | .woff | gzip | IE9+ | ⭐⭐ |
| TTF | .ttf | 无 | 全部 | ⭐ |
| EOT | .eot | 无 | IE | ❌ |
/* 现代写法:只需 WOFF2 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
}
/* 兼容写法:渐进增强 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2'),
url('/fonts/custom.woff') format('woff'),
url('/fonts/custom.ttf') format('truetype');
}
字体子集化
字体子集化是只保留需要的字符,大幅减小文件体积。
中文字体优化
中文字体通常包含 2-3 万个字符,完整字体可达 10MB+。
# 使用 fonttools 子集化
pip install fonttools brotli
# 根据生产语料生成 chars.txt,下面只是命令示例
pyftsubset source.ttf \
--text-file=chars.txt \
--output-file=subset.woff2 \
--flavor=woff2
# 按 Unicode 范围
pyftsubset source.ttf \
--unicodes="U+4E00-U+9FFF" \
--output-file=chinese.woff2 \
--flavor=woff2
动态子集化
// 使用 Google Fonts 的 text 参数
const text = encodeURIComponent('欢迎访问我的网站');
const fontUrl = `https://fonts.googleapis.com/css2?family=Noto+Sans+SC&text=${text}`;
unicode-range 分片
/* 按 Unicode 范围分片加载 */
@font-face {
font-family: 'NotoSansSC';
src: url('/fonts/noto-basic.woff2') format('woff2');
unicode-range: U+0000-00FF; /* 基本拉丁 */
}
@font-face {
font-family: 'NotoSansSC';
src: url('/fonts/noto-chinese-1.woff2') format('woff2');
unicode-range: U+4E00-4FFF; /* 中文片段 1 */
}
@font-face {
font-family: 'NotoSansSC';
src: url('/fonts/noto-chinese-2.woff2') format('woff2');
unicode-range: U+5000-51FF; /* 中文片段 2 */
}
/* 浏览器只下载页面实际使用的字符集 */
字体预加载
<!-- 预加载关键字体 -->
<link
rel="preload"
href="/fonts/main.woff2"
as="font"
type="font/woff2"
crossorigin
>
<!-- 预连接字体服务器 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
JavaScript 预加载
// 使用 FontFace API
async function preloadFont(name: string, url: string) {
const font = new FontFace(name, `url(${url})`);
try {
const loadedFont = await font.load();
document.fonts.add(loadedFont);
console.log(`Font ${name} loaded`);
} catch (error) {
console.error(`Failed to load font ${name}:`, error);
}
}
preloadFont('CustomFont', '/fonts/custom.woff2');
// 检测字体是否已加载
document.fonts.ready.then(() => {
console.log('All fonts loaded');
document.body.classList.add('fonts-loaded');
});
// 检测特定字体
document.fonts.check('16px CustomFont'); // true/false
后备字体优化
使用相似的后备字体减少 FOUT 导致的布局偏移。
字体匹配
/* 使用 size-adjust 调整后备字体大小 */
@font-face {
font-family: 'Fallback';
src: local('Arial');
size-adjust: 105%; /* 调整到与 Web 字体相似 */
ascent-override: 90%;
descent-override: 20%;
line-gap-override: 0%;
}
body {
font-family: 'CustomFont', 'Fallback', sans-serif;
}
使用系统字体栈
/* 系统字体不需要伪造一个 @font-face,直接声明回退顺序 */
body {
font-family: 'CustomFont', system-ui, -apple-system, 'Segoe UI', sans-serif;
}
度量覆盖值不能照抄示例。应对 Web 字体和目标后备字体测量字形指标,并在主要平台检查中文、英文、数字、粗体和多行文本的 CLS。
可变字体与字重治理
可变字体可以用一个文件覆盖多个字重、字宽或其他轴,减少多个独立字体文件之间的重复数据;但如果页面只用一个字重,完整可变字体不一定更小。需要比较“实际使用的轴范围 + 子集”与多个静态字体的总成本。
@font-face {
font-family: 'BrandVariable';
src: url('/fonts/brand-variable-subset.woff2') format('woff2');
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
同时清理没有使用的 font-weight 和斜体变体。CSS 声明了字重但没有对应文件时,浏览器可能合成粗体或斜体,既不符合设计,也会让字体加载行为难以判断。
预加载与缓存边界
- 只预加载首屏一定匹配的字体文件;预加载了 400 字重,但首屏实际使用 500 字重,会浪费高优先级带宽。
- 预加载的 URL、CORS 模式和
@font-face请求必须一致,否则可能重复下载。 - 自托管字体使用内容哈希与长期缓存;跨源字体要配置正确的 CORS 和缓存响应头。
- 第三方字体服务会增加连接和隐私依赖。对关键业务,应比较自托管、CDN 和系统字体方案。
document.fonts.ready表示当前字体集合完成加载与布局,不应作为整页可交互或所有未来动态字体都已加载的信号。
字体加载策略
关键字体优先
// FOFT 策略:Flash of Faux Text
// 1. 先加载子集(只含常用字符)
// 2. 再加载完整字体
async function loadFontsWithFOFT() {
// 加载子集字体
const subsetFont = new FontFace(
'MainFont',
'url(/fonts/main-subset.woff2)',
{ weight: '400' }
);
await subsetFont.load();
document.fonts.add(subsetFont);
document.body.classList.add('subset-loaded');
// 异步加载完整字体
const fullFont = new FontFace(
'MainFont',
'url(/fonts/main-full.woff2)',
{ weight: '400' }
);
fullFont.load().then(font => {
document.fonts.add(font);
document.body.classList.add('full-loaded');
});
}
条件加载
// 只在需要时加载字体
function loadFontOnDemand(text: string) {
// 检查是否包含特殊字符
const hasSpecialChars = /[\u4e00-\u9fff]/.test(text);
if (hasSpecialChars && !document.fonts.check('16px "Chinese"')) {
return preloadFont('Chinese', '/fonts/chinese.woff2');
}
}
// 监听用户交互
document.addEventListener('focus', (e) => {
if (e.target instanceof HTMLInputElement) {
loadFontOnDemand(e.target.placeholder);
}
}, true);
性能监控
// 监控字体加载时间
const fontLoadStart = performance.now();
document.fonts.ready.then(() => {
const fontLoadTime = performance.now() - fontLoadStart;
console.log(`Fonts loaded in ${fontLoadTime}ms`);
// 上报性能数据
sendAnalytics('font_load_time', fontLoadTime);
});
// 监控 CLS
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.sources?.some(s => s.node?.tagName === 'P')) {
console.log('Text CLS:', entry.value);
}
}
}).observe({ type: 'layout-shift', buffered: true });
最佳实践清单
常见面试问题
Q1: 什么是 FOIT 和 FOUT?如何解决?
答案:
| 问题 | 描述 | 解决方案 |
|---|---|---|
| FOIT | 字体加载时文本不可见 | font-display: swap |
| FOUT | 后备字体闪烁为 Web 字体 | 优化后备字体匹配 |
/* 解决 FOIT */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 立即显示后备字体 */
}
/* 减少 FOUT 闪烁 */
@font-face {
font-family: 'Fallback';
src: local('Arial');
size-adjust: 105%; /* 调整后备字体大小 */
}
Q2: font-display 各值的区别?
答案:
| 值 | 阻塞期 | 行为 | 适用场景 |
|---|---|---|---|
| block | 较短 | 先不可见,之后仍可交换 | 特殊品牌场景,需谨慎 |
| swap | 很短或无 | 立即后备,之后可交换 | 强调内容立即可读的正文 |
| fallback | 很短 | 有限交换窗口 | 希望减少延迟切换 |
| optional | 很短 | 浏览器可以不下载或不交换 | 弱网可放弃的装饰字体 |
阻塞期的具体时长由浏览器实现决定。选择标准是内容可读性、品牌要求、网络条件和切换时 CLS,而不是所有字体一律 swap。
Q3: 如何优化中文字体?
答案:
- 字体子集化:从生产语料生成字符集,同时保留标点、数字、OpenType 特性和动态内容兜底
- unicode-range 分片:让浏览器按当前文本加载字符分片,并控制分片数量和缓存命中
- 使用 CDN:Google Fonts 支持动态子集
- 本地字体优先:
local()检测系统字体
/* 按需加载中文字符 */
@font-face {
font-family: 'Chinese';
src: url('/fonts/chinese-common.woff2') format('woff2');
unicode-range: U+4E00-9FFF; /* 示例范围,生产应按真实语料拆分 */
}
Q4: 字体预加载的注意事项?
答案:
<link
rel="preload"
href="/fonts/main.woff2"
as="font"
type="font/woff2"
crossorigin <!-- 必须添加,即使同源 -->
>
注意事项:
- 必须添加 crossorigin:字体加载默认匿名
- 只预加载关键字体:首屏使用的字体
- 使用正确的 type:type="font/woff2"
- 避免过度预加载:会占用带宽
还要验证预加载 URL、crossorigin 模式和 CSS 中的请求完全一致,并在 Network 面板确认没有重复下载。若字体不是首屏必需资源,正常发现或 optional 往往比 preload 更合适。
Q5: 如何检测字体是否加载完成?
答案:
// 1. CSS 类切换
document.fonts.ready.then(() => {
document.body.classList.add('fonts-loaded');
});
// 2. 检测特定字体
const isFontLoaded = document.fonts.check('16px "CustomFont"');
// 3. FontFace API
const font = new FontFace('CustomFont', 'url(/fonts/custom.woff2)');
font.load().then(loadedFont => {
document.fonts.add(loadedFont);
});
// 4. 监听字体加载状态
document.fonts.onloadingdone = (e) => {
console.log('Fonts loaded:', e.fontfaces);
};