<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          【W(wǎng)eb技術(shù)】772- Web 中文字體性能優(yōu)化實(shí)踐

          共 6399字,需瀏覽 13分鐘

           ·

          2020-11-15 13:11

          背景介紹

          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里定義的名字 */}

          由于 woff2woffttf 格式在大多數(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è)方面的原因:

          1. 中文字體包含的字形數(shù)量很多,而英文字體僅包含26個(gè)字母以及一些其他符號(hào)。
          2. 中文字形的線條遠(yuǎn)比英文字形的線條復(fù)雜,用于控制中文字形線條的位置點(diǎn)比英文字形更多,因此數(shù)據(jù)量更大。

          我們可以借助于 opentype.js,統(tǒng)計(jì)一個(gè)中文字體和一個(gè)英文字體在字形數(shù)量以及字形所占字節(jié)數(shù)的差異:

          字體名稱字形數(shù)字形所占字節(jié)數(shù)
          FZQingFSJW_Cu.ttf87314762272
          JDZhengHT-Bold.ttf12218328

          夸克平臺(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):

          TypeNameDescription
          uint32sfntVersion0x00010000
          uint16numTablesNumber of tables
          uint16searchRange(Maximum power of 2 <= numTables) x 16.
          uint16entrySelectorLog2(maximum power of 2 <= numTables).
          uint16rangeShiftNumTables x 16-searchRange.

          表記錄(table record):

          TypeNameDescription
          uint32tableTagTable identifier
          uint32checkSumCheckSum for this table
          uint32offsetOffset from beginning of TrueType font file
          uint32lengthLength of this table

          對(duì)于一個(gè)字體文件,無論其字形輪廓是 TrueType 格式還是基于 PostScript 語言的 CFF 格式,其必須包含的表有 cmapheadhheahtmxmaxpnameOS/2post。如果其字形輪廓是 TrueType 格式,還有cvtfpgmglyflocaprepgasp 六張表會(huì)被用到。這六張表除了 glyfloca 必選外,其它四個(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 Directoryfonteditor-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)容是最重要的,但是 glyftable 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 IndexOffsetGlyph Length
          00100
          1100150
          22500
          .........
          n-11170120
          extra12900

          根據(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 versionLong 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 開頭:

          TypeNameDescription
          int16numberOfContoursthe number of contours
          int16xMinMinimum x for coordinate data
          int16yMinMaximum y for coordinate data
          int16xMaxMinimum x for coordinate data
          int16yMaxMaximum 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è)字形都提供了 xMinxMaxyMinyMax 的值,這四個(gè)值也就是下圖的Bounding Box。除了這四個(gè)值,還需要 advanceWidthleftSideBearing 兩個(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)行寫入。例如有四張表分別是 prephmtxglyfhead、則寫入的順序應(yīng)為 glyf -> head -> hmtx -> prep,而表數(shù)據(jù)沒有這個(gè)要求。

          fontmin 不足之處

          fonteditor-core 在截取字體的過程中只會(huì)對(duì)前文提到的十四張表進(jìn)行處理,其余表丟棄。每個(gè)字體通常還會(huì)包含 vheavmtx 兩張表,它們用于控制字體在垂直布局時(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)大的圖像格式,可以使用 CSSJavaScript 與它們進(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.ttf48.2MB17.41s
          HanyiSentyWoodcut.woff21.7KB0.19s
          HanyiSentyWoodcut.woff212.2KB0.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 PeriodSwap Period
          blockShortInfinite
          swapNoneInfinite
          fallbackExtremely ShortShort
          optionalExtremely ShortNone

          字體的顯示策略和 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 或者 ArrayBuffer

          • descriptors optional

          • style:font-style

          • weight:font-weight

          • stretch:font-stretch

          • display: font-display (這個(gè)值可以設(shè)置,但不會(huì)生效)

          • unicodeRange:@font-face 規(guī)則的 unicode-ranges

          • variant:font-variant

          • featureSettings:font-feature-settings

          構(gòu)造出一個(gè) fontFace 后并不會(huì)加載字體,必須執(zhí)行 fontFaceload 方法。load 方法返回一個(gè) promisepromiseresolve 值就是加載成功后的字體。但是僅僅加載成功還不會(huì)使這個(gè)字體生效,還需要將返回的 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)}

          因此,在客戶端我們可以先設(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

          1. JavaScript 重溫系列(22篇全)
          2. ECMAScript 重溫系列(10篇全)
          3. JavaScript設(shè)計(jì)模式 重溫系列(9篇全)
          4.?正則 / 框架 / 算法等 重溫系列(16篇全)
          5.?Webpack4 入門(上)||?Webpack4 入門(下)
          6.?MobX 入門(上)?||??MobX 入門(下)
          7. 80+篇原創(chuàng)系列匯總

          回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~

          點(diǎn)擊“閱讀原文”查看 80+ 篇原創(chuàng)文章

          瀏覽 26
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  尤物操逼| 超碰97人人操 | 插逼网站 | 丰满人妻一区二区三区色按摩 | 91精品综合久久久久久五月丁香 |