深入W3C的 @font-face 加载逻辑,探索最佳中文字体加载策略

174 阅读12分钟

中文字体加载背景

相比英文字符,中文字符的数量要多的多,这也导致了如果在网页中使用自定义字体的时候,需要下载的字体包很大。一般的中文字体包多达十几兆,可能比本身网页中的html,js,css本身的大小还要大。

因此,如果有合理的方法能让字体包尽可能的优化加载,对于首页的渲染是个不错的进步。

其实网上现在已经有了部分解决的方案。但是笔者认为仍然不是最优的,各有各的优劣。

这里举几个例子:

  1. 抓取分析静态网页,根据网页内容生成对应字符的字体包,这样是最小最合适的子集字体包,但是无法应对动态的网页和内容。
  2. 搭建服务器,利用后端接口请求,让前端把需要新字体的字符传给后端,然后将字体包分割出对应的子集字体包,然后下载下来。这样字体包也是很小的,但是需要依赖一个后台服务。对于一般的公司,只为了让字体快一点下载,而搭建这样的服务有些得不偿失。
  3. 将中文字体按一定的字符数,分割成子集。然后利用 unicode-range​ 让浏览器自动下载子集包。 这样的方案,会在一开始浪费一些流量,下载一些多余的字符的字体,另外css中增加了 unicode-range​ 而且不一定是连续的字符,导致了css文件也会偏大。但总体来说,相比个直接加载一个大的字体包来的划算。

我的理想方案是完全利用浏览器的特性,来尽可能的优化字体加载,不需要再花钱搭建服务。

第3种方案比较接近我想要的,但是还是不够理想。我们看看如何 @font-face 背后的加载逻辑是什么,进一步优化其加载策略。

@font-face 加载逻辑

​@font-face​ 用于声明一个自定义字体。使用方法如下:

@font-face {
    font-family: "Trickster";
    src:
        local("Trickster"),
        url("trickster-outline.otf") format("opentype");
        font-weight: 100 400;
        font-style: oblique 20deg 50deg;
	font-display: auto;
	unicode-range: U+0-7F;
}

定义好后,我们就能在指定元素下直接使用啦:

body {
  font-family: "Trickster", sans-serif;
}

上面列举了声明一个自定义字体常见的属性,这里没有全列举,感兴趣的同学可以到mdn查看:

@font-face - CSS: Cascading Style Sheets | MDN (mozilla.org)

常见属性的用途:

  1. ​font-family​: 定义这个字体名称

  2. ​src​: 字体源文件的位置:可以是网络文件,也可以指定用户电脑里的一个字体名,需要加载时从左向右。遇到可用的文件,后面的源就不会继续请求了。

  3. ​font-weight​, font-style​: 这里可能同学有疑惑了,font-weight,font-display是定义元素样式用的。怎么这里也能用了?其实这是定义当前自定义字体在何种情况下使用的。比如当 body 指定了 font-family: "Trickster", sans-serif​ 2个字体。并指定 font-style: oblique​, 那么浏览器实际渲染的时候,优先使用定义字体时指定了 font-style: oblique​ 的字体,并不一定严格从左向右的使用字体格式。当然这部分不是严格匹配的,不能说指定了 font-weight:500​ 就只能在标明了 font-weight:500​ 的地方使用. 而是有一定容错的算法控制的. 这里算法的部分感兴趣的同学可以参考官方文档: CSS Fonts Module Level 4 (csswg.org)

  4. ​unicode-range​ : 用来声明当前自定义字体可以作用于哪些字符. 这个属性用的不多,但是如果做中文字体控制也是一个非常重要的, 这个对于浏览器的资源加载控制等有相当多的优化.因为当指明了这个属性的字符, 即使元素使用了当前的自定义字体, 当遇到其他字符时, 浏览器会直接使用后备字体, 不会再分析字体包里面有没有当前字符. 也不会请求加载字体的源文件.

  5. ​font-display​: 用来指定当渲染命中当前字体的时候,正在下载字体文件的这段时间,文本该如何展示渲染的策略。

    1. ​block​: 完全阻塞文本渲染,直到字体文件下载完成。
    2. ​swap​: 直接使用可用的字体渲染,直到字体文件下载完成,进行重新渲染。
    3. ​fallback​: 直接使用可用的字体渲染,如果字体能在较短时间下载完成,则重新渲染。否则不进行重新渲染,具体以浏览器确定,一般几百毫秒。如果网站不想让数秒后字体下载完成造成闪烁重排,可以指定这个。
    4. ​optional​: 为字体提供极小的块周期并且没有交换周期。也就是如果第一时间不能加载出指定字体的话,就不会再重新渲染了。这应该是用的最少的属性值了。
    5. ​auto​: 由浏览器决定,一般是block​。

说到这里,同学已经发现了,其实浏览器并不会在一开始的时候就直接全部请求你的字体文件.而是渲染时遇到了使用这个字体的地方,才回去请求字体文件.这就给我们中文字体优化以可乘之机.接下来详细讲述一下字体文件加载的过程.

加载规则

下面简要描述一下字体匹配算法。内容主要来自 CSS Fonts Module Level 4 (csswg.org) 第五章的内容。

给定一段文本中的某个字符时,选择字体的步骤如下:

  1. 遍历由 font-family 属性命名的所有属性值。

  2. 根据其他字体属性选择一个具有适当样式的 font face。这包括font-weight​, unicode-range​ 等属性。

  3. 确定是否存在给定字符的图形。 这使用字体的字符映射表来完成,该表将字符映射到该字符的默认图形。如果一个字体包含给定字符的图形映射,并且(如果需要)该字符的形状信息可用,则认为该字体支持该给定字符。这一步会加载字体包本身,并分析字体包内部的信息。

    1. 使用给定元素的计算字体属性值时,用户代理程序会从 font-family 属性指定的第一个姓氏开始。
    2. 如果是一个通用的 font-family, 会从系统中尝试加载。
    3. 对于其他 font-family, 浏览器尝试通过@font-face​ 规则查找 font-family​,然后在已安装的字体(这可能包括字体别名)中查找。匹配名称时,使用与上节中概述的相同方法进行本地化名称匹配。如果字体资源不可用或者包含无效的字体数据,则无视这个 font-face​。当然我们可以对同一个font-family​同时定义多个font-face​ 。
  4. 如果font-family​匹配成功,用户代理会将该family的font-face​集合起来,然后按照以下给出的顺序使用其他字体属性将集合缩小到单一的font-face​。算法会假定每个支持的字体值组合都是集合中的一种独特字体。一组通过@font-face规则定义的字体,其字体描述符值相同但unicode-range值不同,在此步骤中被认为是一个单一的复合字体。

    1. 这里关于font-width​ ,font-weight​ , font-style​ ,font-size​ 的匹配算法不介绍了。
  5. 如果匹配的字体是通过@font-face规则定义的,用户代理必须使用以下程序来选择一种字体:

    1. 如果字体资源尚未加载,并且unicode-range描述符值定义的字符范围包含要查找的字符,则加载字体。
    2. 下载完成后,如果有效的字符映射支持要查找的字符,请选择该字体。(当没有指定 unicode-range 时,使用此方案)

    当匹配的面是一个复合面时,用户代理必须按照@font-face规则定义的逆序对复合面中的每个面使用上述程序。也就是说,多个font-face​ 定义了同一个字体时,从下往上,逆序执行。

    在下载过程中,用户代理程序必须要么等待字体下载完成后再进行渲染,要么先使用替换后的字体测量值进行一次渲染,然后再进行一次渲染,直到字体下载完成。这就是 font-display​ 指定的效果。

  6. 如果不存在匹配的面孔或者匹配的面孔中没有包含要渲染的字符的字符,则选择下一个 font-family​ 并重复前面三个步骤。来自该font-family​的其他font-face​的字符不会被考虑。

  7. 如果不再有需要评估的font-family​,并且尚未找到匹配的字体,那么用户代理程序将执行已安装字体的后备程序,以查找要渲染的字符的最佳匹配字体。此过程的结果可能会因用户代理程序而异。

  8. 如果某个字符无法使用任何字体显示,用户代理应通过某种方式指示该字符未被显示,可以使用缺失字形的符号表示(例如使用备用字体)或使用默认字体中的缺失字符字形。

以上就是字体加载的几个关键步骤,其中许多细节我没有写上,感兴趣的可以去看原文档。

其中影响我们中文分包策略的就是3,4,5,6这4个步骤了。

实际举例

<!DOCTYPE html>
<html lang="en">
<style>
body {
  font-size: 80px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.fontBClass {
  border: 1px solid rosybrown;
}
 /* 定义1 */
@font-face {
  font-family: 'fontCustom';
  /* // 源文件为字体的子集, 内部包含字符 "AB" */
  src: url('./font-file/Fagrak_AB.otf') format('opentype');
  font-display: swap;
}
/* // 定义2 */
@font-face {
  font-family: 'fontCustom';
  /* // 字符 "B" 的 Unicode 编码 */
  unicode-range: 'U+0066';
  /* // 源文件为字体的子集, 内部包含字符 "B" */
  src: url('./font-file/Kerao-Regular_B.ttf') format('truetype');
  font-display: block;
}
/* // 定义3 */
@font-face {
  font-family: 'fontCustom';
  /* // 字符 "E" 的 Unicode 编码 */
  unicode-range: 'U+0069';
  /* // 源文件为字体的子集, 内部包含字符 "E" */
  src: url('./font-file/MilkyCoffee-gx1yR_E.ttf') format('truetype');
  font-display: block;
}
/* // 定义4 */
@font-face {
  font-family: 'fontCustom';
  /* // 源文件为字体的子集, 内部包含字符 "C" */
  src:
  local('fontCustom'),
  url('./font-file/Yunmobei_C.ttf') format('truetype');
  font-display: block;
}

.fontBClass {
  font-family: 'fontCustom', sans-serif;
}
</style>
<body>
  <div class="fontBClass">ABCD</div>
</body>
</html>
// 对于 fontBClass 元素,加载流程如下
1. 首先识别到当前的div中的字符包括 "ABCD",所使用的字体为 "fontCustom", Micsosoft Yahei;
2. 在用户代理 和 font-face 查找 fontCustom 字体的定义,可以发现,这个字体是由 @font-face 定义的。
3. 由于定义了多个 @font-face,这里按照 font-face 定义的顺序进行逆序搜索。所以现检查定义4.
4. 定义 4 中指定了 font-display: block; 所以在 定义4 的字体逻辑没有走完前,不会显示出文字。页面为空白。
5. 按照 定义 4 指定src从左向右依次加载文件。首先检查本地名为 'fontCustom' 的字体。假设用户没有此字体。 因为没有指定 'unicode-range' 所以浏览器下载 ‘http://xxx.woff2’ 文件,
并检查文件内,有没有对应的字符可以渲染,发现文件内有 'C' 的字符, 使用当前字体渲染C。
6. 此时因为其他字符还没渲染,继续逆序搜索。
7. 定义 3 定义了 'unicode-range' 并且指定字符 E, 但是dom元素中没有字符 E, 所以定义 3 会被直接跳过,不会下载对应的字体文件。
8. 定义 2 定义了 unicode-range: 'U+0042', 且 B 是当前元素的字符之一。 所以尝试下载 'http://font-B.woff2' 文件。并检查字体文件中是否有 B 的字型。有的话就用此字体渲染B。
  我们这里测试文件是有的。
9. 执行 定义 1, 这里的执行逻辑同 定义4, 因为指定 font-display: swap; 所以此时会将字符渲染出来。  因为没有指定 'unicode-range' 所以浏览器下载 'http://font-A.woff2', 发现文件中有字符AB的字体,
用此字体渲染A,并且重绘屏幕。
10. 定义 fontCustom 的所以 font-face 检查完后仍然没有字符 D,所以字符 D 使用后面的字体 sans-serif 渲染。

效果顺序预览:

28.png 2.

74.png

3 98.png

4 120.png

5

154.png

那么我们最终方案可以如下:

  1. 分析汉子使用频率,并利用连续性,打出子集包。使用率越高的字体,最好放在后面。当然有unicode-range​的情况下,顺序影响不大。
  2. 后面的字符利用率非常低,可以根据自身情况,看看是否需要继续使用 unicode-range​,如果不使用unicode-range​,要放在前面让浏览器最后加载。
  3. html中,利用prefetch或者手动下载常用汉子子集,不要prefetch全部的字体。
  4. 最下面的是单独的 local 尝试使用用户本地字体,且不指定 unicode, 应用到全字符。
  5. 另外可以使用 size-adjust​ 修正默认字体偏移
  6. 当然如果需要初始时更精确的字体,可以利用puppeteer,加载网页首次加载的内容。或者ssr的网页在服务端也可以做这一步。或者在前端文件的编译阶段,处理一下静态的文字。将首次渲染所展示的少数字体的打出子集包。

未来展望

w3c推出了新的增量式字体,当前浏览器兼容性很差,但是未来是很值得期待的。增量字体传输 --- Incremental Font Transfer (w3c.github.io)