字体加载时的文本闪烁与布局跳动
打开一个电商网站的商品列表页,标题通常使用品牌定制字体。在弱网环境下,你会看到两种典型现象:要么标题区域长时间空白,要么先显示系统字体,几秒后突然跳成品牌字体,同时下方商品卡片的位置发生明显位移。前者是 FOIT(Flash of Invisible Text,不可见文本闪烁),后者是 FOUT(Flash of Unstyled Text,无样式文本闪烁)。两者都会影响用户体验,但造成的影响不同:FOIT 让用户看不到内容,FOUT 则让页面布局发生跳动。
这种跳动在 Core Web Vitals 中对应 CLS(Cumulative Layout Shift,累积布局偏移)。CLS 衡量页面生命周期内意外布局偏移的分数,字体加载导致的文本尺寸变化是常见诱因之一。要理解 font-display: swap 为什么能缓解或加剧这个问题,需要先弄清楚浏览器在字体加载期间如何决定文本的渲染方式。
字体显示时间轴:block、swap 与 fail 三个周期
CSS Fonts Module Level 4 规范定义了 font-display 描述符,它控制 @font-face 声明的字体在下载期间和下载完成后的渲染行为。规范将字体加载过程划分为三个周期,每个周期内浏览器对使用该字体的元素采取不同策略。
- 字体阻塞周期(block period):如果字体尚未加载完成,任何使用该字体的元素必须渲染一个不可见的后备字体。也就是说,文本占据空间,但内容不可见。如果字体在这个周期内加载完成,则正常显示。
- 字体交换周期(swap period):阻塞周期结束后立即开始。如果字体仍未加载,元素渲染可见的后备字体。若字体在此周期内加载完成,则切换到 Web 字体。
- 字体失败周期(failure period):交换周期结束后开始。如果字体在此时仍未加载,浏览器将其视为加载失败,元素继续使用后备字体,不再尝试切换。
这三个周期的长度由 font-display 的值决定。规范没有规定具体的毫秒数,但浏览器实现通常遵循约定:block 的阻塞周期约为 3 秒,swap 的阻塞周期极短(约 100ms),fallback 的阻塞周期也极短但交换周期有限(约 3 秒),optional 的阻塞周期极短且没有交换周期。这些数值并非标准强制,但被主流浏览器广泛采用。
swap 的机制:极短阻塞与无限交换
font-display: swap 是实践中常用的值,它的行为是:给字体一个极短的阻塞周期(约 100ms),然后进入无限长的交换周期。这意味着在字体加载的初始 100ms 内,文本不可见;超过 100ms 后,文本立即用后备字体渲染,一旦字体加载完成,就切换为 Web 字体。
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand.woff2') format('woff2');
font-display: swap;
}
在电商标题场景中,如果品牌字体文件较大(例如包含中文或装饰性西文),在 4G 网络下可能需要 2~5 秒。使用 swap 时,用户会在约 100ms 后看到系统字体渲染的标题,而不是空白。这保证了内容的可读性,但代价是字体切换时可能发生布局偏移。
swap 的核心权衡是:用 FOIT 的短暂不可见换取 FOUT 的长期可见。它优先保证用户能尽快阅读内容,而把布局稳定性放在次要位置。因此,swap 适合那些字体文件较小、加载较快,或者后备字体与 Web 字体度量接近的场景。
布局偏移的根源:字体度量差异
字体加载导致的 CLS 来源于不同字体之间的度量差异。每种字体都有特定的 ascent、descent 和 line gap 值,这些值决定了文本行的高度和字符的宽度。当文本从后备字体切换到 Web 字体时,如果两者的度量不一致,行高和字符宽度会变化,导致元素尺寸改变,进而推动相邻元素移动。
CLS 的计算基于两个因素:位移距离和受影响区域的比例。一个标题从 16px 系统字体切换到 16px 品牌字体,如果字体度量差异导致行高增加 10%,标题所在容器的高度就会增加,下方的商品卡片会向下移动。这种移动会被浏览器记录为一次布局偏移,累积到 CLS 分数中。
值得注意的是,FOIT 本身不产生布局偏移,因为文本始终占据空间(只是不可见),切换时如果字体度量一致,也不会偏移。但 FOIT 的问题在于用户看不到内容,在慢速网络上可能长达数秒,这比布局偏移更伤害用户体验。因此,swap 通过允许 FOUT 来避免长时间空白,但将布局偏移问题留给了开发者。
对比四种 font-display 值:决策表格
为了在电商场景中做出选择,下表对比了 block、swap、fallback 和 optional 的行为和适用场景。
| 值 | 阻塞周期 | 交换周期 | 文本可见性 | 布局偏移风险 | 适用场景 |
|---|---|---|---|---|---|
| block | 约 3s | 无限 | 阻塞期内不可见,之后后备字体可见 | 低(切换时可能偏移) | 品牌字体必须显示,且后备字体度量接近 |
| swap | 约 100ms | 无限 | 约 100ms 后后备字体可见 | 高(切换必然发生) | 内容可读性优先,字体加载较快 |
| fallback | 约 100ms | 约 3s | 约 100ms 后备字体可见,3s 后不再切换 | 中(3s 内可能切换) | 首次访问可接受后备字体,后续访问用缓存字体 |
| optional | 约 100ms | 无 | 约 100ms 后备字体可见,永不切换 | 低(几乎不切换) | 追求 CLS 最小化,字体非关键 |
以电商标题为例,如果标题是品牌形象的核心,且字体文件经过子集化后小于 50KB,使用 swap 可以在加载完成后快速切换,偏移窗口短。如果标题不是关键内容,且页面有大量图片,使用 optional 可以完全避免字体导致的 CLS,但用户首次访问可能看不到品牌字体。fallback 则适合那些希望首次访问用后备字体、后续访问用缓存字体的场景。
贯穿场景:电商标题的字体加载流程
假设一个电商商品列表页,标题使用品牌字体“BrandFont”,字体文件 200KB,部署在 CDN 上。页面加载时,浏览器解析 HTML 和 CSS,遇到 @font-face 声明后开始下载字体文件。同时,渲染引擎计算样式,发现标题元素使用了 BrandFont,于是启动字体显示时间轴。
如果 font-display 设置为 swap,时间轴如下:
- 字体开始下载,计时器启动。
- 前 100ms 内,标题文本不可见(阻塞周期)。
- 超过 100ms,字体仍未加载,标题用后备字体(如 Arial)渲染,文本可见。
- 字体下载完成(假设 2s 后),浏览器重新计算标题的样式和布局,将文本从 Arial 切换为 BrandFont。
- 如果 BrandFont 的度量与 Arial 不同,标题行高或宽度变化,导致下方元素移动,产生 CLS。
下图展示了这一流程中的关键状态变化:
flowchart TD
A[HTML 解析] --> B[遇到 @font-face]
B --> C[开始下载字体]
C --> D[计时器启动]
D --> E{100ms 内字体加载完成?}
E -- 是 --> F[直接使用 Web 字体]
E -- 否 --> G[使用后备字体渲染文本]
G --> H[字体下载完成]
H --> I[重新计算布局]
I --> J[切换为 Web 字体]
J --> K[可能产生 CLS]
在这个流程中,关键转折点是第 3 步和第 4 步。第 3 步决定了用户何时能看到内容,第 4 步决定了是否发生布局偏移。如果字体在 100ms 内加载完成(例如命中缓存),则不会出现后备字体,也就没有切换和偏移。如果字体加载超过 100ms,则必然经历一次后备字体到 Web 字体的切换,偏移是否发生取决于字体度量差异。
减少 CLS 的实践:字体度量覆盖与子集化
既然 swap 必然导致字体切换,减少 CLS 的关键在于缩小后备字体与 Web 字体之间的度量差异。CSS Fonts 4 提供了 ascent-override、descent-override 和 line-gap-override 描述符,可以覆盖字体的度量值,使后备字体与 Web 字体在行高上保持一致。
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand.woff2') format('woff2');
font-display: swap;
ascent-override: 90%;
descent-override: 20%;
line-gap-override: 0%;
}
这些值需要根据 BrandFont 的实际度量来设定,通常可以从字体文件中读取。通过覆盖度量,后备字体(如 Arial)的行高会被强制调整为与 BrandFont 相同,这样切换时文本行高不变,CLS 显著降低。但字符宽度仍然可能不同,导致文本换行位置变化,因此还需要考虑宽度差异。
另一种实践是字体子集化。电商标题通常只包含少量字符,例如“限时抢购”“新品上市”,可以只加载这些字符的字体子集,将文件大小从 200KB 降到 20KB。这样字体加载时间缩短到 100ms 内,swap 的阻塞周期内就能完成加载,用户根本看不到后备字体,自然没有切换和偏移。Google Fonts 的 text= 参数就是这种思路,可以按需返回字体子集。
此外,使用可变字体也可以减少文件数量。一个可变字体文件可以包含多个字重和样式,避免为每个字重单独加载文件。在电商场景中,如果标题需要粗体,而正文需要常规体,使用可变字体只需加载一个文件,减少了总下载量,也降低了字体加载延迟。
权衡与适用边界:何时不该用 swap
swap 并非所有场景的最佳选择。如果字体文件很大(例如中文全量字体,可能超过 1MB),加载时间可能超过 5 秒,swap 会导致长时间显示后备字体,且切换时偏移明显。此时,optional 可能更合适:它允许浏览器在字体加载超过 100ms 时放弃使用,从而避免切换。但 optional 的代价是用户可能永远看不到品牌字体,除非字体在极短时间内加载完成。
另一个边界是字体加载失败的情况。如果字体文件损坏或 CDN 不可用,swap 会在交换周期内一直显示后备字体,直到失败周期开始(无限交换意味着失败周期永远不会到来,但浏览器会因网络错误而终止加载)。实际上,如果字体请求失败,浏览器会将其视为加载失败,直接使用后备字体,不会无限等待。
对于需要精确控制字体加载的场景,仅靠 font-display 可能不够。开发者可以使用 CSS Font Loading API 或 JavaScript 库(如 FontFaceObserver)来监听字体加载事件,在字体加载完成前给元素添加一个类,暂时隐藏或使用后备字体,加载完成后再切换。这种方法可以更精细地控制切换时机,但增加了复杂度。
可观测信号与诊断路径
要评估 font-display 策略是否有效,需要监控两个指标:文本可见时间和 CLS。文本可见时间可以通过 Performance API 的 paint 事件或 Web Vitals 库来测量,CLS 可以通过 PerformanceObserver 监听 layout-shift 条目。
在开发环境中,可以通过 Chrome DevTools 的 Network 面板模拟慢速网络(如 Fast 3G),观察字体加载瀑布流和文本渲染时间。如果 CLS 分数超过 0.1,说明字体切换导致的偏移较大,需要调整度量覆盖或改用 optional。
一个实用的诊断方法是:在页面加载时记录字体加载完成的时间点,以及该时间点前后是否有 layout-shift 条目。如果字体加载完成时发生偏移,且偏移元素是标题,那么基本可以确定是字体切换所致。此时,可以检查后备字体与 Web 字体的度量差异,并应用 override 描述符。
结论:swap 是权衡而非银弹
font-display: swap 通过极短的阻塞周期和无限交换周期,优先保证文本可读性,但将布局偏移的风险留给了开发者。在电商标题场景中,如果字体文件经过优化(子集化、可变字体)且加载较快,swap 可以平衡可见性与 CLS;如果字体加载较慢或 CLS 是主要矛盾,optional 或 fallback 可能更合适。最终选择取决于业务对品牌字体展示的重视程度和对布局稳定性的要求。