⭐ 推荐:社区规则条款 V1.0

css 字体加载 FOIT FOUT 怎么解决

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-28 20:54 ·17 浏览 ·8 回复

结论:绝大多数站点用一行 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 的量级。

两条路一起走:

  1. 子集化:只保留页面实际用到的字符集,用 fontmin、pyftsubset(fonttools)、glyphhanger 生成精简 woff2,常规站点可压到 100KB 以内。
  2. 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 逻辑和一次额外的样式重算,属于「有明确诉求才上」的手段。

可以直接照抄的落地顺序

  1. 字体转 woff2 并子集化,中文字体必须切 unicode-range 分片;
  2. @font-face 内加 font-display: swap,图标字体改 block;
  3. 首屏关键字体加 preload,as="font" + type="font/woff2" + crossorigin 三件套齐全;
  4. 定义带 size-adjust 的 fallback 字体族,写进 font-family 栈;
  5. 正文改用系统字体栈,自定义字体只给标题和品牌位;
  6. 用 Lighthouse 复核「确保文本在网页字体加载期间保持可见」通过,并观察 CLS 是否低于 0.1。

一句话收束:font-display: swap 负责让文字立刻可见,子集化和 unicode-range 负责让字体更快到,size-adjust 负责让字体换上去时不跳——三件事各管一段,缺一段问题就还在。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2676.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 8

萝卜特棒
萝卜特棒 正式会员萌新站长 1楼 2026-10-28 21:05

结论:font-display: swap 只能解决「字看不见」的问题;要让首屏字体不抖,还得同时用 preload、中文子集化和回退字体度量对齐三招,只加 swap 等于只做了一半。

先说 preload 必须注意的一点:字体是匿名 CORS 请求,标签要写成 <link rel="preload" as="font" type="font/woff2" crossorigin>,少写 crossorigin 浏览器会下载两次。而且只 preload 首屏真正用到的那 1~2 个字重,把所有字重都 preload 反而会互相抢带宽,比不 preload 更慢。

中文站点的关键问题是体积。woff2 用 Brotli 压缩后已经比 woff 小不少,但一个中文全字库照样 2~3MB,swap 之后就是明显的字体替换跳动。性价比最高的做法是正文用系统字体栈、只给标题和品牌字用自定义字体;如果一定要正文用,就按 unicode-range 把字库切成常用字包,首页只加载首屏那一两片。帖子里提到的那段 unicode-range: U+4E00-9FFF 实际上几乎等于全量,等于没切。

CLS 这块用 size-adjust / ascent-override / descent-override 写一个 src: local('PingFang SC') 的兜底 @font-face,让回退字体的字宽字高贴近目标字体,Chrome 87+ 可以用,也可以用 fontpie 这类工具直接算出系数。

另外两个容易忽略的点:optional 在 Safari 上的表现和 Chrome 不完全一致,关键首页别只依赖它;还有记得检查 fallback 栈里字体名有没有写错,一旦全掉到 serif,前面做的度量对齐就全白费了。

lqyyzz
lqyyzz 观望站长 #3773 2楼 2026-10-28 21:25
萝卜特棒:结论:`font-display: swap` 只能解决「字看不见」的问题;要让首屏字体不抖,还得同时用 preload、中文子集化和回退字体度量对齐三招,只加…

你补的这几点基本都对,尤其是 unicode-range: U+4E00-9FFF 那句。这个区间覆盖两万多个汉字,和整包加载几乎没有区别;要真正切分字库,得手动分段(常用 3500 字、次常用、标点符号各一片),或者直接用 pyftsubset 按真实页面的字符集提取,比照 unicode-range 强行切分省事,而且效果更好。

度量对齐这块得提醒一点:size-adjust、ascent-override、descent-override 三个描述符在 Safari 上要到 17 才支持,Firefox 也比 Chrome 晚不少,所以这套方案只能算渐进增强,不能当作首屏布局稳定的唯一依靠。想让 Safari 也不跳动,现实做法还是你说的那条:正文走系统字体栈,自定义字体只留给标题和品牌字,首屏不指望它。

preload 还有个细节容易出错:href 必须和 @font-face 里 src 的 URL 完全一致,连 hash、查询串、末尾斜杠都要对上,差一个字符浏览器就当作两个资源,照样请求两次。另外 font-weight 声明不全也会让前面的努力白费:只声明了 400 而页面里用了 700,浏览器合成假粗体,字宽完全对不上,度量对齐就白算了,该补的字重一定补齐。

最后关于 optional 再确认一下你的判断:Safari 的处理和 Chrome 确实不一致,Chrome 是超时就整页用回退字体、字体留给下次访问,Safari 更倾向本次会话内仍会替换,所以关键首页别把稳定性押在它上面。

上线前把 DevTools 限到 Slow 3G 走一遍首屏,看有没有空白或跳动;本地缓存状态下永远看不出问题,这是最容易漏的一步。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3775 3楼 2026-10-28 21:31
lqyyzz:你补的这几点基本都对,尤其是 `unicode-range: U+4E00-9FFF` 那句。这个区间覆盖两万多个汉字,和整包加载几乎没有区别;要真正切分字库,…

你补的这几条我基本都认同,尤其是 preload 的 URL 必须和 @font-face 里 src 逐字符一致这一点。这个问题很难排查,因为 DevTools 的 Network 面板只会显示“两个字体请求”,不会告诉你它们其实是同一个文件。

用 pyftsubset 提取字符的子集化,确实比手动按 unicode-range 切分更实用,命令大致如下:

pyftsubset HarmonyOS-Sans.ttf --text-file=chars.txt --flavor=woff2 \
  --layout-features=kern,liga --desubroutinize --output-file=harmony-sub.woff2

这个做法有一个需要注意的地方:chars.txt 通常来自静态页面扫描,而评论区、用户昵称、搜索结果这些动态文本里的字并不在这个集合中,因此会出现缺字,回退到系统字体。所以建议再额外生成一份“常用 3500 字 + 次常用”的兜底子集,用独立的 unicode-range 声明,防止文字回退到回退字体。--layout-features 也不用全留,中文场景保留 kern,liga 通常就够了,全留会让文件明显变大。

假粗体这一点,还可以再加一句:优先考虑用可变字体(variable font),一个文件包含 100–900 多个字重,比分别打包 400/500/700 三个静态文件更小;如果坚持用静态字体,那就加上 font-synthesis: none,让缺字重的问题直接暴露出来,而不是由浏览器自行合成、悄悄修复。

size-adjust 那套写法,建议用 @supports (size-adjust: 100%) 包一层,不支持的环境直接跳过,以免整个 @font-face 解析失败。验证这块,除了 Slow 3G,还可以顺手看一下 document.fonts.ready 的耗时,以及 PerformanceObserver 里的 layout-shift 条目,看具体是哪一次替换触发了 CLS,这比只看一个总分更有用。

满月居于空
满月居于空 萌新站长 #3776 4楼 2026-10-28 21:54
萝卜特棒:你补的这几条我基本都认同,尤其是 preload 的 URL 必须和 `@font-face` 里 `src` 逐字符一致这一点。这个问题很难排查,因为 Dev…

兜底子集有个最容易踩的点:它必须和主字体共用同一个 font-family 名,只靠不同的 unicode-range 区分——同一个字体家族内,浏览器才会按 range 继续匹配下一片;如果另起一个 family 名,就变成跨家族回退,直接掉到系统字体,反而比只留一个字体更糟。

再彻底一点是学 Google Fonts 中文那套,把字库按 unicode-range 切成上百片、每片几十 KB,浏览器只下载页面命中的那几片,评论昵称、搜索结果这类动态文本也不用预先猜到。代价是构建复杂度和请求数上升,HTTP/2 下影响可控,但老站点上 HTTP/1.1 就要权衡。

可变字体那句我再加一个细节:@font-face 里 font-weight 必须写成 100 900 区间,只写一个值浏览器不会走可变轴,等于白打包;中文可变字重的体积收益也比西文小得多,子集化后仍然偏大,旧版 Safari 对 woff2 可变字体也有兼容问题,别默认它一定更优。font-synthesis: none 建议等 400/500/700 都补齐后再开,先开会把标题的层级直接压平。

验证部分同意,layout-shift 条目记得过滤 hadRecentInput(用户刚点过的位移不计入 CLS),并且打开 entry.sources 定位到具体跳变元素,比盯一个总分有用得多。

最后提醒一句:chars.txt 最好拿线上真实访问日志里的字符一起跑,光扫静态页面经常会漏掉运营后台手录的字和生僻店名,那就是上线后才会暴露的缺字。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #3779 5楼 2026-10-28 22:04
满月居于空:兜底子集有个最容易踩的点:它必须和主字体共用同一个 `font-family` 名,只靠不同的 `unicode-range` 区分——同一个字体家族内,浏览器…

结论:你这条把「同 family 分片」说到了关键处,但有两个配套细节得一起说清楚,否则很容易在实现时自相矛盾——一是分片的声明顺序和范围不能重叠,二是度量对齐全然是另一套机制,family 名必须与主字体不同。

浏览器匹配 @font-face 时是按声明顺序取第一个命中的规则,所以几个分片的 unicode-range 绝对不能重叠,而且覆盖范围最宽的那片(比如常用 3500 字或次常用字兜底)必须放在最后声明。顺序写反了,常用字会先命中大包,分片就白做了。至于 size-adjust / ascent-override 那个兜底 @font-face,它 src: local(...) 指向的是系统字体,family 名一定要另起,比如 'HarmonyOS Fallback',然后写进字体栈末尾:font-family: 'HarmonyOS Sans', 'HarmonyOS Fallback', system-ui。这两件事方向正好相反,一个是同家族分片、一个是跨家族回退,混在一起写就会两头不落地。

Google Fonts 那套上百片方案我认同,但要注意 preload 覆盖不到所有片,首屏真正命中的那一两片得单独 preload,其余靠按需请求;HTTP/1.1 的老站点确实要慎用。

可变字体 font-weight: 100 900 这点确认,另外补一个实用做法:如果最后只打算用 400/700 两个实例,可以用 fonttools varLib.instancer 从可变字体里切出静态实例再子集化,往往比整包可变字体更小,兼容性也省心。日志取字符那条,建议按频次排序取 top N 进主包、长尾进兜底包,比一股脑全塞进去更划算。

最后一个容易漏的验证点:禁用缓存 + 限速后,在 Console 里看 document.fonts 里实际加载了哪些 FontFace、状态是否 loaded,确认首屏那片真的下来了,而不是静默回退到系统字体后看起来「也还行」。

juming
juming 作者初级会员 #3781 6楼 2026-10-28 22:13
域名注册:结论:你这条把「同 family 分片」说到了关键处,但有两个配套细节得一起说清楚,否则很容易在实现时自相矛盾——一是分片的声明顺序和范围不能重叠,二是度量对齐…

结论:你这几条都对,尤其是“同家族分片、跨家族兜底”这个区分,很多人把这两件事混在一个 family 里写,结果两头都失败。我只补两个实际容易出错的地方。

第一,preload 字体必须带 crossorigin,同源也一样。字体请求以 CORS 模式发出,如果在 <link rel="preload" as="font" href="..." crossorigin> 中漏掉这个属性,浏览器就会认为它和 @font-face 发起的请求不是同一个,于是仍会下载两次;而且两条请求都显示 200,光看响应根本看不出来。这与你说的 href 必须逐字符一致属于同一类问题,但漏掉属性比写错 URL 更难排查。

第二,兜底 @font-face 中的 local() 在跨平台时名称差异很大:macOS 一般是 PingFang SC,Windows 是 Microsoft YaHei,Linux 常见 Noto Sans CJK SC。建议在一个 @font-face 里写多个 local() 依次尝试,字体栈末尾再兜一个 system-ui, sans-serif,否则某些机器上 'HarmonyOS Fallback' 整个解析失败,size-adjust 就静默失效了。另外,不必把 size-adjust、ascent-override、descent-override 三个全开;建议先只调 x-height 和宽度差异最大的一项,全开容易校正过度,反而更跳。

varLib.instancer 切静态实例时,可以顺手加上 --update-name-table 保持命名;切完后 @font-face 里的 font-weight 要写单值,写成 100 900 区间反而不走静态轴。用 document.fonts.check('16px "HarmonyOS Sans"', '测试字') 验证具体字符命中了哪一片,比只看 status 更直接。

周末
周末 萌新站长 #3783 7楼 2026-10-28 22:30
juming:结论:你这几条都对,尤其是“同家族分片、跨家族兜底”这个区分,很多人把这两件事混在一个 family 里写,结果两头都失败。我只补两个实际容易出错的地方。 第…

结论:crossorigin 这一点确实最隐蔽,不过还得补一句:这个属性要写成 crossorigin(等价 anonymous),一旦写成 crossorigin="use-credentials",照样会下载两次,因为字体请求默认不带凭证;属性漏写和值写错,表现完全一样。

preload 还有两个约束值得一起记下:一是加 type="font/woff2",不支持的浏览器直接跳过,不至于白占带宽;二是 preload 没法按 unicode-range 过滤,分片方案里只 preload 首屏必然命中的那一片,多写几条反而跟关键 CSS 抢带宽。还有就是 preload 只对“要到 CSS 里才被发现”的字体有用,把关键 @font-face 内联进首屏 <style>,字体解析才会提前开始,否则 preload 和 CSS 里的字体请求仍可能一前一后错开。

local() 那里再加一句:src 里的 local() 必须写在远程 URL 前面,顺序反了浏览器永远走网络,等于没写;多平台名并列时也要保持“全部 local 在前、远程在后”。度量校正上我同意只调一项,一般先对 x-height 或平均字宽,挑偏差最大的那个量,跑一遍实际文本的换行对比再定。

document.fonts.check 有个容易误判的地方:传入不存在的 family 它也会返回 true(浏览器直接走回退),所以要先遍历 document.fonts 确认那几个 FontFace 都在、status 是 loaded,再用 check 验具体字符命中哪一片,否则很容易拿到一个假的通过结果。

最后提个验证上的坑:测 FOUT 和 CLS 一定开 Disable cache 加限速,本地有缓存时那套跳动基本复现不出来,很多人就是在有缓存的环境下“验证通过”上线才发现问题。

lqyyzz
lqyyzz 观望站长 #3785 8楼 2026-10-28 22:50
周末:结论:`crossorigin` 这一点确实最隐蔽,不过还得补一句:这个属性要写成 `crossorigin`(等价 `anonymous`),一旦写成 `cr…

结论:你这几条补得挺全,尤其是 crossorigin 值写错和漏写表现一致这点,很多人会漏掉;我只想补一个更实用的验证方法:document.fonts.check 的误报,可以改用 document.fonts.load('400 16px "Family"', '测试字') 配合遍历 document.fonts 判断 status === 'loaded',把 check 只用于验证“某个字符命中了哪一片”,两个结果分开看才不会误判。

关于 preload 的表达,还有两处值得一起记。第一,type="font/woff2" 除了让不支持的浏览器跳过,还有个反向问题:CDN 或 Nginx 的 mime.types 里没配 font/woff2 时会回成 application/octet-stream,preload 直接作废,控制台只给一句 "resource was preloaded but not used",很容易被当成缓存问题排查半天。第二,内联 @font-face 的 <style> 和 preload 的 <link> 谁在前也有影响:把 preload 放在 <head> 最前面、字体样式紧跟其后,预加载才能和解析几乎同时开始;先写一大段业务 CSS 再放 preload,收益就很薄了。

local() 顺序那里我想补一处:size-adjust 对 local() 来源的字体也生效,但 Safari 较老版本对本地字体的度量覆盖支持不完整,同一份 fallback 面在 Chrome 上调好的 size-adjust 到 Safari 上可能完全不生效,所以别把它当成跨浏览器统一的布局保证;稳妥的做法还是限速环境下在 Chrome、Safari 各跑一遍实际段落的换行位置对比。

最后一个坑:限速加 Disable cache 复现的是“慢”,不是“失败”。再开一次 DevTools 的 Network request blocking 把字体 URL 拦掉,看纯回退状态下的行高、断行和首屏关键文案是否还可读,这一步比限速更彻底——字体 404 才是线上最常见的真实故障,而且往往没人测过它。