最近在继续撸 Tangency 的前端页面,需要用到一些字体来让页面更加美观。相比于只有26个字母的英文,中文字体文件的大小有时候会动辄10mb。这不仅意味着会给服务器带来巨大的流量的消耗,而且若用户网络不佳的情况下,将会耗费大量的时间去加载,更致命的是,html的默认情况下,字体的加载是会阻塞html渲染的(同步加载)。(这里举个栗子,以前谷歌字体cdn被墙了无法加载的时候,若网页里带了谷歌字体cdn的文件,加载的时候会白屏很久)。
基于这两个原因,大部分中文网页都会优先考虑那些存在于用户系统里的字体作为网页的默认字体,例如微软雅黑、苹方。但如果你不喜欢这些字体,要用一些比较特殊的字体的话,就会比较蛋疼了。
昨天在逛 @Makito 的博客,看到他使用的是很好看的思源宋体,而且重点是访问速度并不差,于是我就开始好奇,这里面到底是什么黑科技。
我先是去下载了思源宋体的字体文件,发现文件是11m左右的,但我在Makito的博客通过network面板查看加载的字体的时候,发现字体文件都是百来k的。于是我开始想,难道这字体是按需加载的?
一番折腾后,得知 Makito 的字体是通过 Typekit 的字体库加载的,然后记起我其他项目里也用过 Typekit 啊,于是就跑到 Typekit 的网站上创建项目来调用网络字体。

当调用的是中文字体时, Typekit 给出的是一段JS代码。JS代码美化后,得到的是下面这几行代码。
(function (d) { // eslint-disable-next-line prefer-const let config = { kitId: 'ne****e', scriptTimeout: 3000, async: true } let h = d.documentElement let t = setTimeout(function () { h.className = h.className.replace(/\bwf-loading\b/g, '') + ' wf-inactive' }, config.scriptTimeout) let tk = d.createElement('script') let f = false let s = d.getElementsByTagName('script')[0] let a h.className += ' wf-loading' tk.src = 'https://use.typekit.net/' + config.kitId + '.js' tk.async = true tk.onload = tk.onreadystatechange = function () { a = this.readyState // eslint-disable-next-line no-mixed-operators if (f || a && a !== 'complete' && a !== 'loaded') return f = true clearTimeout(t) try { Typekit.load(config) } catch (e) {} } s.parentNode.insertBefore(tk, s) })(document)
这代码粗略一看可以得知,它是通过在页面尾部添加 script 标签来加载 https://use.typekit.net/*******.js
,并且通过 onload
事件来让它在js脚本加载完成的时候执行 Typekit.load
方法。
把 ne****e.js
文件下载下来后发现,这个文件是经过了压缩和混淆的,极其恶心。
看了一个下午后,我终于明白了他动态加载的大致原理。

1.获取网页所有文字
他通过 DOM 的 TreeWalker来遍历整个 DOM 树,并通过元素的 value, nodeValue 来获取整页上存在的字符。
document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false)
并且将页面字符转化为 unicode后,进行去重。


最后用一种不明的(压缩?)算法进行拼装,最后使用 window.btoa
进行base64编码,最后以 get
请求的方式请求字体。
2.动态更新
显然Typekit已经考虑到了页面内容可能会动态更新的情况,所以在代码中,我们可以发现它是通过 MutationObserver 监控 DOM 树的变化,若有变化,他会将新增的内容推入到一个数组中。
然后我猜测的是它进行去重,然后向服务器请求这部分新的内容的字体数据,然后用 DataView 把已存在的字体数据和新得到的字体数据进行拼合。

3.字体文件的按需加载
字面上来看,按需加载就是我们只需要加载我们需要的那部分数据,诶,是不是听着有点熟悉,有流媒体内味了吧。
所以总的来说,在服务器返回的字体信息中,可以只包含字体的基本信息和所需要的字体的轮廓信息。
而且我们又知道,大部分的字体格式是由表构成的,这暗示了字体文件的格式是一种相对规整的数据结构。
以 TrueTypeFont(.ttf) 格式的文件结构为例,其表式的存储结构以及在开头先存储一张「表达整体表结构(指定该表有多少种不同字段,以及它们的长度、起始位置等信息的信息)」的表,允许我们在无需遍历整个文件的情况下,就能够获知字段的基本信息(位置、长度等)。
而 TTF 规范则给出了一种在设计数据格式规范时,可供参考的工程实践:
给所有的字段取个四个字母的唯一名字,它们各自的内容都是一段连续的二进制数据。
在文件头部,首先存储一张「表达整体表结构」的表。在其中指定有多少种不同字段,以及它们的长度、起始位置等信息。这张表叫做 Offset 表。
紧接在这张表之后,逐段将这些字段表的内容拼接起来,就获得了最终的 TTF 格式字体。
掘金: doodlewind的文章:文字渲染的那些事(一)字体是如何存储的?
再加上其为了保证数据的紧凑性,各个字段是按照严格的约定的二进制形式存储。
所以,我们可以试下问下自己,如果让我们自己去设计这个字体的按需加载的服务端,我们会用什么样的方法去实现呢?
我们从上文已经知道了,各个字段的基本信息(例如位置和长度)已经在「表达整体表结构」的表中给出,那我们是不是可以通过这些位置数据,定位到我们所需要的字的轮廓信息,然后重新按这个规范对数据进行拼接呢?
而且在「页面更新」需要加载更多的字的字体时,我们就可以只传输这部分字的轮廓信息,然后客户端(浏览器)接受到新的轮廓信息后再在客户端本地进行拼接。
0 条评论
与博主谈论人生经验?