效能優化魔法師:中文字型實踐篇

凹凸實驗室發表於2020-02-27

背景介紹

Web 專案中,使用一個適合的字型能給使用者帶來良好的體驗。但是字型檔案這麼多,如果設計師或者開發人員想要查詢字型,只能一個個開啟,非常影響工作效率。因此,我負責的平臺專案剛好需要實現一個功能,能夠支援根據固定文字以及使用者輸入預覽字型。在實現這一功能的過程中主要解決兩個問題:

  • 中文字型體積太大導致載入時間過長
  • 字型載入完成前不展示預覽內容

現在將問題的解決以及我的思考總結成文。

效能優化魔法師:中文字型實踐篇

使用 web 自定義字型

在聊這兩個問題之前,我們先簡述怎樣使用一個 Web 自定義字型。要想使用一個自定義字型,可以依賴 CSS Fonts Module Level 3 定義的 @font-face 規則。一種基本能夠相容所有瀏覽器的使用方法如下:

@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 文字上,就可以實現字型的預覽效果。

但這麼做我們可以明顯發現一個問題,字型體積太大導致的載入時間過長。我們開啟瀏覽器的 Network 皮膚檢視:

效能優化魔法師:中文字型實踐篇

可以看到字型的體積為5.5 MB,載入時間為5.13 s。而夸克平臺很多的中文字型大小在20~40 MB 之間,可以預想到載入時間會進一步增長。如果使用者還處於弱網環境下,這個等待時間是不能接受的。

一、中文字型體積太大導致載入時間過長

1. 分析原因

那麼中文字型相較於英文字型體積為什麼這麼大,這主要是兩個方面的原因:

  1. 中文字型包含的字形數量很多,而英文字型僅包含26個字母以及一些其他符號。
  2. 中文字形的線條遠比英文字形的線條複雜,用於控制中文字形線條的位置點比英文字形更多,因此資料量更大。

我們可以藉助於 opentype.js,統計一箇中文字型和一個英文字型在字形數量以及字形所佔位元組數的差異:

字型名稱 字形數 字形所佔位元組數
FZQingFSJW_Cu.ttf 8731 4762272
JDZhengHT-Bold.ttf 122 18328

夸克平臺字型預覽需要滿足兩種方式,一種是固定字元預覽, 另一種是根據使用者輸入的字元進行預覽。但無論哪種預覽方式,也僅僅會使用到該字型的少量字元,因此全量載入字型是沒有必要的,所以我們需要對字型檔案做精簡。

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

二、字型載入完成前不展示預覽內容

這是在實現預覽功能過程中的第二個問題。

在瀏覽器的字型顯示行為中存在阻塞期交換期兩個概念,以 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 表的處理,對此感興趣的讀者可進一步學習。

本次工作的回顧和總結過程中,也在思考更好的實現,如果你有建議歡迎和我交流。同時文章的內容是我個人的理解,存在錯誤難以避免,如果發現錯誤歡迎指正。

感謝閱讀!

參考

寫在最後

效能優化魔法師:中文字型實踐篇

JDC凹凸實驗室 春季校園招聘開始啦!

我們是一支熱愛創造、不斷嘗試新技術、新體驗、新產品的團隊。

2020年實習招聘名額開放,快快加入吧!

  • 聯絡郵箱:aotu[AT]jd.com
  • 工作地點:深圳

基本要求

  • 2020年10月1日至2021年9月30日期間畢業,全日制本科及以上學歷;
  • 基礎紮實,邏輯性強,具備良好的溝通能力和團隊協作精神;
  • 熱愛計算機程式設計,關注新事物、新技術,有創造力,有較強的學習能力;
  • 對 Web 標準有一定的理解,對可用性、可訪問性等相關知識有所瞭解;
  • 瞭解如何使用 HTML、CSS、JavaScript 構建高效能的 Web 應用程式;
  • 瞭解主流前端框架(React / Vue / Angular 等),瞭解多端開發框架(Taro / uni-app / Chameleon / MpVue 等),追求良好的程式碼風格,對介面設計與程式架構有所瞭解;

優先考慮

  • 瞭解 Node.js / Java 服務端開發;
  • 瞭解微信小程式 / 移動端開發;
  • 有個人開源專案或技術部落格且更新頻率較高。

請註明來源[掘金]

相關文章