一句话总结
Font fallback metrics(字体降级度量)是一套 CSS 超能力,能人为地拉伸和挤压系统字体,让它占据和你的自定义 Web 字体在数学上完全一致的空间,从而避免自定义字体最终加载出来时页面布局发生剧烈跳动。
它解决了什么问题
要理解这个问题,你首先得懂 Web 端最古老的排版战争:在不稳定的 3G 网络下,苦等 150KB 的自定义 Web 字体下载时,我们该给用户看什么?
历史上,浏览器通常会采取两种策略之一,而这俩都很坑爹。第一种是 FOIT(Flash of Invisible Text,不可见文本闪烁),浏览器干脆把文字藏起来,直到字体下载完才显示。用户只能对着白屏发呆,怀疑你的网站是不是挂了。第二种是 FOUT(Flash of Unstyled Text,无样式文本闪烁),浏览器会立刻用本地系统字体(比如 Arial 或 Times New Roman)渲染文字,等那款华丽的自定义字体到达的毫秒间,再火急火燎地把它替换掉。
Web 开发圈基本达成了共识:为了无障碍体验(accessibility)和感知性能,FOUT 是更好的选择。毕竟,读丑一点的字总比没字可读强。于是,我们用 CSS 规则 font-display: swap 把这个做法给标准化了。
但是,font-display: swap 又孵化出了一个新怪物:CLS (Cumulative Layout Shift, 累积布局偏移)。
每套字体都有独特的骨架。Roboto 里的 "x" 物理宽度就是比 Arial 里的 "x" 要宽。大写字母的高度也不尽相同。字体文件里内置的间距更是千差万别。当浏览器把 Arial 替换成 Roboto 时,文字占据的横向和纵向空间突然就变了。段落重新重排(reflow),文本被挤到了下一行。
如果这事发生时用户已经在阅读,或者正准备点击一个按钮,整个页面就会像 DOM 刚遭遇了一场大地震一样上下跳动。按钮直接从用户的鼠标指针下溜走,图片被硬生生挤到页面下方。Google 引入 Core Web Vitals(核心网页指标)就是为了专门衡量这种让人抓狂的体验,对高 CLS 的网站在 SEO 上狠狠扣分。我们需要一种魔法,能让那款丑陋的临时系统字体,完美模仿出那款漂亮自定义字体的物理占位空间。
底层原理
为了阻止布局偏移,我们不修改自定义 Web 字体。相反,我们用 @font-face 规则造一个本地系统字体的“科学怪人(Frankenstein)”版本。我们使用专用的 CSS 描述符,强行覆盖系统字体内部的几何数据。
字体盒子解剖学
要想操纵字体,你得先懂浏览器是怎么计算一行文本外围那个盒子的。这可不仅仅是 font-size 的事儿。它依赖于直接烙印在字体文件内部的三个度量指标:
- Ascent(升部): 从隐形的基线(baseline,字母坐落的那条线)到最高字符顶部(比如 'h' 或 'T' 的顶端)的距离。
- Descent(降部): 从基线到最低下垂字符底部(比如 'y'、'g' 或 'p' 的尾巴)的距离。
- Line Gap(行距 gap): 推荐的行与行之间的留白,也就是上一行的 descent 到下一行的 ascent 之间的距离(通常被称为 leading 或行距)。
在 CSS 里,一行文本的总高度大概就是 ascent + descent + line gap。如果你的自定义字体和系统字体的这三个指标数值不同,布局就会发生垂直跳动。如果字母的宽度不同,布局就会发生水平跳动。
覆盖属性闪亮登场
CSS Fonts Module Level 4 引入了四个神奇的描述符,可以直接用在 @font-face 块里。这些描述符会拦截浏览器对字体文件内部度量数据的读取,并在实时渲染时重写它们。
size-adjust: 就像整个字体的倍增器。如果你的自定义字体整体上比 Arial 宽,你可以给 Arial 设置size-adjust: 110%来把它水平拉宽。ascent-override: 覆盖字体内部的 ascent(升部)度量。descent-override: 覆盖字体内部的 descent(降部)度量。line-gap-override: 覆盖字体内部的 line gap(行间距)度量。
拼凑背后的数学
这里的坑在于 size-adjust 缩放的是一切——包括水平宽度、ascent 和 descent。所以,如果你把系统字体放大了 110% 让行宽匹配了你的 Web 字体,你也会不小心把系统字体的垂直高度拔高 110%,这反而会引发一次垂直方向的布局偏移!
为了解决这事,那些 override 属性(ascent-override、descent-override、line-gap-override)其实是基于 size-adjust 的值按百分比计算的,而不是原始字号。你得先用 size-adjust 完美匹配水平宽度,然后再用 override 度量硬生生地把垂直高度给压回去(或者拔上来),直到完全契合你自定义字体的 bounding box(边界框)。
最终代码大概长这样。注意我们是如何创造了一个全新的、虚拟的字体族叫 Roboto-Fallback,它包裹了本地的 Arial 字体,并附加上了数学级别精准的 override:
/* 1. Load your actual web font */
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto.woff2') format('woff2');
font-display: swap;
}
/* 2. Create the Frankenstein fallback */
@font-face {
font-family: 'Roboto-Fallback';
src: local('Arial');
/* Stretch Arial horizontally to match Roboto */
size-adjust: 104.5%;
/* Squish Arial's vertical metrics to match Roboto */
ascent-override: 89.92%;
descent-override: 23.44%;
line-gap-override: 0%;
}
/* 3. Use them together in your CSS */
body {
font-family: 'Roboto', 'Roboto-Fallback', sans-serif;
}
当页面加载时,浏览器会立刻绘制出 Roboto-Fallback。它其实就是本地的 Arial,只是被水平拉伸、垂直挤压到了跟 Roboto 的数学占位完全一模一样的大小。等真正的 Roboto 文件下载完,字体 swap 瞬间发生,字母的设计风格变了,但页面布局不会移动哪怕一个像素。
真实场景案例
“取消”按钮陷阱
一家大型电商平台曾经饱受购物车放弃率飙升和客服工单爆炸的困扰。用户们疯狂吐槽说自己总是不小心清空了购物车。工程团队调出了用户会话录像,抓到了罪魁祸首。该网站加载了一款体积庞大的品牌字体。用户看到“去结算”按钮紧挨着“清空购物车”按钮。就在他们鼠标点下“去结算”的那一瞬间,品牌字体加载出来了。这款品牌字体明显比系统默认字体宽得多。文本一重排,把“去结算”按钮往右推了一把,结果“清空购物车”按钮精准地滑到了用户正在按下的鼠标指针底下去。通过引入 size-adjust 和度量 override,团队在字体 swap 期间把按钮牢牢钉死在了原位,误操作率瞬间暴跌。
新闻阅读大地震
一家著名的科技博客进行了一次大型改版,为长文阅读专门配上了一款优美现代的衬线字体。为了追求速度,他们用上了 font-display: swap。然而,网速慢的读者却体验到了一种诡异的现象:加载出文章,往下滚了三个段落,开始美滋滋地阅读。两秒钟后,衬线字体加载完成。由于这款字体的 ascent 和 descent 都比系统默认的大,每一行文字都偷偷长高了零点几毫米。累积了三个段落后,这积攒成了一次巨大的垂直位移,直接把用户正在读的那句话甩出了屏幕之外。通过给本地 fallback 字体加上 ascent-override 和 descent-override,文本实现了完美的“原地变身”,保住了读者眼球的焦点。
Lighthouse 跑分的心碎时刻
一位前端老哥花了三周时间为一个 Web 应用狂做 Google Core Web Vitals 优化。他把图片压成了 WebP,延迟加载非关键 JavaScript,还把 CSS 压缩到了极致。他满怀信心地跑了一下 Google Lighthouse,以为 100 分稳了。结果,只拿到了 75 分。报告里挂着一个巨大的 CLS 惩罚。整个位移全是由于一个 <H1> 标题在自定义 Web 字体加载时,从一行被挤成了两行导致的。这位老哥没有妥协去重新设计头部,也没倒退回去用不可见文本,而是为标题字体生成了精准的 fallback 度量指标。布局偏移瞬间清零,Lighthouse 跑分直达 100,这位老哥终于能睡个好觉了。
常见踩坑与雷区
- 瞎猜百分比: 这些数字你根本不可能靠肉眼估出来。计算过程需要直接从 Web 字体和系统字体的二进制表里提取精确的 UPM (Units Per Em)、ascent、descent 和 line gap 数据,然后做交叉相乘。一定要用代码生成器来搞定这些数学活儿。
- 把 override 属性加在 Web 字体上: 一个经典的陷阱就是把
size-adjust直接写进自定义 Web 字体的@font-face声明里。这纯属徒劳。这些 override 必须被应用在另一个完全独立的@font-face声明上,它加载的是local()系统字体,然后你再把这个虚拟字体放在font-family声明链的第二位。 - 挑了个骨架完全反过来的 fallback 字体: 字体度量能做的终究有限。如果你的自定义字体是个极窄的无衬线体(sans-serif),你却用了一个宽大飘逸的衬线体作为本地 fallback 基础,那
size-adjust的数学魔法会把那个衬线体挤成一坨完全不可读的乱码,直到字体 swap 发生。所以,尽量挑一个骨骼结构和你 Web 字体大差不差的本地系统字体。 - 忘了
line-gap-override: 开发者常常把注意力全放在宽高上,以为 line gap 留空或者设成normal就行了。如果浏览器决定给 fallback 字体加上默认的 leading,而这又没法完美匹配 Web 字体内部的 leading 时,你的垂直排版节奏(vertical rhythm)就会被打乱。一定要显式地定义它。
为什么它必须进入你的技能雷达
在任何看重用户体验和 SEO 的项目里,只要你决定上自定义 Web 字体,你就应该立刻把 font fallback metrics 纳入考虑。
在过去,Web 上的排版总是在美学(用好看的字体)和性能(避免布局乱跳)之间妥协。随着 CSS font metrics 的出现,这种妥协正式寿终正寝。无论是当你审查网站的 Core Web Vitals、在跟居高不下的 CLS 跑分死磕,还是在构建绝不允许在屏幕上乱抖的可复用 UI 组件时,font fallback overrides 都是终极的工程级解决方案。它把 Web 字体从一个加载时的负担,变成了丝滑、无感的过渡体验。
深入拓展
- MDN Web Docs: size-adjust - Mozilla 官方文档,详细解释了 size-adjust 描述符是如何缩放字形轮廓和度量指标的。
- MDN Web Docs: ascent-override - 深入了解如何覆盖字体的 ascent 度量。
- web.dev: Improved font fallbacks - Google 出品的优秀指南,教你用这些纯正的 CSS 描述符来搞定 Cumulative Layout Shift。
- W3C CSS Fonts Module Level 4 - W3C 官方规范,详细记载了字体度量 override 背后的精确数学公式和浏览器渲染规则。