稍候片刻,月出文自明。
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/notosanssc/v41/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYlNbPzT7HEL7j12XCOHJKg4RgZw3nFTvwZ8atTsBvwlvRUk7mYP2g.4.woff2) format('woff2');
unicode-range: U+1f1e9-1f1f5, U+1f1f7-1f1ff, U+1f21a, U+1f232, U+1f234-1f237, U+1f250-1f251, U+1f300, U+1f302-1f308, U+1f30a-1f311, U+1f315, U+1f319-1f320, U+1f324, U+1f327, U+1f32a, U+1f32c-1f32d, U+1f330-1f357, U+1f359-1f37e;
}
有时候,我们需要给网页引入网络字体来增强页面的阅读体验。
纯英文页面通常只需要一套包含大小写字母、数字以及符号的字体,在经过 WOFF2 压缩之后,这类字体的体积往往非常小。但是对于中文为主的网页来说,一套包含完整中文的字体即使经过压缩后文件体积也可能达到 MB 级别。如果直接引用未经过优化的字体文件会给页面加载带来产生非常明显的网络开销。
目前字体方面已经有一系列非常成熟的优化方案,例如对字体进行预加载、子集化、unicode-range 切片、font-display: swap 等等。在引入这些方案后会有非常明显的优化效果,通常情况下,能将一套包含中文字体文件体积控制在一两百 KB 甚至更小而且不会对页面体验产生明显影响,这样的优化已经能满足绝大部分的场景。
直接高效的本体 + DLC 方案
最近开发的过程中碰到了一个比较有意思的情况:页面有一组固定不变的字符以及另一组会不定时改变且经常复用的带生僻字的动态字符。原来的解决方法类似于游戏本体 + DLC,通过脚本 + 工作流的方式定时从后端获取所有动态字符进行子集化,生成不变字符字体和动态字符字体。这种方法非常直接高效,原本 5.7 MB 的字体文件压缩到了 110KB 的不变字符字体 + 8KB 的动态字符字体。
但是前段时间调整数据库架构之后,如果继续使用之前的解决方法去聚合所有动态字符,会把私有用户的某些信息暴露出去。所以需要一种不预先收集所有文本,也能按需加载字体中缺失字符的方案。
看起来很合理的 unicode-range 方案
当使用 @font-face 引入字体的时候,CSS 提供了一个 unicode-range 描述符,允许传入 Unicode 码位来实现根据页面实际使用的字符,加载对应的字体文件,从而避免直接传入庞大的字体文件。
根据目前的需求可以将不变字符组单独切分一片,其余字符根据 Google Fonts 中文字体的 unicode-range 切片方式,剔除掉不变字符组后再进行切片。
实际上 Google Fonts 等中文字体切片方案并不是简单地将一套字体按照码位进行平均切片,其会在字频、切片后的字体文件体积、每个页面平均的请求数量以及字符覆盖率之间做平衡。
这种方案在普通网站中效果非常优秀,但是上文提到,这个页面的动态字符里带有生僻字(而且还不少😭。实际测试下来大量生僻字被分散到各种大块 Unicode 切片里,仅仅一个生僻字 “翳” 就得触发一整个几十 KB 切片的下载。即使限制了单个切片的最大体积,最夸张的一个页面也还是会触发 50 个切片总共超过 400 KB 的字体下载。为了减轻字体压力的 unicode-range 反而产生了大量不必要的开销。
极端页面数据量
极端页面请求次数
平均页面数据量
平均页面请求次数
总切片数
Google Fonts 切片方式
705.6 KB
31
363.5 KB
13.9
98
Google Fonts 切片方式(单个最大 8 KB)
425.7 KB
50
239.2 KB
18.8
335
8 码位切片
253.3 KB
77
174.1 KB
23.7
2854
32 码位切片
431.6 KB
75
218.1 KB
23
765
64 码位切片
643.9 KB
66
293.7 KB
22.4
406
未来可期的 IFT
截止文章撰写时依然没有浏览器原生支持 IFT,下文的测试均使用官方 DEMO (https://garretrieger.github.io/ift-demo) 的 WASM 实现
目前正在 W3C 候选推荐草案阶段的 增量字体传输 IFT 提供了一个新的选择。
IFT 又是什么
与 Unicode 切片是一个个完整的字体文件不同,IFT 实际上是由一个初始字体文件 + 许多后续加载的二进制字体补丁组成。初始字体文件内自带一份补丁映射表,浏览器在加载完初始字体文件之后会先检查其中的字体子集是否已经包含页面所有的字形,再根据补丁映射表下载对应的字形补丁,最后合并到初始字体文件中。
一个 TTF 的字体文件里主要包含字形数据和字体表两类数据,字形数据用来说明一个字符的形状以及应该怎么绘制出这个字符,而字体表提供怎么去找对应的字符以及怎么去排版等的信息。由于 IFT (增量字体传输)允许字体文件通过打补丁的方式来拓展字体集,所以 IFT 也主要分为字形补丁(glyph keyed patch)和表补丁(table keyed patch)两种机制,字形补丁用来增量补上字符字的字形数据,表补丁用来增量补充或者更新字体表。
IFT 对于字形补丁和表补丁两种补丁机制有可能会出现两种不同的处理方式:
效果
要使用 IFT 的相关功能需要使用 w3c/ift-encoder 库对字体文件进行 IFT 编码,这里我们关注几个主要的参数:
由于页面是中文为主,可以使用 --auto_config_primary_script=Script_chinese-simplified 来指定主要文字系统为简体中文。但是与上文的 Google Fonts 切片方式类似,对于大量生僻字的页面来说这种编码方式效果依然不佳,好在 IFT 允许通过 FrequencyDataConfig 指向根据我们自己字频数据编码的 Riegeli 文件来贴合页面的实际效果,后面测试中会使用页面以前的动态字符数据构建自己的 Riegeli 文件。
上文提到表补丁可能相互产生影响,所以在对字体进行编码的时候,编码器需要使用补丁图去处理不同补丁之间的关系。我们可以使用max_depth 参数去限制表补丁图的最大深度,否则会导致补丁规模爆炸。
原始 TTF 字体可能包含几万个字符,在编码的时候需要对里面的字符组合打包成若干份补丁,编辑器想要找到最优的打包方案的话需要对每个候选组合进行非常精确同时也非常慢的压缩(主合并),如果直接进行主合并会导致编码过程慢到不可用的状态,所以在主合并前需要进行粗略的预处理合并。预处理合并的工作流程主要受 preprocess_merging_group_size 和 preprocess_merging_probability_threshold 这两个参数控制。具体如下:
这样就能在保证效果的情况下,极大减轻编码流程的时间以及工作量。
对字体进行 IFT 编码后,页面相关测试结果如下:
方案
极端页面数据量
极端页面请求次数
平均页面数据量
平均页面请求次数
总分片数
IFT( Script_chinese-simplified)
455.9 KB
428
436.3 KB
403
9845
8 码位切片 unicode-range 方案
253.3 KB
77
174.1 KB
23.7
2854
IFT(后续全部使用自定义字频数据)
226.2 KB
49
182 KB
10.8
13026
IFT (深度=3 + 预处理合并=12)
247.9 KB
49
169.3 KB
10.8
1944
IFT (深度=3 + 预处理合并=8)
235.4 KB
49
169.9 KB
10.8
2648
IFT (深度=3 + 预处理合并=6)
228.8 KB
49
170.8 KB
10.8
3354
IFT (深度=3)
224.2 KB
49
172.5 KB
10.8
4760
IFT (深度=4)
225.6 KB
49
172 KB
10.8
5362
IFT (深度=5)
223.7 KB
49
171.6 KiB
10.8
6762
所以 IFT 方案目前推荐使用吗?
在「不预先收集所有文本,也要按需加载字体中缺失的字符」的限制下,算上后续尝试的现用现切的动态子集方案,IFT 方案依然是我目前试过最满意的方案。
极端页面下,对比 unicode-range 方案里最优的 8 码位切片,IFT 的请求数减少了三分之一以上,传输数据量也更低。而得益于 IFT 的增量传输机制,能够实现一直围绕同一份字体文件,相对于 unicode-range 切分成许多不同的分片字体,IFT 有不容易破坏字距、连字排版规则等原生优势。
但是,其目前仍有两个明显的障碍:一是目前需要捆绑上一个 WASM 来实现 IFT 功能,会白白增加两百多 KB 的开销,二是没法充分利用浏览器原生的字体预加载等优化机制,所以现阶段我并不推荐使用这套方案。
等到未来哪天浏览器原生支持 tech(incremental),彻底甩掉 WASM 之后,IFT 大概就会从「未来可期」变成真正的最优选择之一吧。