不只是 @font-face

字体文件在项目中的应用可以说是非常普遍的。例如使用第三方字体,以 iconfont 形式存在的字体图标等等。 但是加载这些字体文件可不是简单一句 @font-face 就完事了。

本文主要参考 comprehensive-webfonts,将介绍加载字体存在的问题和最佳实践。

恼人的 FOIT

首屏加载中的 FOUC(Flash of Unstyled Content) 是需要极力避免的,为此我们常常将关键路径使用的样式内联到 HTML 中。 同样的,字体文件在加载完成之前,浏览器会使用缺省字体显示内容,这就是 FOUT(Flash of Unstyled Text)。 但是问题就出在这里,除了 IE,其他浏览器都会等待 3 秒才展示系统字体,这就造成了 3 秒的 FOIT(Flash of Invisible Text)。用户不得不面对长达 3 秒消失了文字的内容。

FOUT & FOIT

font-display

为了让浏览器立即使用缺省字体现实内容,可以使用 font-display 这个 CSS 新属性。

立即使用缺省字体,Web 字体加载完成后立即更换,重新渲染:

font-display: swap;

这里有一个 Chrome Devtool 实战视频,需翻墙。 一切都很美好,缺点是浏览器支持度

内联字体

既然担心字体文件过大,影响加载时间,那么使用 data uri 内联是个不错的选择,可以完全避免 FOUT 和 FOIT。

@font-face {
	font-family: Lato;
    src: url('data:application/x-font-woff;charset=utf-8;base64...');
}

但是存在以下问题:

  1. 影响首屏性能。毕竟将字体也引入到了关键路径之中,需要谨慎选择文件的大小和数量。
  2. 由于完全指定了使用的字体,@font-face 特有的提供多种文件格式供浏览器选择就无法使用了。
  3. 多个字体无法并行加载。

为了避免 CSS 阻塞渲染,之前《让骨架屏更快渲染》一文使用了 JS 异步加载 CSS,这里也可以借鉴下:

<script>
    /*! loadCSS: load a CSS file asynchronously*/
    (function(e){"use strict";var t=function(t,n,r)});
	loadCSS("async-data-uri.css");
</script>

当然这并不能解决 2,3 两点。

CSS Font Loading API

为了增加开发者对于字体加载状态的控制,目前处于草案阶段的 CSS Font Loading API 是值得关注的。

从草案中提供的 API 示例来看,我们可以得知页面中使用的全部字体何时加载完成:

document.fonts.ready.then(function() {
    // 全部加载完成后显示
    var content = document.getElementById("content");
    content.style.visibility = "visible";
});

通过 load 加载指定字体:

function drawStuff() {
    var ctx = document.getElementById("c").getContext("2d");

    ctx.fillStyle = "red";
    ctx.font = "50px MyDownloadableFont";
    ctx.fillText("Hello!", 100, 100);
}
document.fonts.load("50px MyDownloadableFont")
    .then(drawStuff, handleError);

第三方字体加载库

又到了熟悉的 polyfill 出场了。我们可以借助第三方字体加载库,在加载完成后,在根元素上应用 CSS Class,实现 FOUT + swap 的效果。

首先正常使用 @font-face:

@font-face {
    font-family: Lato;
    src: url('font-lato/lato-regular-webfont.woff2') format('woff2'),
        url('font-lato/lato-regular-webfont.woff') format('woff');
}
.fonts-loaded body {
    font-family: Lato, sans-serif;
}

使用 Font Face Observer,这里可以使用一个小技巧避免多次访问重复请求字体文件:

var fontA = new FontFaceObserver('Lato');
var fontB = new FontFaceObserver('LatoBold', {
    weight: 700
});
Promise.all([
    fontA.load(null, 10000),
    fontB.load(null, 10000)
]).then(function () {
    document.documentElement.className += " fonts-loaded";
    // 加载完成后添加标记,后续访问时可以以此判断不重复加载字体
    sessionStorage.fontsLoadedFoutWithClassPolyfill = true;
});

使用类似思路的还有更有名的 Web Font Loader。 后续的方案也都基于 CSS Font Loading API 及其 polyfill。

分阶段加载

在英文字体中,同一个字体常常会有多个变体,例如加粗,斜体等等。渐进式地加载这些字体,能够减少 FOIT 时间。 例如这里分成两个阶段,第一阶段仅仅加载一个基础字体,完成后立即应用展示。随后开始第二阶段全部字体的加载应用。 值得注意的是基础字体这里需要声明两遍 LatoInitialLato

@font-face {
	font-family: LatoInitial;
	src: url('font-lato/lato-regular-webfont.woff2') format('woff2'),
		url('font-lato/lato-regular-webfont.woff') format('woff');
}

@font-face {
	font-family: Lato;
	src: url('font-lato/lato-regular-webfont.woff2') format('woff2'),
		url('font-lato/lato-regular-webfont.woff') format('woff');
}

@font-face {
	font-family: Lato;
	src: url('font-lato/lato-bold-webfont.woff2') format('woff2'),
		url('font-lato/lato-bold-webfont.woff') format('woff');
	font-weight: 700;
}

body {
	font-family: sans-serif;
}
.fonts-loaded-1 body {
	font-family: LatoInitial;
}
.fonts-loaded-2 body {
	font-family: Lato;
}

你可能会好奇 Lato 拥有两个字体资源 regularbold,在实际使用中浏览器会选择哪一个呢? 可以参考 Font selection algorithm & Font synthesis,这里就不展开了。

if( "fonts" in document ) {
    if( sessionStorage.fontsLoadedFoft ) {
        document.documentElement.className += " fonts-loaded-2";
        return;
    }

    document.fonts.load("1em LatoInitial").then(function () {
        document.documentElement.className += " fonts-loaded-1";

        Promise.all([
            document.fonts.load("400 1em Lato"),
            document.fonts.load("700 1em Lato"),
            document.fonts.load("italic 1em Lato"),
            document.fonts.load("italic 700 1em Lato")
        ]).then(function () {
            document.documentElement.className += " fonts-loaded-2";
            sessionStorage.fontsLoadedFoft = true;
        });
    });
}

最佳实践!

其实到目前为止,已经称得上是比较理想的方案了。

对于英文字体,在第一阶段中加载的基础字体还可以精简,例如使用仅仅包含字母的 LatoSubset 以减少文件大小。 这还不算完,既然已经精简过,使用 data uri 增加的首屏加载时间将完全优于增加的网络开销。 Demo

关于字体图标的一点想法

使用 iconfont 的时候,其实是希望出现 FOIT,反而要避免 FOUT。否则首屏将出现一个大大的 search 文字。

<span class='material-icon'>search</span>

这时上述提到的方法虽然不能直接使用,但 CSS Font Loading API 还是能派上用场。 一种理想的做法是默认字体图标不可见,字体加载完成后变为可见。 可以参考 Typekit 中的例子

参考资料