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 表的處理,對此感興趣的讀者可進一步學習。

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

感謝閱讀!

參考

相關文章
相關標籤/搜索