【譯】CSS 才不是什麼黑魔法呢

吃土小2叉發表於2017-07-25

CSS 才不是什麼黑魔法呢

一起來揭開 CSS 的神祕面紗

如果你是一名 web 開發者,你可能會時不時地寫一些 CSS。

當你第一次接觸 CSS 時,似乎覺得 CSS 輕而易舉。加邊框,改顏色,小菜一碟。JavaScript 才是前端開發的難點,不是嗎?

但是在你 web 開發生涯中的某天,這個想法變了!更糟糕的是,許多前端社群的開發者早已把 CSS 輕視為一門玩具語言。

然而,事實卻是當我們碰壁時,我們中的許多人實際上未曾深入瞭解我們編寫的 CSS 做了什麼。

在我接受前端培訓後的頭兩年,我曾從事全棧 JavaScript 開發,偶爾寫一點點 CSS。作為 JavaScript Jabber 評委會的一員,我一直認為 JavaScript 才是我吃飯的傢伙,所以大部分時間我都花在 JavaScript 上。

然而直到去年,當我決定專注於前端時,才意識到根本無法像除錯 JavaScript 那樣輕鬆地除錯 CSS!

我們都喜歡拿 CSS 開玩笑,但是我們中有多少人真的花時間去嘗試理解我們正在編寫或正在閱讀的 CSS。當我們碰壁時,我們有多少人在解決問題的同時,會深入最底層(看看發生了什麼)? 相反,我們止步於照搬 StackOverflow 上票數最高的答案,或者用一些黑科技(hack)手段隨便應付一下,或者我們乾脆撒手不管了:那是一個 feature 而不是一個 bug。

當瀏覽器以非預期的方式呈現 CSS 時,開發者常常感到非常困惑。但是 CSS 並不是黑魔法,而作為開發者,我們都明白計算機只會按照我們的指令去執行。

學習瀏覽器的內部工作原理將有助於掌握高階除錯技巧和效能優化方案。雖然許多會議的演講會討論如何修復常見的 bug,但我的演講(和這篇文章)的重點在於為什麼會有這些 bug,為此我將深入介紹瀏覽器內部原理,看看我們的 CSS 是如何被解析和呈現。

DOM 與 CSSOM

首先,瞭解瀏覽器包含 JavaScript 引擎和渲染引擎非常重要,而本文將重點關注後者。例如,我們將討論涉及 WebKit(Safari),Blink(Chrome),Gecko(Firefox)和 Trident / EdgeHTML(IE / Edge)的細節。瀏覽器將經歷包括轉換、標記化、詞法分析和解析的過程,最終構建 DOM 和 CSSOM。(譯註:CSSOM 即 CSS Object Model,定義了媒體查詢,選擇器和 CSS 本身的 API,這些 API 包括了通用解析和序列化規則,傳送門:CSSOM

這一過程大致可以分為以下幾個步驟:

  • 轉換:從磁碟或網路讀取 HTML 和 CSS 的原始位元組。
  • 標記化: 將輸入內容分解成一個個有效標記(例如:起始標籤、結束標籤、屬性名、屬性值),分離無關字元(如空格和換行符)。
  • 詞法分析:和 tokenizer(標記生成器)類似,但它還標記每個 token 的型別(型別包括:數字、字串字面量、相等運算子等等)。
  • 解析: 解析器接收詞法分析器傳遞的 tokens,並嘗試將其與某條語法規則進行匹配,匹配成功後將之新增到抽象語法樹中。

一旦 DOM 樹和 CSSOM 樹建立完畢,渲染引擎就會將資料結構附加到所謂的渲染樹中,並作為佈局過程的一部分。

渲染樹是文件的視覺化表現形式,它按照正確的順序繪製頁面的內容。渲染樹的構造過程遵循以下順序:

  • 從 DOM 樹的根節點開始,遍歷每個可見節點
  • 忽略不可見的節點
  • 對於每個可見節點,找到合適的與 CSSOM 匹配的規則並應用它們
  • 傳送包含內容和計算樣式的可見節點
  • 最後,在螢幕上輸出包含所有可見元素的內容和樣式資訊的渲染樹。

CSSOM 可以對渲染樹產生很大的影響,但不會影響到 DOM 樹。

渲染

經歷了佈局和渲染樹構建後,瀏覽器終於要開始將網頁繪製到螢幕上併合成圖層。

  • 佈局:包括計算一個元素佔用的空間以及它在螢幕上的位置。父元素可以影響子元素佈局,某些情況下子元素也會反過來影響父元素。
  • 繪製:將渲染樹中的每個節點轉換為螢幕上的實際畫素的過程。它涉及繪製文字、顏色、影像、邊框和陰影。繪圖通常在多個圖層上完成,另外由於載入、執行 JavaScript 而改變了 DOM 會導致多次繪製 。
  • 合成:將所有圖層合併在一個圖層,作為最終螢幕上可見圖層的過程。由於頁面的各個部分可以繪製成多層,所以需要以正確的順序繪製到螢幕上。

繪製時間取決於渲染樹結構,元素的 widthheight 的值越大,繪製時間就越長。

新增各種特效同樣會增加繪畫時間。繪製的順序是按照元素進入層疊上下文的順序(從後往前繪製),稍後我們再談談 z-index。如果你喜歡看視訊教程,有一個很棒的關於繪製過程的 demo

當人們在談論瀏覽器的硬體加速時,絕大多數都是指加速“合成”過程,也就是意味著使用 GPU 來合成網頁的內容。

與使用計算機 CPU 進行合成的舊方式相比,使用 GPU 能帶來相當多的速度提升,而合理利用 will-change 這一屬性有助於此。(譯註:will-change 相關資料傳送門 will-change MDNEverything You Need to Know About the CSS will-change Property

舉個例子:在使用 CSS transform 屬性時,will-change 屬效能提前告知瀏覽器 DOM 元素接下來會有哪些變化。這可以將一些繪製和合成操作移交給 GPU,從而大大提高有大量動畫的頁面的效能。使用 will-change 屬性,對於滾動位置變化、內容變化、不透明度變化以及絕對定位座標位置變化也有類似的效能收益。

有必要了解一件事:某些 CSS 屬性將導致重新佈局,而其他屬性只會導致重新繪製。當然出於效能考慮,最好只觸發重繪。

舉個例子:元素的顏色改變後,只會對該元素進行重繪。而元素的位置改變後,會對該元素及其子元素(可能還有同級元素)進行佈局和重繪。新增 DOM 節點後,會對該節點進行佈局和重繪。一些重大變化(例如增大 html 元素的字型)會導致整個渲染樹進行重新佈局和繪製。

如果你像我一樣,比起 CSSOM 更熟悉 DOM,那麼讓我們來深入瞭解一下 CSSOM。請務必注意,預設情況下,CSS 會被視為阻塞渲染資源。這意味著瀏覽器在構建完 CSSOM 之前,將掛起任何其它程式的渲染。

CSSOM 和 DOM 並不是一一對應的。具有 dispay:none 屬性的元素、<script> 標籤、<meta> 標籤、<head> 元素等等不可見的 DOM 元素不會顯示在渲染樹中。

CSSOM 和 DOM 的另一個區別則在於解析 CSS 使用的是一種上下文無關語法。也就是說,CSS 渲染引擎不會自動補全 CSS 中缺少的語法,然而解析 HTML 建立 DOM 時則剛好相反。

解析 HTML 時,瀏覽器不得不結合 HTML 標籤所在的上下文,而且只遵從 HTML 規範是不夠的,因為 HTML 標籤可能包含一些預設的資訊,並且無論解析成什麼,最終都要渲染出來。(譯註:這麼做的目的是為了包容開發者的錯誤,簡化 web 開發,例如能省略一些起始或者結束標記等等)

說了那麼多,我們來回顧一下:

  • 瀏覽器向伺服器發起 HTTP 請求
  • 伺服器響應請求,並返回網頁資料
  • 瀏覽器通過標記化將響應資料(位元組)轉換為 tokens
  • 瀏覽器將 tokens 轉換為節點
  • 瀏覽器將節點插入 DOM 樹
  • 等待構建 CSSOM 樹

優先順序

我們已經深入瞭解了不少瀏覽器的工作原理,那麼接下來我們來看看一些更常見的開發痛點吧。首先說說優先順序。

簡單來說,CSS 的優先順序是指以正確的層疊順序應用規則。儘管可以使用多種 CSS 選擇器來選中特定的標籤,瀏覽器仍需要一種方式來決定最終哪些樣式將會生效。在決策過程中,首先瀏覽器會計算每個選擇器的優先順序。

不幸的是,優先順序的計算規則難倒了不少 JavaScript 開發者,所以讓我們一起深入研究 CSS 優先順序的計算規則。我們將使用以下的 html 結構作為例子:有一個類名為 container 的 div,在這個 div 裡,我們巢狀了另一個 div,它的 id 是 main,我們又在這個 div 裡巢狀了一個包含 a 標籤的 p 標籤。別偷看答案,你知道 a 標籤的顏色是什麼嗎?

#main a {
  color: green;
}

p a {
  color: yellow;
}

.container #main a {
  color: pink;
}

div #main p a {
  color: orange;
}

a {
  color: red;
}複製程式碼

(譯註:加一段 html 結構順便防偷看答案 →_→)

<div class="container">
    <div id="main">
        <p>
            <a href="#">Test</a>
        </p>
    </div>
</div>複製程式碼

答案是粉色,它的優先順序為:1,1,1。以下是其餘選擇器的優先順序:

  • div #main p a: 1,0,3
  • #main a: 1,0,1
  • p a: 2
  • a: 1

優先順序的每一個數的計算規則如下:

  • 第一個數:ID 選擇器的數量
  • 第二個數:類選擇器、屬性選擇器(不包含:[type="text"], [rel="nofollow"])、以及偽類選擇器(不包含::hover, :visited)的數量和。
  • 第三個數:元素選擇器與偽元素選擇器(不包含: ::before, ::after)的數量和。

因此,對於以下選擇器:

#header .navbar li a:visited複製程式碼

該選擇器的優先順序是:1,2,2。因為我們有 1 個 ID 選擇器、1 個類選擇器、1 個偽類選擇器、還有 2 個元素選擇器(lia)。你可以把優先順序看作一個數字,比如 1,2,2 就是 122。這裡的逗號是為了提現你優先順序的數值並不是以 10 進位制計算的。理論上你可以讓一個元素的優先順序為:0,1,13,4,其中的 13 並不會像 10 進位制那樣產生進位。(譯註:不會變成 0,2,3,4)

定位

其次,我想花點時間討論一下定位。正如前文所說的,定位和佈局是密切相關的。

佈局是一個遞迴的過程,當全域性樣式變化的時候,有時會在整個渲染樹上(重新)觸釋出局,有時則僅在區域性變化的地方增量更新。有一件有趣的事情值得注意:如果我們重新思考渲染樹中的絕對定位元素,該物件在渲染樹中的位置和它在 DOM 樹中的位置不同的。

我也經常被問及應該使用 flexbox 還是 float 進行佈局。毫無疑問,用 flexbox 進行佈局相當方便,而且當應用於同一個元素時,flexbox 佈局將在大約 3.5ms 內呈現,而 float 佈局可能需要大約 14ms。所以,磨礪你的 CSS 技能所帶來的回報不下於磨礪你的 JavaScript 技能的回報。

Z-Index

最後,我想聊聊 z-index。起初 z-index 聽起來很簡單。HTML 文件中的每個元素都可以處在文件的每個其他元素的前面或後面。 而它也只適用於指定了定位方式的元素(譯註:即,未被定位,非 position:static 的元素)。如果你嘗試在沒有被定位的元素上設定 z-index,則不會起作用。

除錯 z-index 問題的關鍵是理解層疊上下文,並始終從層疊上下文的根元素開始除錯。 層疊上下文是 HTML 元素的三維概念,這些 HTML 元素在一條假想的相對於面向視窗(電腦螢幕)的使用者的 z 軸上延伸。換句話說,它是一組具有相同父級的元素,在同一個層疊上下文領域,層疊水平值大的那一個覆蓋小的那一個。

每個層疊上下文都有一個唯一的 HTML 元素作為其根元素,並且在不涉及 z-indexposition 屬性時,層疊規則很簡單:層疊順序與元素在 HTML 中出現的順序相同。(譯註:即,新繪製的元素會覆蓋之前的元素)

當然,你也可以使用 z-index 之外的屬性來建立新的層疊上下文,這會導致情況更為複雜。以下屬性都會建立新的層疊上下文:

  • opacity 值不是 1
  • filter 值不是 none
  • mix-blend-mode 值不是 normal

順便提一下,blend mode 決定了指定圖層上的畫素與其下方圖層上的可見畫素的混合方式。

transform 屬性值不為 none 的元素同樣會建立新的層疊上下文。例如 scale(1)translate3d(0,0,0)。同樣順便提一下,scale 屬性是用於調整元素大小的,而 translate3d 屬性則會啟用 GPU 加速讓 CSS 動畫更為流暢 。

所以,儘管你可能還沒有設計師般的眼光,但希望你正向著 CSS 大師邁進!如果你有興趣瞭解更多,我整理了一些學習資源


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章