结论:绝大多数站点用一行 font-display: swap 就能同时解决 FOIT 和 FOUT 带来的不可读问题;剩下的字体跳动(CLS)用 preload + 字体子集化 + size-adjust 回退字体度量对齐来处理,只有对布局稳定性要求极高的首屏才考虑 font-display: optional。
FOIT 和 FOUT 到底有什么区别?
FOIT 是「字体没到、文字不显示」,FOUT 是「先用回退字体显示、字体到了再换」。两者都由浏览器渲染字体时的两个时间段决定:block period(阻塞期,文字不可见)和 swap period(交换期,可用回退字体显示、字体到达后替换)。
默认 font-display: auto 在 Chrome 下阻塞期约 3 秒,这 3 秒内正文是一片空白,就是典型的 FOIT。慢网或字体文件 3MB 以上时,用户看到的就是「页面结构出来了但一个字没有」。
结论:FOIT 伤害的是可读性(内容直接消失),FOUT 只影响观感(字形跳一下但内容始终可读),所以工程上优先消灭 FOIT,再去压 FOUT 带来的布局抖动。
font-display 的 5 个值分别该用在哪?
结论:正文和 UI 文本用 swap,图标字体用 block,品牌标题字用 optional。
auto:等同 block,阻塞约 3 秒,产生 FOIT,不建议保留默认值。
block:阻塞期约 3 秒,之后用回退字体并无限等待交换。图标字体适合它——图标字体用 swap 会出现「先显示成方框或错误字符」,短暂空白反而更干净。
swap:阻塞期接近 0,立刻显示回退字体,字体到达后无限期替换。这是正文的默认选择。
fallback:阻塞期约 100 毫秒,之后用回退字体;交换期只有 3 秒,超过 3 秒字体才到就不再替换,留到下次访问生效。适合「能接受一次不换」的场景。
optional:约 100 毫秒内加载完才使用自定义字体,否则整页用回退字体,字体在后台下载供下次访问用。它把 CLS 压到 0,代价是首次访问的视觉与二次访问不一致。
font-display 是 CSS Font Loading 规范的一部分,Chrome 60+、Firefox 58+、Safari 11.1+ 均已支持,不需要加前缀。
font-display 写在哪儿才生效?
结论:font-display 必须写在 @font-face 规则内部,写在 body 或 font-family 声明上一律无效,这是最常见的写法错误。
@font-face {
font-family: 'HarmonyOS Sans';
src: url('/fonts/harmonyos-sans.woff2') format('woff2'),
url('/fonts/harmonyos-sans.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+4E00-9FFF;
}
同时把 woff2 放在 src 列表第一位:woff2 使用 Brotli 压缩,体积通常比 woff 再小 30% 左右,而 Chrome、Firefox、Safari 14+ 全部支持,woff/ttf 只作为老浏览器兜底。
preload 字体为什么必须加 crossorigin?
结论:字体 preload 缺少 crossorigin 属性会导致字体被下载两次,因为字体请求按 CORS 模式发起,即使同域也必须带上该属性才能命中缓存。
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
三个属性都不能省:as="font" 决定请求优先级,type="font/woff2" 让不支持的浏览器直接跳过,crossorigin 保证 preload 与真实字体请求匹配。
只 preload 首屏真正用到的 1~2 个字体文件(比如正文常规字重 + 一个标题字重),把全部字重都 preload 会挤占 CSS 和首屏图片的带宽,反而拖慢 LCP。用第三方字体 CDN 时再加一行 <link rel="preconnect" href="https://字体域名">。
中文字体加载慢怎么根治?
结论:中文字体不做子集化,任何 font-display 都救不了首屏——一套完整中文字体动辄 3~10MB,远超西文字体 30~200KB 的量级。
两条路一起走:
- 子集化:只保留页面实际用到的字符集,用
fontmin、pyftsubset(fonttools)、glyphhanger 生成精简 woff2,常规站点可压到 100KB 以内。
unicode-range 分片:把字体按字符区间切成多个文件,浏览器只下载页面实际命中区间的那几片,Google Fonts 的中文方案就是这么做的。配合 unicode-range: U+4E00-9FFF 之类的声明,可以把首屏字体请求控制到 1~2 个分片。
另外,正文级中文内容直接用系统字体栈(system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif),自定义字体只用于标题、Logo 和品牌位,受影响的文本量小了,FOUT 的观感问题基本就消失了。
字体切换时的整页跳动怎么压?
结论:FOIT/FOUT 解决后剩下的主要副作用是 CLS(累计布局偏移),根因是回退字体与自定义字体的 x-height、字宽、行高不一致,换字体时整页重排;用 size-adjust 系列描述符把回退字体的度量对齐即可。
@font-face {
font-family: 'Fallback Sans';
src: local('Arial');
size-adjust: 107%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: 'HarmonyOS Sans', 'Fallback Sans', sans-serif;
}
size-adjust 缩放回退字形的整体尺寸,ascent-override / descent-override 覆盖上下行高度,三者组合能让换字体前后行高几乎一致。这几个描述符在 Chrome 92+、Firefox 89+ 支持,Safari 需要较新版本,老浏览器会直接忽略、退化成普通回退字体,不影响可用性。用 Next.js 的 next/font 或 @fontsource 时会自动生成对齐后的 fallback 字体族,不用手算。
需要更精细控制时怎么做?
结论:只有「必须等字体加载完再显示某个区块」时才需要 CSS Font Loading API,普通站点用 font-display 就够了。
document.fonts.load('400 16px "HarmonyOS Sans"').then(() => {
document.documentElement.classList.add('fonts-loaded');
});
// 二次访问:命中缓存直接标记,避免再闪一次
if (sessionStorage.getItem('fonts-loaded')) {
document.documentElement.classList.add('fonts-loaded');
}
h1 { font-family: system-ui, sans-serif; }
.fonts-loaded h1 { font-family: 'HarmonyOS Sans', system-ui, sans-serif; }
document.fonts.ready 返回全部字体加载完成的 Promise,document.fonts.check('16px MyFont') 可同步判断某字体是否已可用。这套方案的代价是增加 JS 逻辑和一次额外的样式重算,属于「有明确诉求才上」的手段。
可以直接照抄的落地顺序
- 字体转 woff2 并子集化,中文字体必须切
unicode-range 分片;
@font-face 内加 font-display: swap,图标字体改 block;
- 首屏关键字体加
preload,as="font" + type="font/woff2" + crossorigin 三件套齐全;
- 定义带
size-adjust 的 fallback 字体族,写进 font-family 栈;
- 正文改用系统字体栈,自定义字体只给标题和品牌位;
- 用 Lighthouse 复核「确保文本在网页字体加载期间保持可见」通过,并观察 CLS 是否低于 0.1。
一句话收束:font-display: swap 负责让文字立刻可见,子集化和 unicode-range 负责让字体更快到,size-adjust 负责让字体换上去时不跳——三件事各管一段,缺一段问题就还在。