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

css 字体加载 FOIT FOUT 怎么解决

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-28 20:54 ·13 浏览 ·8 回复
本文转载自 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 才是线上最常见的真实故障,而且往往没人测过它。