字体优化
问题
前端如何优化 Web 字体加载?什么是 FOIT 和 FOUT?如何进行字体子集化?
答案
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 | 3s | 无限 | FOIT,最多等待 3s |
| swap | 0 | 无限 | FOUT,立即显示后备字体 |
| fallback | 100ms | 3s | 短暂不可见后尝试加载 |
| optional | 100ms | 0 | 可能不使用 Web 字体 |
推荐策略
/* 正文字体:优先内容可见 */
@font-face {
font-family: 'BodyFont';
src: url('/fonts/body.woff2') format('woff2');
font-display: swap;
}
/* 图标字体:短暂不可见可接受 */
@font-face {
font-family: 'IconFont';
src: url('/fonts/icons.woff2') format('woff2');
font-display: block;
}
/* 装饰字体:可选择性使用 */
@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
# 提取常用 3500 字
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 覆盖
/* 定义优化的系统字体栈 */
@font-face {
font-family: 'SystemStack';
src: local('San Francisco'),
local('-apple-system'),
local('BlinkMacSystemFont'),
local('Segoe UI'),
local('Roboto'),
local('Helvetica Neue'),
local('Arial');
}
字体加载策略
关键字体优先
// 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 | 3s | 不可见等待 | 图标字体 |
| swap | 0 | 立即后备 | 正文(推荐) |
| fallback | 100ms | 短暂不可见 | 平衡体验 |
| optional | 100ms | 可能不用 | 装饰字体 |
Q3: 如何优化中文字体?
答案:
- 字体子集化:只包含使用的字符
- unicode-range 分片:按需加载字符集
- 使用 CDN:Google Fonts 支持动态子集
- 本地字体优先:
local()检测系统字体
/* 按需加载中文字符 */
@font-face {
font-family: 'Chinese';
src: url('/fonts/chinese-common.woff2') format('woff2');
unicode-range: U+4E00-U+9FFF;
}
Q4: 字体预加载的注意事项?
答案:
<link
rel="preload"
href="/fonts/main.woff2"
as="font"
type="font/woff2"
crossorigin <!-- 必须添加,即使同源 -->
>
注意事项:
- 必须添加 crossorigin:字体加载默认匿名
- 只预加载关键字体:首屏使用的字体
- 使用正确的 type:type="font/woff2"
- 避免过度预加载:会占用带宽
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);
};