如何在对抗 FOUT 并让 Lighthouse 满意的情况下加载字体

Avatar of Adrian Bece
Adrian Bece

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费额度!

网页字体工作流程很简单,对吧?选择一些外观不错的网页字体,获取 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),所有其他资源都应在页面渲染后加载,包括字体。

如果用户在速度慢、连接不可靠的情况下浏览未经优化的网站,他们会因为等待字体文件和其他关键资源完成加载而感到沮丧地坐在空白屏幕前。结果?除非该用户非常有耐心,否则他们很有可能会放弃并关闭窗口 认为页面根本没有加载。

但是,如果非关键资源被延迟加载,并且内容尽快显示,用户将能够浏览网站并忽略任何丢失的呈现样式(如字体)——也就是说,如果这些样式不影响内容。

经过优化的网站尽快使用关键 CSS 呈现内容,并延迟加载非关键资源。在第二个时间轴上,字体切换发生在 0.5 秒到 1.0 秒之间,表明呈现样式开始渲染的时间。

加载字体的最佳方式

没有必要重新发明轮子。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 值,字体会被应用于所有媒体类型(屏幕、打印和语音)。

Lighthouse 对这种方法感到满意!

需要注意的是,自托管字体也有助于解决渲染阻塞问题,但这并非总是可行。例如,使用 CDN 可能不可避免。在某些情况下,让 CDN 承担服务静态资源的重任是有益的。

即使我们现在以最佳的非渲染阻塞方式加载字体样式表和字体文件,我们也引入了一个小型的 UX 问题……

未格式化文本闪现 (FOUT)

这就是我们所说的 FOUT

为什么会这样?为了消除渲染阻塞资源,我们必须在页面内容渲染后(即显示在屏幕上)加载它。对于在关键资源之后异步加载的低优先级字体样式表,用户可以看到字体从备用字体更改为下载字体的瞬间。不仅如此,页面布局可能会发生变化,导致某些元素看起来破损,直到网页字体加载完成。

处理 FOUT 的最佳方法是使备用字体和网页字体之间的过渡平滑。为了实现这一点,我们需要

  • 选择合适的备用系统字体,使其尽可能接近异步加载的字体。
  • 调整备用字体的字体样式font-sizeline-heightletter-spacing 等),使其尽可能接近异步加载的字体的特征。
  • 清除备用字体的样式,一旦异步加载的字体文件完成渲染,就应用打算用于新加载字体的样式。

我们可以使用 字体样式匹配器 查找最佳备用系统字体,并为我们计划使用的任何网页字体配置它们。一旦我们准备好了备用字体和网页字体的样式,我们就可以进行下一步。

在本示例中,Merriweather 是字体,Georgia 是备用系统字体。一旦应用了 Merriweather 样式,布局偏移应最小,字体之间的切换应该不那么明显。

我们可以使用 CSS 字体加载 API 来检测我们的网页字体何时加载完成。为什么呢?Typekit 的网页字体加载器 曾经是更流行的方式之一,虽然继续使用它或类似的库很诱人,但我们需要考虑以下几点

  • 它已经超过四年没有更新了,这意味着,如果插件方面出现任何问题,或者需要新的功能,很可能没有人会实现和维护它们。
  • 我们已经使用 Harry Roberts 的代码片段有效地处理了异步加载,我们不需要依赖 JavaScript 来加载字体。

在我看来,使用 Typekit 这样的库对于像这样简单的任务来说,JavaScript 过多。我希望避免使用任何第三方库和依赖项,因此让我们自己实现解决方案,并尽可能使其简单直观,不要过度设计。

尽管 CSS 字体加载 API 被认为是实验性技术,但它大约有 95% 的浏览器支持。但是,无论如何,我们应该在 API 发生更改或将来被弃用时提供备用方案。丢失字体的风险不值得冒这个险。

CSS 字体加载 API 可用于动态异步加载字体。我们已经决定不依赖 JavaScript 来执行像字体加载这样的简单任务,并且我们已经使用包含预加载和预连接的纯 HTML 以最佳方式解决了这个问题。我们将使用 API 中的一个函数,它将帮助我们检查字体是否已加载并可用。

document.fonts.check("12px 'Merriweather'");

check() 函数将根据函数参数中指定的字体是否可用返回 truefalse。字体大小参数值对我们的用例并不重要,可以设置为任何值。不过,我们需要确保

  • 页面上至少有一个 HTML 元素包含一个字符,并且应用了网页字体声明。在示例中,我们将使用 &nbsp;,但任何字符都可以,只要它对有视力的人和无视力的人都是隐藏的(不使用 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 元素来跟踪字体并应用其样式?我们添加了一个带有 &nbsp; 字符的 <div>,我们以一种可访问的方式将其从视力正常和视力障碍的用户隐藏起来,因为我们不能使用 display: none;。此元素有一个内联 font-family: 'Merriweather' 样式。这使我们能够在备用样式和已加载的字体样式之间平滑切换,并确保所有字体文件都得到正确跟踪,无论它们是否在页面上使用。

请注意,&nbsp; 字符没有在代码片段中显示出来,但它确实存在!

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 问题。


参考资料