字体加载最佳实践及原理完全解读

3,714 阅读9分钟

Motivation

我们知道,中文字体包一般都比较大,但很多前端页面,尤其是一些活动页面,又经常会引用一些自定义的字体包,那在这些字体包加载完成之前,使用该字体的文字会以什么行为渲染很可能会影响页面的展示效果。

有些浏览器默认会出现无墨渲染,即在下载完成前就不出字了。而不同的需求可能会需要在此场景下不同的呈现方式:比如先用后备字体显示文本,下载字体后再更换;而如果超出一定时间未下载完成,就一直使用后备字体;或者在下载完成前使用前面提到无墨渲染的方式避免页面字体闪烁……等等。

为了解决上述的问题,并在不同浏览器中统一此行为,CSS 提供了 font-display 属性来告知浏览器下载过程中的渲染方法。然而此属性有兼容性问题;

此外,还有一些控制浏览器行为的方法与字体下载进度api: FontFaceSet 相关的一些接口,但这些 api 仍在草案阶段,只有部分浏览器实现。

在兼容性有问题的设备上,要想让字体显示行为符合预期,还需要使用一些 hack 方法。

本文将依次介绍 CSS 实现字体加载优化、JS api 实现字体加载状态监听以及 hack 实现字体加载状态监听的方法。最后提供 github 仓库和 npm 引用方式。

font-display 属性

`font-display` 属性决定了一个 `@font-face` 在不同的下载时间和可用时间下是如何展示的。

从以上定义来看,font-display 至少定义了两个条件下的字体加载行为:下载时间 & 可用时间

在介(zhao)绍(ban)文档之前,简单理解一下这两个时间的概念:

  1. 下载时间:顾名思义,这个时间很容易理解,就是下载字体所需的时间;
  2. 可用时间:这里可用时间不是指的字体下载完成可以使用的时间,如果这样的话这个值其实就和下载时间一样了。这里的可用时间是为了防止页面字体闪烁所定义的。一般的情况是:字体在这个时间内下载完成,那么可以更换字体,否则不进行更换。说一个使用场景:比如字体包很大,下载非常非常慢,用户已经浏览到一半了,字体才下载完成。这时候如果突然切换字体反而会影响用户体验,不如让用户以当前字体浏览完成,如果下次再进入页面,则可以使用使用缓存加载所需字体了。

简单了解了这个概念后,来看官方的解释。

在学习 font-display 的属性之前,需要先学习 字体显示时间轴 的概念。

字体显示时间轴

字体显示时间轴基于一个计时器,该计时器在用户代理尝试使用给定下载字体的那一刻开始。时间线分三个时间段,在这三个时间段中指定字体的元素的渲染行为。

字体阻塞周期(block period)

如果未加载字体,任何试图使用它的元素都必须渲染 不可见的 后备字体。如果在此期间字体已成功加载,则正常使用它。

字体交换周期(wrap period)

如果未加载字体,任何尝试使用它的元素都必须呈现后备字体。如果在此期间字体已成功加载,则正常使用它。

字体失败周期

如果未加载字体,用户代理将其视为导致正常字体回退的失败加载。

font-display 属性值

[auto | block | swap | fallback | optional]

auto

字体显示策略由用户代理定义

注意: 许多浏览器的默认策略类似于 `block` 指定的策略

block

为字体提供一个短暂的阻塞周期(大多数情况下建议为3s)和无限的交换周期

理解:浏览器首先绘制不可见的文本(即无墨渲染),加载完成后立即更换字体

使用场景:一般用于需要使用特定字体渲染文本才能使页面可用时。

swap

为字体提供一个非常小的阻塞周期(建议<=100ms)和无限的交换周期

理解:如果未加载字体,则浏览器立即使用后备字体进行渲染,但是在加载后立即交换字体。

使用场景:一般用于以特定字体呈现文本对于页面非常重要时,但是以其他字体呈现仍然会得到正确的信息。

fallback

为字体提供一个非常小的阻塞周期(建议<=100ms)和短暂的交换周期(建议为3s)

理解:如果字体未加载,则首先会显示一个后备字体,但加载后会立即替换。但是,如果经过的时间过多,则会在整个页面的剩余生命周期中使用后备字体。

使用场景:用于正文或其他希望使用所选字体的文本,但对于用户来说,使用后备字体也可以正确看到文本。该值适用于大段文本的展示。

因为在大块的文本中,最重要的是使文本快速呈现,以便使用户可以尽快开始阅读。此外,一旦用户开始了阅读,就不应该因为字体的突然改变而被分散注意力甚至重新寻找文字所在的位置。

optional

为字体提供一个非常小的阻塞周期,并且没有交换周期

理解:如果可以立即展示字体,则使用该字体。否则不用。

对于因为因设置 font-display: optional 不能立即展示的字体,用户代理可以终止字体下载,或以非常低的优先级下载字体。

使用场景:可以被用于正文文本或纯粹是装饰性的其他任何文本。比起用户等待更长时间看到美观的内容而言,网页在首次访问时快速呈现更为重要

兼容性问题和判断

上面叙述中的使用 font-display 属性的效果很美好,几个值基本满足了所有使用场景。然鹅:

caniuse上可以看到,这个属性的兼容性还是有问题的。

要想判断当前用户代理是否支持 font-display 属性,只需要用下的表达式:

const isSupportFontDisplay = 'fontDisplay' in document.body.style;

CSS FONT LOADING API

Font Loading API 是为了增加开发者对于字体加载状态的控制所提出的一个草案。

在已支持该标准的浏览器中,通过调用 document 的 fonts 属性可以返回文档的 FontFaceSet 接口。通过该接口可以得知页面中使用的全部字体何时加载完成:

document.fonts.ready.then(function() {
  // 字体加载完成后的逻辑
});

还可以获取指定字体加载完成或失败时的 Promise

document.fonts.load("12px MyFont", "ß").then(…);  

一切看起来很美好,有了这个 API,我们就可以在进入页面中先使用后备字体渲染,然后通过 document.fonts.load 拿到指定字体加载完成状态,之后更换字体就可以了。

问题依然是兼容性:caniuse

判断是否支持此 API 的方法:

const isSupportNativeFontLoader = !!document.fonts

Polyfill

为了解决兼容性问题,需要使用一些 hack 的polyfill。下面介绍一个令人叹为观止的手法。

核心思想:

对于由内容撑开的容器,其中文字字体改变时,会造成内容高度变化,引发 scroll 事件。

这样,监听 scroll 事件岂不就知道文字何时加载完成了?

废话少说,先看 dom 结构:

<div class="Font-loader__wrapper" style="position:absolute; top:0; left:0; overflow:hidden;">
    <div class="Font-loader__content" style="position:relative; white-space:nowrap;">
        <div class="Font-loader__inner-wrapper" style="position:absolute; width:100%; height:100%; overflow:hidden;">
            <div class="Font-loader__inner-content"></div>
        </div>
        test words here。测试文本测试文本
    </div>
</div>
<span class="Font-loader__test" style="white-space:nowrap">
    test words here。测试文本测试文本
</span>
<span class="Font-loader__refer" style="white-space:nowrap">test words here。测试文本测试文本</span>

首先,content(Font-loader__content) 的高度的内容撑开的,是内容高度。然后要设置 wrapper(Font-loader__wrapper) 的高度小于content的高度,如(content.clientHeight - 1)。这时候 wrapper 的 scrollHeight(content 的高度)与 clientHeight(wrapper 设置的高度)不一样,wrapper 是可以滚动的。 接下来把 wrapper 的 scrollTop 设置为 scrollHeight - clientHeight,即把 content 滚动到底部。

origHeight = $content.offsetHeight;
origWidth = $content.offsetWidth;

$wrapper.style.width = (origWidth - 1) + 'px';
$wrapper.style.height = (origHeight - 1) + 'px';
$wrapper.scrollTop = $wrapper.scrollHeight - $wrapper.clientHeight;
$wrapper.scrollLeft = $wrapper.scrollWidth - $wrapper.clientWidth;

这里不仅监听了垂直方向上的高度变化,还监听了水平方向上的宽度变化。但本节解说只说高度变化一种情况,大家知道宽度也会监听就可以了。

简单画个图,大家意会一下:

接下来就分两种情况:

  1. 如果字体加载完成后相比默认字体变矮

    1. 当字体变小时,content高度改变,于是wrapper的scrollHeight会改变,引发wrapper上scrollTop的变化,于是触发wrapper的scroll事件

    wrapper 变矮了,会自动滚下去,触发scrollTop的改变
  2. 如果字体加载完成后相比默认字体变高

    1. 这种情况下, wrapper的clientHeight 变大,但是不会触发scrollTop改变,只是内容区没有滚到底部而已。

    wrapper变高了,scrollTop不会改变 2. 这种情况下就需要借助 innerWrapper 反向思考:
    1. innerWrapper(Font-loader__inner-wrapper) 的高度设置为父元素的100%;一开始要把 innerContent(Font-loader__inner-content)的高度设置为比 content 稍高,如content.clientHeight + 1。然后设置 innerWrapper = innerWrapper.scrollTop - innerWrapper.clientHeight 使其滚动到底部。
      $innerContent.style.width = (origWidth + 1) + 'px';
      $innerContent.style.height = (origHeight + 1) + 'px';
      $innerWrapper.scrollTop = $innerWrapper.scrollHeight - $innerWrapper.clientHeight;
      $innerWrapper.scrollLeft = $innerWrapper.scrollWidth - $innerWrapper.clientWidth;
    
    1. 在字体变高时,content 变高,innerWrapper 也会变高。
    2. 这时候会触发 innerWrapper 的 scroll 事件使其 scrollTop 发生改变。
  3. 结合 1,2 两种情况就可以handle住字体变高变低(变胖变瘦)两种情况。所以分别设置并监听 innerWrapper 和 wrapper 的scroll 事件就可以了。 对宽度 width、scrollLeft 的监听同理。

$wrapper.addEventListener('scroll', _checkFont);
$innerWrapper.addEventListener('scroll', _checkFont);

这里 _checkFont 是里个双保险,通过比较相同文本下分别设置为该字体和默认字体的宽度,如果一样说明加载完成,不一样说明已经加载。这就是 DOM 结构里 Font-loader__testFont-loader__refer 的用途了。

最佳实践

以上几节基本可以处理各种环境下的字体加载问题了。在满足需求的情况下,优先使用 CSS 属性 font-display,否则使用 CSS Font Loading API 监听字体加载状态,再不行用监听 scroll 的方法模拟字体加载回调函数。

整理了最佳方案的实现代码:见 github,并发布了 npm包。第一次发包,请大家多多指教~

参考资料

font-display 规范

Font Loading API 草案

MDN: FontFace

Web font loading detection, without timers