【W(wǎng)eb技術(shù)】772- Web 中文字體性能優(yōu)化實(shí)踐
背景介紹—
Web 項(xiàng)目中,使用一個(gè)適合的字體能給用戶帶來良好的體驗(yàn)。但是字體文件這么多,如果設(shè)計(jì)師或者開發(fā)人員想要查詢字體,只能一個(gè)個(gè)打開,非常影響工作效率。我負(fù)責(zé)的平臺(tái)項(xiàng)目剛好需要實(shí)現(xiàn)一個(gè)功能,能夠支持根據(jù)固定文字以及用戶輸入預(yù)覽字體。在實(shí)現(xiàn)這一功能的過程中主要解決兩個(gè)問題:
中文字體體積太大導(dǎo)致加載時(shí)間過長(zhǎng) 字體加載完成前不展示預(yù)覽內(nèi)容
現(xiàn)在將問題的解決以及我的思考總結(jié)成文。

使用 web 自定義字體—
在聊這兩個(gè)問題之前,我們先簡(jiǎn)述怎樣使用一個(gè) Web 自定義字體。要想使用一個(gè)自定義字體,可以依賴 CSS Fonts Module Level 3 定義的 @font-face 規(guī)則。一種基本能夠兼容所有瀏覽器的使用方法如下:
@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里定義的名字 */}
由于 woff2、woff、ttf 格式在大多數(shù)瀏覽器支持已經(jīng)較好,因此上面的代碼也可以寫成:
@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 規(guī)則,我們只需要將字體源文件上傳至 cdn,讓 @font-face 規(guī)則的 url 值為該字體的地址,最后將這個(gè)規(guī)則應(yīng)用在 Web 文字上,就可以實(shí)現(xiàn)字體的預(yù)覽效果。
但這么做我們可以明顯發(fā)現(xiàn)一個(gè)問題,字體體積太大導(dǎo)致的加載時(shí)間過長(zhǎng)。我們打開瀏覽器的 Network 面板查看:

可以看到字體的體積為5.5 MB,加載時(shí)間為5.13 s。而夸克平臺(tái)很多的中文字體大小在20~40 MB 之間,可以預(yù)想到加載時(shí)間會(huì)進(jìn)一步增長(zhǎng)。如果用戶還處于弱網(wǎng)環(huán)境下,這個(gè)等待時(shí)間是不能接受的。
中文字體體積太大導(dǎo)致加載時(shí)間過長(zhǎng)—
分析原因
那么中文字體相較于英文字體體積為什么這么大,這主要是兩個(gè)方面的原因:
中文字體包含的字形數(shù)量很多,而英文字體僅包含26個(gè)字母以及一些其他符號(hào)。 中文字形的線條遠(yuǎn)比英文字形的線條復(fù)雜,用于控制中文字形線條的位置點(diǎn)比英文字形更多,因此數(shù)據(jù)量更大。
我們可以借助于 opentype.js,統(tǒng)計(jì)一個(gè)中文字體和一個(gè)英文字體在字形數(shù)量以及字形所占字節(jié)數(shù)的差異:
| 字體名稱 | 字形數(shù) | 字形所占字節(jié)數(shù) |
|---|---|---|
| FZQingFSJW_Cu.ttf | 8731 | 4762272 |
| JDZhengHT-Bold.ttf | 122 | 18328 |
夸克平臺(tái)字體預(yù)覽需要滿足兩種方式,一種是固定字符預(yù)覽, 另一種是根據(jù)用戶輸入的字符進(jìn)行預(yù)覽。但無論哪種預(yù)覽方式,也僅僅會(huì)使用到該字體的少量字符,因此全量加載字體是沒有必要的,所以我們需要對(duì)字體文件做精簡(jiǎn)。
如何減小字體文件體積
unicode-range
unicode-range 屬性一般配合 @font-face 規(guī)則使用,它用于控制特定字符使用特定字體。但是它并不能減小字體文件的大小,感興趣的讀者可以試試。
CSS unicode-range特定字符使用font-face自定義字體
fontmin
fontmin 是一個(gè)純 JavaScript 實(shí)現(xiàn)的字體子集化方案。前文談到,中文字體體積相較于英文字體更大的原因是其字形數(shù)量更多,那么精簡(jiǎn)一個(gè)字體文件的思路就是將無用的字形移除:
// 偽代碼const text = '字體預(yù)覽'const unicodes = text.split('').map(str => str.charCodeAt(0))const font = loadFont(fontPath)font.glyf = font.glyf.map(g => {// 根據(jù)unicodes獲取對(duì)應(yīng)的字形})
實(shí)際上的精簡(jiǎn)并沒有這么簡(jiǎn)單,因?yàn)橐粋€(gè)字體文件由許多
表(table)構(gòu)成,這些表之間是存在關(guān)聯(lián)的,例如maxp表記錄了字形數(shù)量,loca表中存儲(chǔ)了字形位置的偏移量。同時(shí)字體文件以offset table(偏移表)開頭,offset table記錄了字體所有表的信息,因此如果我們更改了glyf表,就要同時(shí)去更新其他表。
在討論 fontmin 如何進(jìn)行字體截取之前,我們先來了解一下字體文件的結(jié)構(gòu):

上面的結(jié)構(gòu)限于字體文件只包含一種字體,且字形輪廓是基于 TrueType 格式(決定 sfntVersion 的取值)的情況,因此偏移表會(huì)從字體文件的0字節(jié)開始。如果字體文件包含多個(gè)字體,則每種字體的偏移表會(huì)在 TTCHeader 中指定,這種文件不在文章的討論范圍內(nèi)。
偏移表(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 |
對(duì)于一個(gè)字體文件,無論其字形輪廓是 TrueType 格式還是基于 PostScript 語言的 CFF 格式,其必須包含的表有 cmap、head、hhea、htmx、maxp、name、OS/2、post。如果其字形輪廓是 TrueType 格式,還有cvt、fpgm、glyf、loca、prep、gasp 六張表會(huì)被用到。這六張表除了 glyf 和 loca 必選外,其它四個(gè)為可選表。
fontmin 截取字形原理
fontmin 內(nèi)部使用了 fonteditor-core,核心的字體處理交給這個(gè)依賴完成,fonteditor-core 的主要流程如下:

1. 初始化 Reader
將字體文件轉(zhuǎn)為 ArrayBuffer 用于后續(xù)讀取數(shù)據(jù)。
2. 提取 Table Directory
前文我們說到緊跟在 offset table(偏移表) 之后的結(jié)構(gòu)就是 table record(表記錄),而多個(gè) table record 叫做 Table Directory。fonteditor-core 會(huì)先讀取原字體的 Table Directory,由上文表記錄的結(jié)構(gòu)我們知道,每一個(gè) table record 有四個(gè)字段,每個(gè)字段占4個(gè)字節(jié),因此可以很方便的利用 DataView 進(jìn)行讀取,最終得到一個(gè)字體文件的所有表信息如下:

3. 讀取表數(shù)據(jù)
在這一步會(huì)根據(jù) Table Directory 記錄的偏移和長(zhǎng)度信息讀取表數(shù)據(jù)。對(duì)于精簡(jiǎn)字體來說,glyf 表的內(nèi)容是最重要的,但是 glyf 的 table record 僅僅告訴了我們 glyf 表的長(zhǎng)度以及 glyf 表相對(duì)于整個(gè)字體文件的偏移量,那么我們?nèi)绾蔚弥?glyf 表中字形的數(shù)量、位置以及大小信息呢?這需要借助字體中的 maxp 表和 loca(glyphs location) 表,maxp 表的 numGlyphs 字段值指定了字形數(shù)量,而 loca 表記錄了字體中所有字形相對(duì)于 glyf 表的偏移量,它的結(jié)構(gòu)如下:
| Glyph Index | Offset | Glyph Length |
|---|---|---|
| 0 | 0 | 100 |
| 1 | 100 | 150 |
| 2 | 250 | 0 |
| ... | ... | ... |
| n-1 | 1170 | 120 |
| extra | 1290 | 0 |
根據(jù)規(guī)范,索引0指向缺失字符(missing character),也就是字體中找不到某個(gè)字符時(shí)出現(xiàn)的字符,這個(gè)字符通常用空白框或者空格表示,當(dāng)這個(gè)缺失字符不存在輪廓時(shí),根據(jù) loca 表的定義可以得到 loca[n] = loca[n+1]。我們可以發(fā)現(xiàn)上文表格中多出了 extra 一項(xiàng),這是為了計(jì)算最后一個(gè)字形 loca[n-1] 的長(zhǎng)度。
上述表格中 Offset 字段值的單位是字節(jié),但是具體的字節(jié)數(shù)取決于字體
head表的indexToLocFormat字段取值,當(dāng)此值為0時(shí),Offset 100 等于 200 個(gè)字節(jié),當(dāng)此值為1時(shí),Offset 100 等于 100 個(gè)字節(jié),這兩種不同的情況對(duì)應(yīng)于字體中的Short version和Long version。
但是僅僅知道所有字形的偏移量還不夠,我們沒辦法認(rèn)出哪個(gè)字形才是我們需要的。假設(shè)我需要字體預(yù)覽這四個(gè)字形,而字體文件有一萬個(gè)字形,同時(shí)我們通過 loca 表得知了所有字形的偏移量,但這一萬里面哪四個(gè)數(shù)據(jù)塊代表了字體預(yù)覽四個(gè)字符呢?因此我們還需要借助 cmap 表來確定具體的字形位置,cmap 表里記錄了字符代碼(unicode)到字形索引的映射,我們拿到對(duì)應(yīng)的字形索引后,就可以根據(jù)索引獲得該字形在 glyf 表中的偏移量。

而一個(gè)字形的數(shù)據(jù)結(jié)構(gòu)以 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 字段指定了這個(gè)字形的輪廓數(shù)量,緊跟在 Glyph Headers 后面的數(shù)據(jù)結(jié)構(gòu)為 Glyph Table。
在字體的定義中,輪廓是由一個(gè)個(gè)位置點(diǎn)構(gòu)成的,并且每個(gè)位置點(diǎn)具有編號(hào),這些編號(hào)從0開始按升序排列。因此我們讀取指定的字形就是讀取 Glyph Headers 中的各項(xiàng)值以及輪廓的位置點(diǎn)坐標(biāo)。
在 Glyph Table 中,存放了每個(gè)輪廓的最后一個(gè)位置點(diǎn)編號(hào)構(gòu)成的數(shù)組,從這個(gè)數(shù)組中就可以求得這個(gè)字形一共存在幾個(gè)位置點(diǎn)。例如這個(gè)數(shù)組的值為[3, 6, 9, 15],可以得知第四個(gè)輪廓上最后一個(gè)位置點(diǎn)的編號(hào)是15,那么這個(gè)字形一共有16個(gè)位置點(diǎn),所以我們只需要以16為循環(huán)次數(shù)進(jìn)行遍歷訪問 ArrayBuffer 就可以得到每個(gè)位置點(diǎn)的坐標(biāo)信息,從而提取出了我們想要的字形,這也就是 fontmin 在截取字形時(shí)的原理。
另外,在提取坐標(biāo)信息時(shí),除了第一個(gè)位置點(diǎn),其他位置點(diǎn)的坐標(biāo)值并不是絕對(duì)值,例如第一個(gè)點(diǎn)的坐標(biāo)為[100, 100],第二個(gè)讀取到的值為[200, 200],那么該點(diǎn)位置坐標(biāo)并不是[200, 200],而是基于第一個(gè)點(diǎn)的坐標(biāo)進(jìn)行增量,因此第二點(diǎn)的實(shí)際坐標(biāo)為[300, 300]
因?yàn)橐粋€(gè)字體涉及的表實(shí)在太多,并且每個(gè)表的數(shù)據(jù)結(jié)構(gòu)也不一樣。這里無法一一列舉
fonteditor-core是如何處理每個(gè)表的。
4. 關(guān)聯(lián)glyf信息
在使用了 TrueType 輪廓的字體中,每個(gè)字形都提供了 xMin、xMax、yMin 和 yMax 的值,這四個(gè)值也就是下圖的Bounding Box。除了這四個(gè)值,還需要 advanceWidth 和 leftSideBearing 兩個(gè)字段,這兩個(gè)字段并不在 glyf 表中,因此在截取字形信息的時(shí)候無法獲取。在這個(gè)步驟,fonteditor-core 會(huì)讀取字體的 hmtx 表獲取這兩個(gè)字段。

5. 寫入字體
在這一步會(huì)重新計(jì)算字體文件的大小,并且更新偏移表(Offset table)和表記錄(Table record)有關(guān)的值, 然后依次將偏移表、表記錄、表數(shù)據(jù)寫入文件中。有一點(diǎn)需要注意的是,在寫入表記錄時(shí),必須按照表名排序進(jìn)行寫入。例如有四張表分別是 prep、hmtx、glyf、head、則寫入的順序應(yīng)為 glyf -> head -> hmtx -> prep,而表數(shù)據(jù)沒有這個(gè)要求。
fontmin 不足之處
fonteditor-core 在截取字體的過程中只會(huì)對(duì)前文提到的十四張表進(jìn)行處理,其余表丟棄。每個(gè)字體通常還會(huì)包含 vhea 和 vmtx 兩張表,它們用于控制字體在垂直布局時(shí)的間距等信息,如果用 fontmin 進(jìn)行字體截取后,會(huì)丟失這部分信息,可以在文本垂直顯示時(shí)看出差異(右邊為截取后):

fontmin 使用方法
在了解了 fontmin 的原理后,我們就可以愉快的使用它啦。服務(wù)器接受到客戶端發(fā)來的請(qǐng)求后,通過 fontmin 截取字體,fontmin 會(huì)返回截取后的字體文件對(duì)應(yīng)的 Buffer,別忘了 @font-face 規(guī)則中字體路徑是支持 base64 格式的,因此我們只需要將 Buffer 轉(zhuǎn)為 base64 格式嵌入在 @font-face 中返回給客戶端,然后客戶端將該 @font-face 以 CSS 形式插入 標(biāo)簽中即可。
對(duì)于固定的預(yù)覽內(nèi)容,我們也可以先生成字體文件保存在 CDN 上,但是這個(gè)方式的缺點(diǎn)在于如果 CDN 不穩(wěn)定就會(huì)造成字體加載失敗。如果用上面的方法,每一個(gè)截取后的字體以 base64 字符串形式存在,則可以在服務(wù)端做一個(gè)緩存,就沒有這個(gè)問題。利用 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: '字體預(yù)覽'})).use(Fontmin.ttf2woff2()).dest('./dist')await Promise.promisify(fontmin.run, { context: fontmin })()}extractFontData()
對(duì)于固定預(yù)覽內(nèi)容我們可以預(yù)先生成好分割后的字體,對(duì)于用戶輸入的動(dòng)態(tài)預(yù)覽內(nèi)容,我們當(dāng)然也可以按照這個(gè)流程:
獲取輸入 -> 截取字形 -> 上傳 CDN -> 生成 @font-face -> 插入頁面
按照這個(gè)流程來客戶端需要請(qǐng)求兩次才能獲取字體資源(別忘了在 @font-face 插入頁面后才會(huì)去真正請(qǐng)求字體),并且截取字形和上傳 CDN 這兩步時(shí)間消耗也比較長(zhǎng),有沒有更好的辦法呢?我們知道字形的輪廓是由一系列位置點(diǎn)確定的,因此我們可以獲取 glyf 表中的位置點(diǎn)坐標(biāo),通過 SVG 圖像將特定字形直接繪制出來。
SVG是一種強(qiáng)大的圖像格式,可以使用CSS和JavaScript與它們進(jìn)行交互,在這里主要應(yīng)用了path元素
獲取位置信息以及生成 path 標(biāo)簽我們可以借助 opentype.js 完成,客戶端得到輸入字形的 path 元素后,只需要遍歷生成 SVG 標(biāo)簽即可。
減小字體文件體積的優(yōu)勢(shì)
下面附上字體截取后文件大小和加載速度對(duì)比表格。可以看出,相較于全量加載,對(duì)字體進(jìn)行截取后加載速度快了145 倍。
fontmin是支持生成woff2文件的,但是官方文檔并沒有更新,最開始我使用的woff文件,但是woff2格式文件體積更小并且瀏覽器支持不錯(cuò)
| 字體名稱 | 大小 | 時(shí)間 |
|---|---|---|
| HanyiSentyWoodcut.ttf | 48.2MB | 17.41s |
| HanyiSentyWoodcut.woff | 21.7KB | 0.19s |
| HanyiSentyWoodcut.woff2 | 12.2KB | 0.12s |
字體加載完成前不展示預(yù)覽內(nèi)容—
這是在實(shí)現(xiàn)預(yù)覽功能過程中的第二個(gè)問題。
在瀏覽器的字體顯示行為中存在阻塞期和交換期兩個(gè)概念,以 Chrome 為例,在字體加載完成前,會(huì)有一段時(shí)間顯示空白,這段時(shí)間被稱為阻塞期。如果在阻塞期內(nèi)仍然沒有加載完成,就會(huì)先顯示后備字體,進(jìn)入交換期,等待字體加載完成后替換。這就會(huì)導(dǎo)致頁面字體出現(xiàn)閃爍,與我想要的效果不符。而 font-display 屬性控制瀏覽器的這個(gè)行為,是否可以更換 font-display 屬性的取值來達(dá)到我們的目的呢?
font-display
| Block Period | Swap Period | |
|---|---|---|
| block | Short | Infinite |
| swap | None | Infinite |
| fallback | Extremely Short | Short |
| optional | Extremely Short | None |
字體的顯示策略和 font-display 的取值有關(guān),瀏覽器默認(rèn)的 font-display 值為 auto,它的行為和取值 block 較為接近。
第一種策略是
FOIT(Flash of Invisible Text),FOIT是瀏覽器在加載字體的時(shí)候的默認(rèn)表現(xiàn)形式,其規(guī)則如前文所說。
第二種策略是
FOUT(Flash of Unstyled Text),FOUT會(huì)指示瀏覽器使用后備字體直至自定義字體加載完成,對(duì)應(yīng)的取值為swap。
兩種不同策略的應(yīng)用:Google Fonts FOIT?漢儀字庫 FOUT
在夸克項(xiàng)目中,我希望的效果是字體加載完成前不展示預(yù)覽內(nèi)容,FOIT 策略最為接近。但是 FOIT 文本內(nèi)容不可見的最長(zhǎng)時(shí)間大約是3s, 如果用戶網(wǎng)絡(luò)狀況不太好,那么3s過后還是會(huì)先顯示后備字體,導(dǎo)致頁面字體閃爍,因此 font-display 屬性不滿足要求。
查閱資料得知,CSS Font Loading API在 JavaScript 層面上也提供了解決方案:
FontFace、FontFaceSet
先看看它們的兼容性:


又是 IE,IE 沒有用戶不用管
我們可以通過 FontFace 構(gòu)造函數(shù)構(gòu)造出一個(gè) FontFace 對(duì)象:
const fontFace = new FontFace(family, source, descriptors)
family
字體名稱,指定一個(gè)名稱作為 CSS屬性font-family的值,source
字體來源,可以是一個(gè)
url或者ArrayBufferdescriptors
optionalstyle:
font-styleweight:
font-weightstretch:
font-stretchdisplay:
font-display(這個(gè)值可以設(shè)置,但不會(huì)生效)unicodeRange:
@font-face規(guī)則的unicode-rangesvariant:
font-variantfeatureSettings:
font-feature-settings
構(gòu)造出一個(gè) fontFace 后并不會(huì)加載字體,必須執(zhí)行 fontFace 的 load 方法。load 方法返回一個(gè) promise,promise 的 resolve 值就是加載成功后的字體。但是僅僅加載成功還不會(huì)使這個(gè)字體生效,還需要將返回的 fontFace 添加到 fontFaceSet。
使用方法如下:
/*** @param {string} path 字體文件路徑*/async function loadFont(path) {const fontFaceSet = document.fontsconst fontFace = await new FontFace('fontFamily', `url('${path}') format('woff2')`).load()fontFaceSet.add(fontFace)}
因此,在客戶端我們可以先設(shè)置文字內(nèi)容的 CSS 為 opacity: 0,
等待 await loadFont(path) 執(zhí)行完畢后,再將 CSS 設(shè)置為 opacity: 1, 這樣就可以控制在自定義字體加載未完成前不顯示內(nèi)容。
最后總結(jié)—
本文介紹了在開發(fā)字體預(yù)覽功能時(shí)遇到的問題和解決方案,限于 OpenType 規(guī)范條目很多,在介紹 fontmin 原理部分,僅描述了對(duì) glyf 表的處理,對(duì)此感興趣的讀者可進(jìn)一步學(xué)習(xí)。
本次工作的回顧和總結(jié)過程中,也在思考更好的實(shí)現(xiàn),如果你有建議歡迎和我交流。同時(shí)文章的內(nèi)容是我個(gè)人的理解,存在錯(cuò)誤難以避免,如果發(fā)現(xiàn)錯(cuò)誤歡迎指正。
感謝閱讀!
參考—
前端字體截取 Scalable Vector Graphics FontFace FontFaceSet fontmin fonteditor-core TrueType-Reference-Manual OpenType-Font-File

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點(diǎn)擊“閱讀原文”查看 80+ 篇原創(chuàng)文章
