网页字体工作流程很简单,对吧?选择一些外观不错的网页字体,获取 HTML 或 CSS 代码片段,将其放到项目中,然后检查它们是否正常显示。人们每天都会使用 Google Fonts 做这件事,将它的 <link>
标签放到 <head>
中。
让我们看看 Lighthouse 对这个工作流程有什么看法。

<head>
中的样式表被 Lighthouse 标记为 渲染阻塞资源,它们会导致渲染延迟一秒?不太好。我们已经按照书本、文档和 HTML 标准的所有操作步骤进行了操作,那么为什么 Lighthouse 告诉我们所有操作都是错误的?
让我们谈谈如何消除字体样式表作为渲染阻塞资源,并逐步了解一个最佳设置,它不仅可以使 Lighthouse 满意,还可以克服使用加载字体时通常出现的令人讨厌的 未格式化文本闪现 (FOUT)。我们将使用纯 HTML、CSS 和 JavaScript 完成所有操作,因此它可以应用于任何技术栈。作为奖励,我们还将研究 Gatsby 实现以及我开发的一个插件,它可以作为 Gatsby 的简单即用解决方案。
我们所说的“渲染阻塞”字体
当浏览器加载网站时,它会从 DOM(即 HTML 的对象模型)和 CSSOM(即所有 CSS 选择器的映射)创建一个 渲染树。渲染树是 关键渲染路径的一部分,它表示浏览器渲染页面所经历的步骤。对于浏览器要渲染页面,它需要加载并解析 HTML 文档以及在该 HTML 中链接的每个 CSS 文件。
这是一个从 Google Fonts 直接获取的相当典型的字体样式表
@font-face {
font-family: 'Merriweather';
src: local('Merriweather'), url(https://fonts.gstatic.com/...) format('woff2');
}
您可能认为字体样式表在文件大小方面非常小,因为它们通常最多包含几个 @font-face
定义。它们不应该对渲染有任何明显的影响,对吧?
假设我们正在从外部 CDN 加载 CSS 字体文件。当我们的网站加载时,浏览器需要等待该文件 从 CDN 加载并被包含在渲染树中。不仅如此,它还需要 等待作为 CSS @font-face
定义中 URL 值引用的字体文件被请求并加载。
底线:字体文件成为关键渲染路径的一部分,它会增加页面渲染延迟。

(来源:web.dev 在 知识共享署名 4.0 许可协议 下)
对于普通用户来说,任何网站最重要的部分是什么?当然,是内容。这就是为什么内容需要在网站加载过程中尽快显示给用户。为了实现这一点,关键渲染路径需要减少到关键资源(例如 HTML 和关键 CSS),所有其他资源都应在页面渲染后加载,包括字体。
如果用户在速度慢、连接不可靠的情况下浏览未经优化的网站,他们会因为等待字体文件和其他关键资源完成加载而感到沮丧地坐在空白屏幕前。结果?除非该用户非常有耐心,否则他们很有可能会放弃并关闭窗口 ,认为页面根本没有加载。
但是,如果非关键资源被延迟加载,并且内容尽快显示,用户将能够浏览网站并忽略任何丢失的呈现样式(如字体)——也就是说,如果这些样式不影响内容。

加载字体的最佳方式
没有必要重新发明轮子。Harry Roberts 已经做了很棒的工作 描述加载网页字体的最佳方式。他通过 Google Fonts 的全面研究和数据进行了详细介绍,将其归纳为一个四步过程
- 预连接到字体文件源。
- 预加载字体样式表,异步加载,优先级较低。
- 异步加载字体样式表和字体文件,在内容使用 JavaScript 渲染后加载。
- 提供备用字体,供 JavaScript 被禁用的用户使用。
让我们使用 Harry 的方法来实现我们的字体
<!-- https://fonts.gstatic.com is the font file origin -->
<!-- It may not have the same origin as the CSS file (https://fonts.googleapis.com) -->
<link rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin />
<!-- We use the full link to the CSS file in the rest of the tags -->
<link rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Merriweather&display=swap" />
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Merriweather&display=swap"
media="print" onload="this.media='all'" />
<noscript>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Merriweather&display=swap" />
</noscript>
请注意字体样式表链接上的 media="print"
。浏览器会自动将打印样式表设置为较低的优先级,并将它们排除在关键渲染路径之外。打印样式表加载后,会触发 onload
事件,媒体会被切换到默认的 all
值,字体会被应用于所有媒体类型(屏幕、打印和语音)。

需要注意的是,自托管字体也有助于解决渲染阻塞问题,但这并非总是可行。例如,使用 CDN 可能不可避免。在某些情况下,让 CDN 承担服务静态资源的重任是有益的。
即使我们现在以最佳的非渲染阻塞方式加载字体样式表和字体文件,我们也引入了一个小型的 UX 问题……
未格式化文本闪现 (FOUT)
这就是我们所说的 FOUT

为什么会这样?为了消除渲染阻塞资源,我们必须在页面内容渲染后(即显示在屏幕上)加载它。对于在关键资源之后异步加载的低优先级字体样式表,用户可以看到字体从备用字体更改为下载字体的瞬间。不仅如此,页面布局可能会发生变化,导致某些元素看起来破损,直到网页字体加载完成。
处理 FOUT 的最佳方法是使备用字体和网页字体之间的过渡平滑。为了实现这一点,我们需要
- 选择合适的备用系统字体,使其尽可能接近异步加载的字体。
- 调整备用字体的字体样式(
font-size
、line-height
、letter-spacing
等),使其尽可能接近异步加载的字体的特征。 - 清除备用字体的样式,一旦异步加载的字体文件完成渲染,就应用打算用于新加载字体的样式。
我们可以使用 字体样式匹配器 查找最佳备用系统字体,并为我们计划使用的任何网页字体配置它们。一旦我们准备好了备用字体和网页字体的样式,我们就可以进行下一步。

我们可以使用 CSS 字体加载 API 来检测我们的网页字体何时加载完成。为什么呢?Typekit 的网页字体加载器 曾经是更流行的方式之一,虽然继续使用它或类似的库很诱人,但我们需要考虑以下几点
- 它已经超过四年没有更新了,这意味着,如果插件方面出现任何问题,或者需要新的功能,很可能没有人会实现和维护它们。
- 我们已经使用 Harry Roberts 的代码片段有效地处理了异步加载,我们不需要依赖 JavaScript 来加载字体。
在我看来,使用 Typekit 这样的库对于像这样简单的任务来说,JavaScript 过多。我希望避免使用任何第三方库和依赖项,因此让我们自己实现解决方案,并尽可能使其简单直观,不要过度设计。
尽管 CSS 字体加载 API 被认为是实验性技术,但它大约有 95% 的浏览器支持。但是,无论如何,我们应该在 API 发生更改或将来被弃用时提供备用方案。丢失字体的风险不值得冒这个险。
CSS 字体加载 API 可用于动态异步加载字体。我们已经决定不依赖 JavaScript 来执行像字体加载这样的简单任务,并且我们已经使用包含预加载和预连接的纯 HTML 以最佳方式解决了这个问题。我们将使用 API 中的一个函数,它将帮助我们检查字体是否已加载并可用。
document.fonts.check("12px 'Merriweather'");
check()
函数将根据函数参数中指定的字体是否可用返回 true
或 false
。字体大小参数值对我们的用例并不重要,可以设置为任何值。不过,我们需要确保
- 页面上至少有一个 HTML 元素包含一个字符,并且应用了网页字体声明。在示例中,我们将使用
,但任何字符都可以,只要它对有视力的人和无视力的人都是隐藏的(不使用display: none;
)。API 跟踪应用了字体样式的 DOM 元素。如果页面上没有匹配的元素,那么 API 将无法确定字体是否已加载。 check()
函数参数中指定的字体与 CSS 中的字体名称完全相同。
在本演示中,我使用 CSS 字体加载 API 实现字体加载监听器。为了演示目的,通过点击按钮来模拟页面加载,从而启动字体加载和监听器,以便您可以看到发生的更改。在常规项目中,这应该在网站加载并渲染后不久发生。
很棒吧?借助 CSS 字体加载 API 中得到良好支持的功能,我们只用不到 30 行 JavaScript 代码就实现了简单的字体加载监听器。我们还处理了过程中可能出现的两个边缘情况。
- API 出错,或发生某些错误导致网络字体无法加载。
- 用户在 JavaScript 被关闭的情况下浏览网站。
现在我们有了检测字体文件何时完成加载的方法,我们需要向我们的备用字体添加样式以匹配网络字体,并了解如何更有效地处理 FOUT。
备用字体和网络字体之间的过渡看起来很流畅,我们已经成功地使 FOUT 变得不那么明显了!在复杂的网站上,这种更改将导致更少的布局偏移,并且依赖于内容大小的元素将不会看起来损坏或位置错误。
幕后发生了什么
让我们更仔细地看一下前面示例中的代码,从 HTML 开始。我们在 <head>
元素中包含了代码片段,这使我们能够使用预加载、预连接和备用异步加载字体。
<body class="no-js">
<!-- ... Website content ... -->
<div aria-visibility="hidden" class="hidden" style="font-family: '[web-font-name]'">
/* There is a non-breaking space here */
</div>
<script>
document.getElementsByTagName("body")[0].classList.remove("no-js");
</script>
</body>
请注意,我们为 <body>
元素硬编码了 .no-js
类,该类在 HTML 文档完成加载的那一刻就被删除了。这将为禁用 JavaScript 的用户应用网络字体样式。
其次,请记住 CSS 字体加载 API 至少需要一个包含单个字符的 HTML 元素来跟踪字体并应用其样式?我们添加了一个带有
字符的 <div>
,我们以一种可访问的方式将其从视力正常和视力障碍的用户隐藏起来,因为我们不能使用 display: none;
。此元素有一个内联 font-family: 'Merriweather'
样式。这使我们能够在备用样式和已加载的字体样式之间平滑切换,并确保所有字体文件都得到正确跟踪,无论它们是否在页面上使用。
请注意,
字符没有在代码片段中显示出来,但它确实存在!
CSS 是最直接的部分。我们可以利用在 HTML 中硬编码或使用 JavaScript 有条件应用的 CSS 类来处理各种字体加载状态。
body:not(.wf-merriweather--loaded):not(.no-js) {
font-family: [fallback-system-font];
/* Fallback font styles */
}
.wf-merriweather--loaded,
.no-js {
font-family: "[web-font-name]";
/* Webfont styles */
}
/* Accessible hiding */
.hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
JavaScript 是魔法发生的地方。如前所述,我们使用 CSS 字体加载 API 的 check()
函数检查字体是否已加载。同样,字体大小参数可以是任何值(以像素为单位);需要匹配我们要加载的字体名称的字体系列值。
var interval = null;
function fontLoadListener() {
var hasLoaded = false;
try {
hasLoaded = document.fonts.check('12px "[web-font-name]"')
} catch(error) {
console.info("CSS font loading API error", error);
fontLoadedSuccess();
return;
}
if(hasLoaded) {
fontLoadedSuccess();
}
}
function fontLoadedSuccess() {
if(interval) {
clearInterval(interval);
}
/* Apply class names */
}
interval = setInterval(fontLoadListener, 500);
这里发生的事情是,我们使用 fontLoadListener()
设置了监听器,该监听器以规律的间隔运行。此函数应该尽可能简单,以便它能够在间隔内高效运行。我们使用 try-catch 块来处理任何错误并捕获任何问题,以便在发生 JavaScript 错误的情况下仍然应用网络字体样式,这样用户就不会遇到任何 UI 问题。
接下来,我们考虑字体成功加载的情况,使用 fontLoadedSuccess()
处理。我们需要确保首先清除间隔,这样检查就不会在之后不必要地运行。在这里,我们可以添加需要应用网络字体样式的类名。
最后,我们启动间隔。在本示例中,我们将其设置为 500 毫秒,因此该函数每秒运行两次。
这是一个 Gatsby 实现
Gatsby 做了一些与普通 Web 开发(甚至常规的 create-react-app 技术栈)不同的操作,这使得实施我们在这里介绍的内容变得有点棘手。
为了简化操作,我们将开发一个 本地 Gatsby 插件,因此所有与我们的字体加载器相关的代码都位于下面的示例中的 plugins/gatsby-font-loader
中。
我们的字体加载器代码和配置将分布在三个主要的 Gatsby 文件中。
- 插件配置 (
gatsby-config.js
): 我们将在项目中包含本地插件,列出所有本地和外部字体及其属性(包括字体名称和 CSS 文件 URL),并包含所有预连接 URL。 - 服务器端代码 (
gatsby-ssr.js
): 我们将使用配置在 HTML<head>
中生成并包含预加载和预连接标签,使用 Gatsby API 的setHeadComponents
函数。然后,我们将生成隐藏字体的 HTML 代码片段,并使用setPostBodyComponents
将它们包含在 HTML 中。 - 客户端代码 (
gatsby-browser.js
): 由于此代码在页面加载后以及 React 启动后运行,因此它已经是异步的。这意味着我们可以使用 react-helmet 插入字体样式表链接。我们还将启动字体加载监听器来处理 FOUT。
您可以在以下 CodeSandbox 示例中查看 Gatsby 实现。
我知道,其中一些东西很复杂。如果您只是想要一个简单的即用型解决方案,用于实现高性能的异步字体加载和 FOUT 修复,我已经开发了一个名为 gatsby-omni-font-loader 的插件。它使用了本文中的代码,我正在积极维护它。如果您有任何建议、错误报告或代码贡献,请随时 在 GitHub 上提交。
结论
内容可能是用户在网站上体验的最重要的组成部分。我们需要确保内容得到最高优先级并尽快加载。这意味着在加载过程中使用最少的演示样式(即内联关键 CSS)。这也是为什么在大多数情况下网络字体被认为是非关键的 - 用户可以在没有它们的情况下仍然阅读内容 - 因此,它们在页面渲染后加载是完全可以的。
但这可能会导致 FOUT 和布局偏移,因此需要字体加载监听器来实现备用系统字体和网络字体之间的平滑切换。
我想听听您的想法!请在评论中告诉我,您如何在项目中处理网络字体加载、阻塞渲染的资源和 FOUT 问题。
参考资料
- 消除阻塞渲染的资源 (web.dev)
- 优化 WebFont 加载和渲染 (web.dev)
- 阻塞渲染的 CSS (Google Web Fundamentals)
- 最快的 Google 字体 (CSS Wizardry)
- CSS 基础知识:用于更强大的 Web 排版的备用字体栈 (CSS-Tricks)
- CSS 字体加载 API (MDN)
- 字体样式匹配器
感谢您的分享。一如既往,直到您开始提到 Gatsby 我都理解 :)。
很高兴您喜欢这篇文章。我一直想把 Gatsby 实现作为附录,因此您可以在选择的技术栈和框架中应用最终部分之前的想法和代码。
这真是巧合,我昨天大部分时间都在捣鼓这个话题,因为我的 Pagespeed Insights 评分被扣分了。然后,我在与我的 Joomla 框架开发人员进行电子邮件交流时,他将我引导到昨天发布的这篇文章!看来英雄所见略同 :)。但说正经的,找到这些优化 Google 页面加载分数的方法很快就会让所有人都受益。尤其是考虑到即将到来的 Google 算法更改,它将以与对待非 https 页面相同的方式(即空白页面上有一个警告,底部有一个小的“您确定吗?”链接)通知用户网站速度慢,这将影响数百万个网站。这样的优化将改变一切。
非常感谢您花时间整理这些内容!我之前从未听说过 FOUT 这个术语,但一直想知道如何解决它。我一定会将此页面加入书签,以便在下一个项目中参考。
非常感谢。很高兴听到您喜欢这篇文章,并计划在下一个项目中使用 FOUT 处理!
字体加载“观察者”非常聪明,但我不太确定为什么要首先使用它,因为
document.fonts.load(...).then(...)
已经存在。这将删除带有 的 HTML 行并简化代码。有效观点,但可以这样考虑
字体加载 - 对网站展示来说是“必不可少的”。我们希望将其尽可能接近原生,所以我使用了带有 JavaScript 回退的 HTML。我还不希望它依赖于可能随时更改的实验性 API。如果规范出现任何问题,或者浏览器不支持 API,我需要提供 HTML 回退。
FOUT 处理 - 有用,但不是必需的。如果 Font API 发生变化或不支持,唯一的问题是一些轻微的视觉抖动。不是真正的问题。因此我可以让 JavaScript 来处理它。
因此,我建议在规范最终确定且不再是实验性之前采用这种方法。
我的想法:我不喜欢它。
我不喜欢在内容出现后弹出的骨架加载器、字体、图片或任何其他东西 - 这很分散注意力。
解决您的性能问题,而不是发明巧妙的变通方法。选择更小的字体。更少的字体。预加载任何可见于折叠线以上的内容的字体。使用 HTTP/2。如果必须,使用 Base-64 编码并内联。
关于“感知性能”的整个故事,都是为了避免解决底层技术或设计问题而找的借口。这是一个诱人的机会,可以展现你的创造力,想出“巧妙的”变通方法。
但所有这些都是不必要的。好的网页字体已针对网页进行了优化。好的设计通常不会使用超过两种字体。好的实现会通过压缩、内联、预加载等方式进行优化,以提供预期的体验。
只是我的意见,但我发现许多这些技术都分散了对真正问题的关注,同时总是会增加一些不必要的复杂性。
以传统方式解决性能问题可能并不新颖、令人兴奋或有趣 - 但我认为在所有其他选项都耗尽之前,没有人应该求助于“感知性能”的变通方法。
您好,Rasmus,感谢您的意见。
我同意您应该解决底层问题并深入挖掘系统,而不是增加复杂性。这就是为什么我想将我的实现尽可能接近原生功能(无依赖关系、无库、无黑客)。
我当然不认为这是“加载字体和处理 FOUT 的最佳方式”,而更像是通往更好解决方案的垫脚石,以及适用于大多数场景的快速解决方案。
优化网页字体是一个复杂的话题(就像图像优化或任何网页优化一样)。我实际上查看了 CSS-tricks 如何处理字体加载,它很麻烦:https://www.zachleat.com/web/css-tricks-web-fonts/
我认为您的解决方案是理想的,但这在很大程度上取决于具体情况。有时,客户使用从外部服务器加载的许可字体,而他们无法控制字体文件本身。有时,这些优化任务不符合客户的预算或截止日期。并且复杂的解决方案确实需要一些先见之明和开发经验才能安全地完成,并且不会出现任何未来问题。
最后,我认为这可以解决浏览器存在的一些小问题 - 它们在加载字体文件时不会显示内容。我认为内容显示依赖于字体文件是没必要的,我想解决异步加载带来的 FOUT。
无论如何,我认为方法应该取决于具体情况、项目复杂性、开发人员的技能和经验,以及可用的时间和预算。
有趣的方法。但使用 setTimeout/setInterval 会有一些计时不一致和延迟,具体取决于各种因素,因此可能会延长检查字体加载的时间,因为我们正在谈论快速发生的视觉变化,这可能会产生相当大的影响。https://hackwild.com/article/web-worker-timers/。此外,您是否比较过您的技术与仅使用 font-display:swap 的结果?感谢您的文章!
您好,Mihovil,感谢您的评论。
关于“setInterval”的性能,我对此进行了研究。通常,您应该只在间隔内使用一个简单的函数,这样我们就不会遇到重大的性能瓶颈。我们使用的是一个简单的原生 API 检查(不依赖于任何 HTTP 请求或任何复杂的操作),所以在这一点上我们很好。如果计时器不准确,这不是问题,我们只是想偶尔运行它并查看字体是否已加载。希望这能使实现更清晰一些。
关于“font-display: swap”,这是一个很好的方法,但问题是您仍然会注意到回退字体和主要字体之间的切换。主要字体和回退字体之间的宽度和高度差异可能会导致一些令人讨厌的布局偏移,或者使某些元素看起来损坏,具体取决于布局和屏幕尺寸。使用 FOUT 方法,我们可以检测到主要字体何时完成加载并切换 CSS 类(字体样式),这样布局偏移就不会发生,切换也就不明显了。
Adrian,很棒的文章!以前不知道字体文件会增加页面渲染延迟。
谢谢。很高兴您喜欢它!
我发现自托管我的字体可以解决网页字体可能带来的几乎所有问题。一个有用的工具可以帮助:https://google-webfonts-helper.herokuapp.com/fonts
感谢您这篇文章,Adrian,它非常及时!在您的示例中,字体大小都以像素设置。在我构建的网站中,我使用可变的字体大小公式,使用 calc() 或 clamp()。我很好奇,当字体大小都是可变的时候,您是否有任何处理 FOUT 的建议?
示例:
font-size: calc(1.3125rem + (75vw - 15rem)/65.375);
假设标题有一个菜单,然后下面是一个英雄部分。菜单项的文本以及英雄部分的文本都将具有不同的字体大小,而不是固定的像素值。因此,使字体切换准确地实现,并尽可能减少布局偏移,变得相当具有挑战性。
您好,Zach,谢谢。这是一个很棒的问题。
字体样式匹配很棘手,所以我想可变字体会使它更加棘手。我自己还没有尝试过,但我最好的猜测是使用字体样式匹配器,并从您提供的 calc 代码段中找到最小字体大小和最大字体大小的匹配项。对字母间距、单词间距、行高…基本上所有与排版相关的东西都应该这样做。您最终将获得一组针对回退字体和主要字体的一组 calc 函数。
在理想情况下,回退字体参数应该与主要字体参数线性匹配,这样分辨率的变化就会保持一致,FOUT 也会得到妥善处理。
如果您决定尝试一下,请告诉我结果。
示例代码显示了属性 aria-visibility。但这个标签不存在(至少我在规范中没有找到任何内容)。我觉得它应该是 aria-hidden=”true” 而不是。
感谢您的文章,它帮助很大!让我补充几句。)
您为什么不使用 document.fonts.ready.then(),它似乎具有与 check() 相同的浏览器覆盖范围?不需要 setInterval()!
此外,您可以使用 .fonts-loader::before { content: “\00a0”; font-family: ‘Merriweather’; } 使 html 更简洁一些。“\00a0” - 是
感谢您的文章!有一点我不太明白。您在哪里包含带有 setInterval 的脚本?在 body 标签的末尾,在删除 no-js 类的脚本之后,还是之前?(或者在 head 标签中?)
许多这些文章都关注 Google 字体。如果一个网站使用 Adobe 字体怎么办?对于那些有什么技巧吗?如何使用字体样式匹配器之类的工具与 Adobe 字体一起使用?我对这些优化东西还很陌生。
写得很棒。我最近在我的博客中禁用了自定义字体,结果……我再也忍受不了它的外观了。我不记得以前那么讨厌 Helvetica 和 Arial。但现在,它看起来就像一个长篇文字字体一样笨拙。我不太关心 FOUT - 我只是想最终加载一个好看的字体。
看来字体大小很重要;它不能是任何值。
在调试视图中访问以下笔,它从您的笔中分叉出来,以使字体急切加载,并查看控制台输出
https://cdpn.io/pen/debug/NWLavry
输出将是
在
12px
FontFace 加载时,其他大小尚未加载。如果你在开发者工具的网络选项卡中将它设置为慢速 3G 节流,你会发现 12px 字体的加载速度会明显更快,而 24px 字体却不会加载,有时我们甚至可以看到 FOUT(字体未找到)错误。
基于此,看来真正确保字体加载的正确方法是等待与我们的应用程序所使用的字体大小和字体名称相匹配的组合(这可能需要费力地跟踪)。
我假设,只有当特定大小和名称组合的位图计算完毕后,该字体大小和名称组合的 FontFace 才会被认为已加载。浏览器执行这些计算的过程被认为是异步的(基于我所观察到的现象),这解释了为什么 Font Loading API 需要指定字体大小(我毫不怀疑这些计算是在单独的线程中进行的,作为一种优化)。