[譯] Font-size:一個意外複雜的 CSS 屬性

ZephyrJS發表於2018-06-02

font-size 是糟糕的 CSS 屬性

這可能是每一個寫過 CSS 的人都知道的屬性。它隨處可見。

但它也十分的複雜。

“它不過是個數值”,你說,“它能有多複雜呢?”

我曾經也這麼認為,直到我開始致力於實現 stylo

Stylo 是一個將 Servo 的樣式系統整合到 Firefox 中的專案。這個樣式系統負責解析 CSS,確定哪些規則適用於哪些元素,通過級聯執行這些規則,最終計算並將樣式分配給樹中的各個元素。這不僅發生在頁面載入上,也發生在各種事件(包括 DOM 操作)觸發時,並且是頁面載入和互動時間的一個重要部分。

Servo 使用 Rust,並在許多地方用到了 Rust 的並行安全特性,樣式便是其中之一。Stylo 有潛力將這些加速技術帶入 Firefox,以及更安全的系統語言帶來的程式碼的安全性。

無論如何,就樣式系統而言,我認為字型大小是它必須處理的最複雜的屬性。當涉及到佈局或渲染時,有些屬性可能會更復雜,但 font-size 可能是樣式中最複雜的屬性。

我希望這篇文章能給出一個關於 web 會變得多麼複雜的想法,同時也可以作為一些複雜問題的文件。在這篇文章中,我也將嘗試解釋一個樣式系統是如何工作的。

好的。讓我們看看 font-size 是有多麼的複雜。

基礎

該屬性的語法非常簡單。你可以將其指定為:

  • 長度值(12px, 15pt, 13em, 4in, 8rem
  • 百分比值(50%
  • 將上述混合起來,使用 calc 來計算(calc(12px + 4em + 20%)
  • 絕對關鍵字(medium, small, large, x-large, 等等)
  • 相對關鍵字(larger, smaller

前三種用法在長度相關的 CSS 屬性中十分常見。語法沒有異常。

接下來兩個很有趣。本質上,絕對關鍵字對映到各種畫素值,並匹配 <font size=foo> 的結果(例如 size=3 就相當於 font-size: medium)。他們對映到的實際值並不簡單,我將在後面的文章中討論。

相對關鍵字基本上是向上或向下縮放。縮放的機制也是複雜的,但是這已經改變了。接下來我也會談到這個。

em 和 rem 單位

首先:em 單位。在任何基於長度的 CSS 屬性中都可以指定為一個單位為 em 或 rem 的值。

5em 是指 “應用於元素的 font-size 的 5 倍”。5rem 是指 “根元素的 font-size 的 5 倍”

這意味著字型大小需要在所有其他屬性之前計算(好吧,不完全是,但是我們將討論這個!)以便在這段時間內它是可用的。

你也可以在 font-size 中使用 em 單位。在本例中,它是相對於元素的字型大小計算的,而不是根據自身的字型大小來計算。

最小字型大小

瀏覽器允許您在它們的首選項中設定 “最小” 字型大小,文字不會比這個字型大小更小。這對於難以閱讀小字的人來說是很有幫助的。

但是,這並不影響以 em 為單位的 font-size 屬性。所以如果你使用最小字型大小,<div style="font-size: 1px; height: 1em; background-color: red"> 將會有一個很小的高度(你可以通過顏色注意到),但文字的大小卻會被限制在最小的尺寸上。

實際上這意味著你需要跟蹤兩個單獨計算的字型大小值。其中一個值用於確定實際文字的字型大小(例如,用於計算 em 單位。),而當樣式系統需要知道字型大小時使用另一個值。

但涉及到 ruby(旁註標記)時,這會變得更加複雜。在表意文字中(通常指漢字及基於漢字的日本漢字和朝鮮漢字),為了幫助那些不熟悉漢字的讀者,用拼音字來表達每個字元的發音有時是很有用的,這就是所謂的 “ruby”(在日語中被叫做 “振り仮名”)。因為這些文字是表意的,所以學習者知道一個單詞的發音卻不知道如何書寫它的情況並不少見。例如想要顯示 ほん,則需要在日語的日本(日語中讀作 “nihon”)上用 ruby 新增上平假名 にほん 。

如你所見,拼音部分的 ruby 文字字型更小(通常是主文字字型大小的 50%1)。最小字型大小遵守這一點,並確保如果 ruby 應用 50% 的字型大小,則 ruby 的最小字型大小是原始最小字型大小的 50%。這就避免了 ほん(上下兩段字設定成相同大小時)的情況,這樣看起來將會很醜。

文字變大

Firefox 允許你在僅縮放的時候縮放文字。如果你在閱讀一些小字時遇到了困難,那麼在不需要整頁放大的情況下就能把頁面上的文字放大(這意味著你需要大量滾動),這是很好的體驗。

在這個例子中,其他設定了 em 單位的屬性也被放大。畢竟,它們應該相對於文字的字型大小(並且可能與文字有某種關係),所以如果這個大小已經改變,那它們也應隨之改變。

(當然,這個論點也適用於最小字型大小。但我不知道為什麼最小字型沒有應用。)

實際上這很容易實現。在計算絕對字型大小(包括關鍵字)時,如果文字縮放功能開啟則它們會相應的縮放。而其他則一切照舊。

<svg:text> 元素禁止了文字縮放功能,這也引起了一些相當棘手的問題。

插曲:樣式系統是如何工作的

再繼續接下來的內容之前,我有必要概述下樣式系統是如何工作的。

樣式系統的職責是接受 CSS 程式碼和 DOM 樹,併為每個元素分配計算樣式。

這裡的 “specified” 和 “computed” 是不一樣的。“specified” 樣式是在 CSS 中指定的樣式,而計算樣式是指那些附加到元素、傳送到佈局並繼承自元素的那些樣式。當應用於不同的元素時,指定的樣式可以計算出不同的值。

所以當你指定width: 5em,它可能計算得出 width: 80px。計算值通常是指定值清理後的結果。

樣式系統將首先解析 CSS,通常會生成一組包含宣告的規則(宣告類似於 width: 20%;;即屬性名和指定值)

然後,它按照自頂向下的順序遍歷樹(在 Stylo 中這是並行的),找出每個元素所適用的宣告以及其執行順序 - 有些宣告優先於其他宣告。然後,它將根據元素的樣式(父樣式和其他資訊)計算每個相關宣告,並將該值儲存在元素的 “計算樣式” 中。

為了避免重複的工作,Gecko 和 Servo 在這裡做了很多優化2。 有一個 bloom 過濾器用於快速檢查深層後代選擇器是否應用於子樹。有一個 “規則樹” 用於快取已確定的宣告。計算樣式經常被引用、計數和共享(因為預設狀態是從父樣式或預設樣式繼承的)。

總的來說,這就是樣式系統運作的基本原理。

關鍵字值

好吧,這就是事情變得複雜的地方。

還記得我說的 font-size: medium 會對映到某個值嗎?

那麼它對映到什麼呢?

嗯,結果是,這取決於字型。對於以下 HTML:

<span style="font: medium monospace">text</span>
<span style="font: medium sans-serif">text</span>
複製程式碼

你能從(codepen)看到執行結果。

text text

其中第一個計算字型大小為 13px,第二個字型大小為 16px。你能從 devtools 的計算樣式視窗得到答案,或者使用 getComputedStyle() 也行。

認為這背後的原因是等寬字型往往更寬,而預設字型大小(medium)被縮小,使得它們看起來有相似的寬度,以及所有其他關鍵字字型大小也被改變。最終的結果就變成這樣:

[譯] Font-size:一個意外複雜的 CSS 屬性

Firefox 和 Servo 有一個 矩陣 用在計算基於“基本大小”(也就是 font-size: medium 的計算值)的所有絕對字型大小的關鍵字的值。實際上,Firefox 有 三個表格 來支援一些遺留用例,例如怪異模式(Servo 尚未新增對這三個表的支援)。我們在瀏覽器的其他部分查詢“基本大小”時是基於語言和字型的。

等等,這和語言又有什麼關係呢?語言是如何影響字型大小的?

實際上,基本大小取決於字型家族語言,你可以對它進行配置。

Firefox 和 Chrome(使用擴充套件)實際上都允許你為每種語言設定使用哪些字型,以及預設(基本)的字型大小

這並不像人們想象的那樣晦澀難懂。對於非拉丁語系的文字,預設字型通常很難看。我單獨安裝了一個字型, 可以顯示好看的天城文連字

同樣的,有些文字也比拉丁文複雜得多。我為天城文設定的預設字型為 18 而不是 16。我已經開始學習普通話了,我也把字號設定為 18。漢字字形可能會變得相當複雜,我仍然很難學會(以及認識)它們。更大的字型對學習它們更有幫助。

總之,這不會讓事情變得太複雜。這確實意味著 font family 需要在 font-size 之前計算,而 font-size 需要在大多數其他屬性之前計算。語言可以通過 HTML 的 lang 屬性來設定,由於它是可繼承的,Firefox 內部將其視為一個 CSS 屬性,必須儘早計算。

到此為止,還不算太糟。

現在,難以預料的事情出現了。這種對 language 和 family 的依賴是可以繼承的

快看,div 裡面的字型大小是多少呢?

<div style="font-size: medium; font-family: sans-serif;"> <!-- base size 16 -->
    font size is 16px
    <div style="font-family: monospace"> <!-- base size 13 -->
        font size is ??
    </div>
</div>
複製程式碼

對於可繼承的 CSS 屬性3,如果父級的計算值是 16px,且子元素沒有被指定其他值,那麼子元素將繼承這個 16px 的值。子元素不需要關心父元素是從哪裡得到這個計算值的。

現在,font-size “繼承”了一個 13px 的值。你能從這裡(codepen)看到結果:

font size is 16px
font size is ??

基本上,如果計算的值來自關鍵字,那麼無論 font family 或 language 如何變化,font-size 都會用關鍵字裡的 font family 和 language 來重新計算。

這麼做的原因是如果不這麼做,不同的字型大小將無法工作。預設字型大小為 medium,因此根元素基本上會得到一個 font-size: medium 而其他元素將繼承這個宣告。如果在文件中將其改為等寬字型或使用其他語言,則需要重新計算字型大小。

不僅如此。它甚至通過相對單位繼承(IE 除外)。

<div style="font-size: medium; font-family: sans-serif;"> <!-- base size 16 -->
    font size is 16px
    <div style="font-size: 0.9em"> <!-- could also be font-size: 50%-->
        font size is 14.4px (16 * 0.9)
        <div style="font-family: monospace"> <!-- base size 13 -->
            font size is 11.7px! (13 * 0.9)
        </div>
    </div>
</div>
複製程式碼

(codepen)

<div style="border: 1px solid black; display: inline-block; padding: 15px;">
    <div style="font-size: medium; font-family: sans-serif;">font size is 16px
        <div style="font-size: 0.9em">font size is 14.4px (16 * 0.9)

            <div style="font-family: monospace">font size is 11.7px! (13 * 0.9)</div>
        </div>
    </div>
</div>
複製程式碼

因此,當我們從第二個 div 繼承時,實際繼承的是 0.9*medium,而不是 14.4px

另一種看待這個問題的方法是,每當 font family 或 language 怎麼變化,你都應該重新計算字型大小, 就好像 language 和 family 沒有變化一樣。

Firefox 同時使用了這兩種策略。最初的 Gecko 樣式系統通過實際返回樹的頂部並重新計算字型大小來處理這個問題,就好像 language 和 family 是不同的一樣。我懷疑這是低效的,但是規則樹似乎使其略微高效了一些。

另一方面,在計算的同時,Servo 會儲存一些額外的資料,這些資料會被複制到子元素中。基本上來說, 儲存的內容相當於:“是的,這個字型是從關鍵字計算出來的。關鍵字是 medium,然後我們對它應用了 0.9 因子。”4

在這兩種情況下,這都會導致所有其他字型大小複雜性加劇,因為它們需要通過這種方式得到謹慎的保護。

在 Servo 裡,多數情況都是通過 font-size 自定義級聯函式 來處理的。

Larger/smaller

前面我提到了 font-size: larger/smaller 的是按比例縮放的,但還沒有提到對應的比例值。

根據 規範,如果當前字型大小與絕對關鍵字大小的值匹配(medium,large 等),則應該選擇上一個或下一個關鍵字大小的值。

如果是在兩個絕對關鍵字值之間,則在前兩個或後兩個尺寸中間尋找相同比例的點。

當然,這必須很好地處理之前提到的關鍵字字型大小的奇怪繼承問題。在 gecko 模型中這並不太難,因為 Gecko 無論如何都會重新計算。在 Servo 的模組中,我們儲存一系列 larger/smaller 的應用和相對單位,而不是隻儲存一個相對單位。

此外,在文字縮放過程中計算此值時,必須先取消縮放,然後再在表中查詢,然後重新縮放。

總的來說,一堆複雜的東西並沒有帶來多大的收益 —— 原來只有 Gecko 真正遵循了規範!其他瀏覽器引擎只是使用了簡單的比例縮放。

所以我的解決方案 就是把這種行為從 Gecko 上移除。簡化了這個處理過程。

MathML

Firefox 和 Safari 支援數學標記語言 MathML。如今,它在網路上使用不多,但它確實存在。

當談到字型大小時,MathML 也有它的複雜性。特別是 scriptminsizescriptlevelscriptsizemultiplier

例如,在 MathML 中,分子、分母或是文字上標是其外部文字字型大小的 0.71 倍。這是因為 MathML 元素預設的 scriptsizemultiplier 為 0.71, 而這些特定元素的 scriptlevel 預設為 +1

基本上,scriptlevel=+1 的意思是 “字型大小乘以 scriptsizemultiplier”,而 scriptlevel=-1 則用於消除這種影響。這可以通過在 mstyle 元素上設定 scriptlevel 屬性指定。同樣你也可以通過 scriptsizemultiplier 來調整(繼承的)乘數因子,通過 scriptminsize 來調整最小值。

例如:

<math><msup>
    <mi>text</mi>
    <mn>small superscript</mn>
</msup></math><br>
<math>
    text
    <mstyle scriptlevel=+1>
        small
        <mstyle scriptlevel=+1>
            smaller
            <mstyle scriptlevel=-1>
                small again
            </mstyle>
        </mstyle>
    </mstyle>
</math>
複製程式碼

顯示如下(需要用 Firefox 來檢視呈現版本,Safari 也支援 MathML,但支援不太好):

textsmall superscript text small smaller small again

(codepen)

所以這沒那麼糟。就好像 scriptlevel 是一個奇怪的 em 單位。沒什麼大不了的,我們已經知道如何處理這些問題了。

還有 scriptminsize。這使你可以scriptlevel 所引起的更改設定最小字型大小。

這意味著,scriptminsize 將確保 scriptlevel 不會導致出現比最小尺寸更小的字型,但它會忽略特意指定的 em 單位和畫素值。

這裡已經引入了一點微妙的複雜性,現在 scriptlevel 成了影響到 font-size 如何繼承的另一個因素了。幸運的是,在 Firefox/Servo 的內部,scriptlevel(以及 scriptminsizescriptsizemultiplier)也是作為 CSS 屬性處理,這意味著我們可以使用與 font-family 和 language 一樣的框架來處理 —— 在字型大小設定之前計算指令碼屬性,如果設定了 scriptlevel,則強制重新計算字型大小,即使沒有設定字型大小本身。

插曲:早期和晚期處理屬性

在 Servo 中,我們處理屬性依賴關係的方式是擁有一組 “早期” 屬性和一組 “後期” 屬性(允許依賴於早期屬性)。我們對宣告進行了兩次查詢,一次是查詢早期屬性,另一次是後期屬性。然而,現在我們有了一組相當複雜的依賴關係,其中 font-size 必須在 language、font-family 和指令碼屬性之後計算,但在其他所有涉及長度的東西之前計算。另外,由於另一個我沒有談到的字型複雜性,font-family 必須在所有其他早期屬性之後進行計算。

我們處理這個問題的方法是在早期計算時 抽離 font-size 和 font-family ,直到早期計算完成後再處理它

在這個階段,我們首先處理文字縮放的禁用,然後處理 font-family 的複雜性

然後計算 font family。如果指定了字型大小,則進行計算。如果沒有指定,但指定了 font family,lang 或 scriptlevel,則強制將計算作為繼承,來處理所有的約束。

為什麼 scriptminsize 會變得這麼複雜

與其他 “最小字型大小” 不同,在字型大小被 scriptminsize 限制時,在任何屬性中使用 em 單位都將用一個鉗位值來計算長度,而不是 “如果沒有被鉗位” 的值, 如果字型大小被 scriptminsize 限制。因此,乍一看,處理這一點似乎很簡單;當因為 scriptlevel 而需要縮放時, 只考慮最小字型大小 scriptminsize。

和往常一樣,事情並沒有這麼簡單 ?:

<math>
<mstyle scriptminsize="10px" scriptsizemultiplier="0.75" style="font-size:20px">
    20px
    <mstyle scriptlevel="+1">
        15px
        <mstyle scriptlevel="+1">
            11.25px
                <mstyle scriptlevel="+1">
                    would be 8.4375, but is clamped at 10px
                        <mstyle scriptlevel="+1">
                            would be 6.328125, but is clamped at 10px
                                <mstyle scriptlevel="-1">
                                    This is not 10px/0.75=13.3, rather it is still clamped at 10px
                                        <mstyle scriptlevel="-1">
                                            This is not 10px/0.75=13.3, rather it is still clamped at 10px
                                            <mstyle scriptlevel="-1">
                                                This is 11.25px again
                                                    <mstyle scriptlevel="-1">
                                                        This is 15px again
                                                    </mstyle>
                                            </mstyle>
                                        </mstyle>
                                </mstyle>
                        </mstyle>
                </mstyle>
        </mstyle>
    </mstyle>
</mstyle>
</math>
複製程式碼

codepen

基本上來說, 如果你在達到最小字型大小後繼續多次增加層級, 然後減掉一個層級, 是沒法立即計算出 min size / multiplier 的值的。這使之變得不對稱了, 如果乘數因子沒有變化, 一個淨層級為 +5 應該與一個淨層級為 +6 -1 的元素具有相同字型大小。

因此,所發生的情況是,script level 是根據字型大小計算的就好像 scriptminsize 從未應用過一樣,而且只有當指令碼大小大於最小大小時,我們才使用該大小。

這不僅僅是跟蹤 script level 還需要跟蹤 multiplier 的變化。因此,這最終將建立另一個要繼承的字型大小值

概括地說,我們現在有種不同的繼承字型大小的概念:

  • 樣式使用的主要字型大小
  • “實際” 的字型大小,即主要的字型大小,但受限於最小值
  • (僅在 servo 中的)“關鍵字” 尺寸;即儲存為關鍵字和比率的大小(如果它是從關鍵字派生的)
  • “不受指令碼控制的” 尺寸;就像 scriptminsize 從不存在。

另一個複雜性在於下面這種情況應該仍然能正常工作:

<math>
<mstyle scriptminsize="10px" scriptsizemultiplier="0.75" style="font-size: 5px">
    5px
    <mstyle scriptlevel="-1">
        6.666px
    </mstyle>
</mstyle>
</math>
複製程式碼

(codepen)

如果已經比 scriptminsize 還小,減少 script level(以增大字型大小)不應該被鉗制,因為之後這會讓它看起來過於巨大。

這基本上意味著, 只能在 script level 對應的值大於指令碼最小字型大小時, 使用 scriptminsize。

在 Servo 中,所有 MathML 的處理都被這個奇妙的註釋比程式碼多的函式以及它附近函式的一些程式碼完美解決。


這就是你要了解的。font-size 實際上是相當複雜的。很多網路平臺都隱藏著這樣的複雜情況,但遇到了卻會覺得十分有趣。

(當我必須實現它們時,可能就沒那麼有趣了。 ?)

感謝 mystor,mgattozzi,bstrie 和 projektir 審閱了這篇文章的草稿。


  1. 有趣的是,在 Firefox 中,對所有的 ruby 來說這個數值為 50%,當語言為臺灣中文時除外(此時為 30%)。 這是因為臺灣使用一種名為 Bopomofo 的拼音文字,每一個漢字可由最多三個 Bopomofo 字元表示。因此,有可能選擇一個合理的最小尺寸,使 ruby 永遠不會超出下面的文字。另一方面,拼音最多可達 6 個字母,而日語平假名最多可達 (我認為) 5 個,相應的 “no overflow” 將使文字顯得太小。 因此,將它們放在字上並不是問題,相反,為了更好的可讀性,我們選擇使用更大的字型大小。 此外,Bopomofo ruby 通常是放在文字的旁邊而非頂部, 所以 30% 效果更好。(h/t @upsuper 指出了這一點)
  2. 其他的瀏覽器引擎也有其他的優化,但我還不瞭解它們。
  3. 有些屬性是繼承的,有些是 “reset” 的。例如,font-family 繼承的 —— 除非另外設定。但是 transform 卻不是,如果你在元素上應用了 transform 但它的子元素卻不會繼承這個屬性。
  4. 這不能處理 calcs,這是我需要解決的問題。除了比率之外,還儲存一個絕對偏移量。

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章