Web 中文字体性能优化实践

背景介绍

Web 项目中,使用一个适合的字体能给用户带来良好的体验。可是字体文件这么多,若是设计师或者开发人员想要查询字体,只能一个个打开,很是影响工做效率。所以,夸克平台须要实现一个功能,可以支持根据固定文字以及用户输入预览字体。在实现这一功能的过程当中主要解决两个问题:javascript

  • 中文字体体积太大致使加载时间过长
  • 字体加载完成前不展现预览内容

如今将问题的解决以及个人思考总结成文。css

使用 web 自定义字体

在聊这两个问题以前,咱们先简述怎样使用一个 Web 自定义字体。要想使用一个自定义字体,能够依赖 CSS Fonts Module Level 3 定义的 @font-face 规则。一种基本可以兼容全部浏览器的使用方法以下:html

@font-face {
    font-family: "webfontFamily"; /* 名字任意取 */
    src: url('webfont.eot');
         url('web.eot?#iefix') format("embedded-opentype"),
         url("webfont.woff2") format("woff2"),
         url("webfont.woff") format("woff"),
         url("webfont.ttf") format("truetype");
    font-style:normal;
    font-weight:normal;
}
.webfont {
    font-family: webfontFamily;   /* @font-face里定义的名字 */
}

因为 woff2woffttf 格式在大多数浏览器支持已经较好,所以上面的代码也能够写成:前端

@font-face {
    font-family: "webfontFamily"; /* 名字任意取 */
    src: url("webfont.woff2") format("woff2"),
         url("webfont.woff") format("woff"),
         url("webfont.ttf") format("truetype");
    font-style:normal;
    font-weight:normal;
}

有了@font-face 规则,咱们只须要将字体源文件上传至 cdn,让 @font-face 规则的 url 值为该字体的地址,最后将这个规则应用在 Web 文字上,就能够实现字体的预览效果。java

但这么作咱们能够明显发现一个问题,字体体积太大致使的加载时间过长。咱们打开浏览器的 Network 面板查看:git

能够看到字体的体积为5.5 MB,加载时间为5.13 s。而夸克平台不少的中文字体大小在20~40 MB 之间,能够预想到加载时间会进一步增加。若是用户还处于弱网环境下,这个等待时间是不能接受的。github

1、中文字体体积太大致使加载时间过长

1. 分析缘由

那么中文字体相较于英文字体体积为何这么大,这主要是两个方面的缘由:web

  1. 中文字体包含的字形数量不少,而英文字体仅包含26个字母以及一些其余符号。
  2. 中文字形的线条远比英文字形的线条复杂,用于控制中文字形线条的位置点比英文字形更多,所以数据量更大。

咱们能够借助于 opentype.js,统计一个中文字体和一个英文字体在字形数量以及字形所占字节数的差别:数组

字体名称 字形数 字形所占字节数
FZQingFSJW_Cu.ttf 8731 4762272
JDZhengHT-Bold.ttf 122 18328

夸克平台字体预览须要知足两种方式,一种是固定字符预览, 另外一种是根据用户输入的字符进行预览。但不管哪一种预览方式,也仅仅会使用到该字体的少许字符,所以全量加载字体是没有必要的,因此咱们须要对字体文件作精简。promise

2. 如何减少字体文件体积

unicode-range

unicode-range 属性通常配合 @font-face 规则使用,它用于控制特定字符使用特定字体。可是它并不能减少字体文件的大小,感兴趣的读者能够试试。

fontmin

fontmin 是一个纯 JavaScript 实现的字体子集化方案。前文谈到,中文字体体积相较于英文字体更大的缘由是其字形数量更多,那么精简一个字体文件的思路就是将无用的字形移除:

// 伪代码
const text = '字体预览'
const unicodes = text.split('').map(str => str.charCodeAt(0))
const font = loadFont(fontPath)
font.glyf = font.glyf.map(g => {
 // 根据unicodes获取对应的字形
})
实际上的精简并无这么简单,由于一个字体文件由许多 表(table)构成,这些表之间是存在关联的,例如 maxp 表记录了字形数量, loca 表中存储了字形位置的偏移量。同时字体文件以 offset table(偏移表) 开头, offset table记录了字体全部表的信息,所以若是咱们更改了 glyf 表,就要同时去更新其余表。

在讨论 fontmin 如何进行字体截取以前,咱们先来了解一下字体文件的结构:

上面的结构限于字体文件只包含一种字体,且字形轮廓是基于 TrueType 格式(决定 sfntVersion 的取值)的状况,所以偏移表会从字体文件的0字节开始。若是字体文件包含多个字体,则每种字体的偏移表会在 TTCHeader 中指定,这种文件不在文章的讨论范围内。

偏移表(offset table):

Type Name Description
uint32 sfntVersion 0x00010000
uint16 numTables Number of tables
uint16 searchRange (Maximum power of 2 <= numTables) x 16.
uint16 entrySelector Log2(maximum power of 2 <= numTables).
uint16 rangeShift NumTables x 16-searchRange.

表记录(table record):

Type Name Description
uint32 tableTag Table identifier
uint32 checkSum CheckSum for this table
uint32 offset Offset from beginning of TrueType font file
uint32 length Length of this table

对于一个字体文件,不管其字形轮廓是 TrueType 格式仍是基于 PostScript 语言的 CFF 格式,其必须包含的表有 cmapheadhheahtmxmaxpnameOS/2post。若是其字形轮廓是 TrueType 格式,还有cvtfpgmglyflocaprepgasp 六张表会被用到。这六张表除了 glyfloca 必选外,其它四个为可选表。

fontmin 截取字形原理

fontmin 内部使用了 fonteditor-core,核心的字体处理交给这个依赖完成,fonteditor-core 的主要流程以下:

1. 初始化 Reader

将字体文件转为 ArrayBuffer 用于后续读取数据。

2. 提取 Table Directory

前文咱们说到紧跟在 offset table(偏移表) 以后的结构就是 table record(表记录),而多个 table record 叫作 Table Directoryfonteditor-core 会先读取原字体的 Table Directory,由上文表记录的结构咱们知道,每个 table record 有四个字段,每一个字段占4个字节,所以能够很方便的利用 DataView 进行读取,最终获得一个字体文件的全部表信息以下:

3. 读取表数据

在这一步会根据 Table Directory 记录的偏移和长度信息读取表数据。对于精简字体来讲,glyf 表的内容是最重要的,可是 glyftable record 仅仅告诉了咱们 glyf 表的长度以及 glyf 表相对于整个字体文件的偏移量,那么咱们如何得知 glyf 表中字形的数量、位置以及大小信息呢?这须要借助字体中的 maxp 表和 loca(glyphs location) 表,maxp 表的 numGlyphs 字段值指定了字形数量,而 loca 表记录了字体中全部字形相对于 glyf 表的偏移量,它的结构以下:

Glyph Index Offset Glyph Length
0 0 100
1 100 150
2 250 0
... ... ...
n-1 1170 120
extra 1290 0

根据规范,索引0指向缺失字符(missing character),也就是字体中找不到某个字符时出现的字符,这个字符一般用空白框或者空格表示,当这个缺失字符不存在轮廓时,根据 loca 表的定义能够获得 loca[n] = loca[n+1]。咱们能够发现上文表格中多出了 extra 一项,这是为了计算最后一个字形 loca[n-1] 的长度。

上述表格中 Offset 字段值的单位是字节,可是具体的字节数取决于字体 head 表的 indexToLocFormat 字段取值,当此值为 0时,Offset 100 等于 200 个字节,当此值为 1时,Offset 100 等于 100 个字节,这两种不一样的状况对应于字体中的 Short versionLong version

可是仅仅知道全部字形的偏移量还不够,咱们没办法认出哪一个字形才是咱们须要的。假设我须要字体预览这四个字形,而字体文件有一万个字形,同时咱们经过 loca 表得知了全部字形的偏移量,但这一万里面哪四个数据块表明了字体预览四个字符呢?所以咱们还须要借助 cmap 表来肯定具体的字形位置,cmap 表里记录了字符代码(unicode)到字形索引的映射,咱们拿到对应的字形索引后,就能够根据索引得到该字形在 glyf 表中的偏移量。

而一个字形的数据结构以 Glyph Headers 开头:

Type Name Description
int16 numberOfContours the number of contours
int16 xMin Minimum x for coordinate data
int16 yMin Maximum y for coordinate data
int16 xMax Minimum x for coordinate data
int16 yMax Maximum x for coordinate data

numberOfContours 字段指定了这个字形的轮廓数量,紧跟在 Glyph Headers 后面的数据结构为 Glyph Table

在字体的定义中,轮廓是由一个个位置点构成的,而且每一个位置点具备编号,这些编号从0开始按升序排列。所以咱们读取指定的字形就是读取 Glyph Headers 中的各项值以及轮廓的位置点坐标。

Glyph Table 中,存放了每一个轮廓的最后一个位置点编号构成的数组,从这个数组中就能够求得这个字形一共存在几个位置点。例如这个数组的值为[3, 6, 9, 15],能够得知第四个轮廓上最后一个位置点的编号是15,那么这个字形一共有16个位置点,因此咱们只须要以16为循环次数进行遍历访问 ArrayBuffer 就能够获得每一个位置点的坐标信息,从而提取出了咱们想要的字形,这也就是 fontmin 在截取字形时的原理。

另外,在提取坐标信息时,除了第一个位置点,其余位置点的坐标值并非绝对值,例如第一个点的坐标为[100, 100],第二个读取到的值为[200, 200],那么该点位置坐标并非[200, 200],而是基于第一个点的坐标进行增量,所以第二点的实际坐标为[300, 300]

由于一个字体涉及的表实在太多,而且每一个表的数据结构也不同。这里没法一一列举 fonteditor-core 是如何处理每一个表的。
4. 关联glyf信息

在使用了 TrueType 轮廓的字体中,每一个字形都提供了 xMinxMaxyMinyMax 的值,这四个值也就是下图的Bounding Box。除了这四个值,还须要 advanceWidthleftSideBearing 两个字段,这两个字段并不在 glyf 表中,所以在截取字形信息的时候没法获取。在这个步骤,fonteditor-core 会读取字体的 hmtx 表获取这两个字段。

5. 写入字体

在这一步会从新计算字体文件的大小,而且更新偏移表(Offset table)表记录(Table record)有关的值, 而后依次将偏移表表记录表数据写入文件中。有一点须要注意的是,在写入表记录时,必须按照表名排序进行写入。例若有四张表分别是 prephmtxglyfhead、则写入的顺序应为 glyf -> head -> hmtx -> prep,而表数据没有这个要求。

fontmin 不足之处

fonteditor-core 在截取字体的过程当中只会对前文提到的十四张表进行处理,其他表丢弃。每一个字体一般还会包含 vheavmtx 两张表,它们用于控制字体在垂直布局时的间距等信息,若是用 fontmin 进行字体截取后,会丢失这部分信息,能够在文本垂直显示时看出差别(右边为截取后):

fontmin 使用方法

在了解了 fontmin 的原理后,咱们就能够愉快的使用它啦。服务器接受到客户端发来的请求后,经过 fontmin 截取字体,fontmin 会返回截取后的字体文件对应的 Buffer,别忘了 @font-face 规则中字体路径是支持 base64 格式的,所以咱们只须要将 Buffer 转为 base64 格式嵌入在 @font-face 中返回给客户端,而后客户端将该 @font-face 以 CSS 形式插入 <head></head> 标签中便可。

对于固定的预览内容,咱们也能够先生成字体文件保存在 CDN 上,可是这个方式的缺点在于若是 CDN 不稳定就会形成字体加载失败。若是用上面的方法,每个截取后的字体以 base64 字符串形式存在,则能够在服务端作一个缓存,就没有这个问题。利用 fontmin 生成字体子集代码以下:

const Fontmin = require('fontmin')
const Promise = require('bluebird')

async function extractFontData (fontPath) {
  const fontmin = new Fontmin()
    .src('./font/senty.ttf')
    .use(Fontmin.glyph({
      text: '字体预览'
    }))
    .use(Fontmin.ttf2woff2())
    .dest('./dist')

  await Promise.promisify(fontmin.run, { context: fontmin })()
}
extractFontData()

对于固定预览内容咱们能够预先生成好分割后的字体,对于用户输入的动态预览内容,咱们固然也能够按照这个流程:

获取输入 -> 截取字形 -> 上传 CDN -> 生成 @font-face -> 插入页面

按照这个流程来客户端须要请求两次才能获取字体资源(别忘了在 @font-face 插入页面后才会去真正请求字体),而且截取字形上传 CDN 这两步时间消耗也比较长,有没有更好的办法呢?咱们知道字形的轮廓是由一系列位置点肯定的,所以咱们能够获取 glyf 表中的位置点坐标,经过 SVG 图像将特定字形直接绘制出来。

SVG 是一种强大的图像格式,可使用 CSSJavaScript 与它们进行交互,在这里主要应用了 path 元素

获取位置信息以及生成 path 标签咱们能够借助 opentype.js 完成,客户端获得输入字形的 path 元素后,只须要遍历生成 SVG 标签便可。

3. 减少字体文件体积的优点

下面附上字体截取后文件大小和加载速度对比表格。能够看出,相较于全量加载,对字体进行截取后加载速度快了145 倍。

fontmin 是支持生成 woff2 文件的,可是官方文档并无更新,最开始我使用的 woff 文件,可是 woff2 格式文件体积更小而且浏览器支持不错
字体名称 大小 时间
HanyiSentyWoodcut.ttf 48.2MB 17.41s
HanyiSentyWoodcut.woff 21.7KB 0.19s
HanyiSentyWoodcut.woff2 12.2KB 0.12s

2、字体加载完成前不展现预览内容

这是在实现预览功能过程当中的第二个问题。

在浏览器的字体显示行为中存在阻塞期交换期两个概念,以 Chrome 为例,在字体加载完成前,会有一段时间显示空白,这段时间被称为阻塞期。若是在阻塞期内仍然没有加载完成,就会先显示后备字体,进入交换期,等待字体加载完成后替换。这就会致使页面字体出现闪烁,与我想要的效果不符。而 font-display 属性控制浏览器的这个行为,是否能够更换 font-display 属性的取值来达到咱们的目的呢?

font-display

Block Period Swap Period
block Short Infinite
swap None Infinite
fallback Extremely Short Short
optional Extremely Short None

字体的显示策略和 font-display 的取值有关,浏览器默认的 font-display 值为 auto,它的行为和取值 block 较为接近。

第一种策略是 FOIT(Flash of Invisible Text)FOIT 是浏览器在加载字体的时候的默认表现形式,其规则如前文所说。

第二种策略是 FOUT(Flash of Unstyled Text)FOUT 会指示浏览器使用后备字体直至自定义字体加载完成,对应的取值为 swap

两种不一样策略的应用:Google Fonts FOIT汉仪字库 FOUT

在夸克项目中,我但愿的效果是字体加载完成前不展现预览内容,FOIT 策略最为接近。可是 FOIT 文本内容不可见的最长时间大约是3s, 若是用户网络情况不太好,那么3s事后仍是会先显示后备字体,致使页面字体闪烁,所以 font-display 属性不知足要求。

查阅资料得知,CSS Font Loading APIJavaScript 层面上也提供了解决方案:

FontFace、FontFaceSet

先看看它们的兼容性:

又是 IE,IE 没有用户不用管

咱们能够经过 FontFace 构造函数构造出一个 FontFace 对象:

const fontFace = new FontFace(family, source, descriptors)

  • family

    • 字体名称,指定一个名称做为 CSS 属性 font-family 的值,
  • source

    • 字体来源,能够是一个 url 或者 ArrayBuffer
  • descriptors optional

    • style:font-style
    • weight:font-weight
    • stretch:font-stretch
    • display: font-display (这个值能够设置,但不会生效)
    • unicodeRange:@font-face 规则的 unicode-ranges
    • variant:font-variant
    • featureSettings:font-feature-settings

构造出一个 fontFace 后并不会加载字体,必须执行 fontFaceload 方法。load 方法返回一个 promisepromiseresolve 值就是加载成功后的字体。可是仅仅加载成功还不会使这个字体生效,还须要将返回的 fontFace 添加到 fontFaceSet

使用方法以下:

/**
  * @param {string} path 字体文件路径
  */
async function loadFont(path) {
  const fontFaceSet = document.fonts
  const fontFace = await new FontFace('fontFamily', `url('${path}') format('woff2')`).load()
  fontFaceSet.add(fontFace)
}

所以,在客户端咱们能够先设置文字内容的 CSS 为 opacity: 0
等待 await loadFont(path) 执行完毕后,再将 CSS 设置为 opacity: 1, 这样就能够控制在自定义字体加载未完成前不显示内容。

最后总结

本文介绍了在开发字体预览功能时遇到的问题和解决方案,限于 OpenType 规范条目不少,在介绍 fontmin 原理部分,仅描述了对 glyf 表的处理,对此感兴趣的读者可进一步学习。

本次工做的回顾和总结过程当中,也在思考更好的实现,若是你有建议欢迎和我交流。同时文章的内容是我我的的理解,存在错误难以免,若是发现错误欢迎指正。

感谢阅读!

参考

相关文章
相关标签/搜索