[譯]瀏覽器工作原理探究

志文軒發表於2018-10-31

引言

最近對web的效能優化比較感興趣,而前端程式碼主要在瀏覽器工作的。如果對瀏覽器的工作原理了解清楚,可以為web效能優化提供方向以及理論依據。
本文主要參考 How Browsers Work: Behind the scenes of modern web browsers 。在此基礎上整理而來的。個人覺得這是一篇好文章,所以進行整理轉載,希望對大家有所幫助。(文章篇幅比較長,需花點時間^_^)

目錄

簡介

我們要討論的瀏覽器

目前使用的主流瀏覽器有五個:Internet Explorer、Firefox、Safari、Chrome 瀏覽器和 Opera。本文中以開放原始碼瀏覽器為例,即 Firefox、Chrome 瀏覽器和 Safari(部分開源)。

瀏覽器的主要功能

瀏覽器的主要功能就是向伺服器發出請求,在瀏覽器視窗中展示您選擇的網路資源。這裡所說的資源一般是指 HTML 文件,也可以是 PDF、圖片或其他的型別。資源的位置由使用者使用 URI(統一資源標示符)指定。

瀏覽器解釋並顯示 HTML 檔案的方式是在 HTML 和 CSS 規範中指定的。這些規範由網路標準化組織 W3C(全球資訊網聯盟)進行維護。 多年以來,各瀏覽器都沒有完全遵從這些規範,同時還在開發自己獨有的擴充套件程式,這給網路開發人員帶來了嚴重的相容性問題。如今,大多數的瀏覽器都是或多或少地遵從規範。

瀏覽器的使用者介面有很多彼此相同的元素,其中包括:

  • 用來輸入 URI 的位址列
  • 前進和後退按鈕
  • 書籤設定選項
  • 用於重新整理和停止載入當前文件的重新整理和停止按鈕
  • 用於返回主頁的主頁按鈕

瀏覽器的高層結構

瀏覽器的主要元件為:

  1. 使用者介面 - 包括位址列、前進/後退按鈕、書籤選單等。除了瀏覽器主視窗顯示的您請求的頁面外,其他顯示的各個部分都屬於使用者介面。
  2. 瀏覽器引擎 - 在使用者介面和渲染引擎之間傳送指令。
  3. 渲染引擎 - 負責顯示請求的內容。如果請求的內容是 HTML,它就負責解析 HTML 和 CSS 內容,並將解析後的內容顯示在螢幕上。
  4. 網路 - 用於網路呼叫,比如 HTTP 請求。其介面與平臺無關,併為所有平臺提供底層實現。
  5. 使用者介面後端 - 用於繪製基本的視窗小部件,比如組合框和視窗。其公開了與平臺無關的通用介面,而在底層使用作業系統的使用者介面方法。
  6. JavaScript 直譯器。用於解析和執行 JavaScript 程式碼。
  7. 資料儲存。這是持久層。瀏覽器需要在硬碟上儲存各種資料,例如 Cookie。新的 HTML 規範 (HTML5) 定義了“網路資料庫”,這是一個完整(但是輕便)的瀏覽器內資料庫。

瀏覽器元件

圖1: 瀏覽器元件

渲染引擎

渲染引擎主要用來展示HTML,XML,圖片,PDF等資源。本文所討論的瀏覽器(Firefox、Chrome 瀏覽器和 Safari)是基於兩種渲染引擎構建的。Firefox 使用的是 Gecko,這是 Mozilla 公司“自制”的渲染引擎。而 Safari 和 Chrome 瀏覽器使用的都是 WebKit。

WebKit 是一種開放原始碼渲染引擎,起初用於 Linux 平臺,隨後由 Apple 公司進行修改,從而支援蘋果機和 Windows。

主要流程

渲染引擎一開始會從網路層獲取請求文件的內容,內容的大小一般限制在 8000 個塊以內。

然後進行如下所示的基本流程:

渲染引擎工作流程

圖2: 渲染引擎工作流程

渲染引擎將開始解析 HTML 文件,並將各標記逐個轉化成“內容樹”上的 DOM 節點。同時也會解析外部 CSS 檔案以及樣式元素中的樣式資料。HTML 中這些帶有視覺指令的樣式資訊將用於建立另一個樹結構:渲染樹。

渲染樹包含多個帶有視覺屬性(如顏色和尺寸)的矩形。這些矩形的排列順序就是它們將在螢幕上顯示的順序。

渲染樹構建完畢之後,進入“佈局”處理階段,也就是為每個節點分配一個應出現在螢幕上的確切座標。下一個階段是繪製 - 渲染引擎會遍歷渲染樹,由使用者介面後端層將每個節點繪製出來。

需要著重指出的是,這是一個漸進的過程。為達到更好的使用者體驗,渲染引擎會力求儘快將內容顯示在螢幕上。它不必等到整個 HTML 文件解析完畢之後,就會開始構建渲染樹和設定佈局。在不斷接收和處理來自網路的其餘內容的同時,渲染引擎會將部分內容解析並顯示出來。

主要流程如下:

WebKit主要流程

圖3: WebKit主要流程

Gecko主要流程

圖4: Gecko主要流程

從圖 3 和圖 4 可以看出,雖然 WebKit 和 Gecko 使用的術語略有不同,但整體流程是基本相同的。

Gecko 將視覺格式化元素組成的樹稱為“框架樹”。每個元素都是一個框架。WebKit 使用的術語是“渲染樹”,它由“渲染物件”組成。對於元素的放置,WebKit 使用的術語是“佈局”,而 Gecko 稱之為“重排”。對於連線 DOM 節點和視覺化資訊從而建立渲染樹的過程,WebKit 使用的術語是“附加”。有一個細微的非語義差別,就是 Gecko 在 HTML 與 DOM 樹之間還有一個稱為“內容槽”的層,用於生成 DOM 元素。我們會逐一論述流程中的每一部分:

解析和DOM樹構建

解析 - 綜述

解析是渲染引擎中非常重要的一個環節,因此我們要更深入地講解。首先,來介紹一下解析。

解析文件是指將文件轉化成為有意義的結構,也就是可讓程式碼理解和使用的結構。解析得到的結果通常是代表了文件結構的節點樹,它稱作解析樹或者語法樹。

示例 - 解析 2 + 3 - 1 這個表示式,會返回下面的樹:

數學表示式樹節點

圖5: 數學表示式樹節點
語法

解析是以文件所遵循的語法規則(編寫文件所用的語言或格式)為基礎的。所有可以解析的格式都必須對應確定的語法(由詞彙和語法規則構成)。這稱為與上下文無關的語法。人類語言並不屬於這樣的語言,因此無法用常規的解析技術進行解析。

解析器和詞法分析器的組合

解析的過程可以分成兩個子過程:詞法分析和語法分析。

詞法分析是將輸入內容分割成大量標記的過程。標記是語言中的詞彙,即構成內容的單位。在人類語言中,它相當於語言字典中的單詞。

語法分析是應用語言的語法規則的過程。

解析器通常將解析工作分給以下兩個元件來處理:詞法分析器(有時也稱為標記生成器),負責將輸入內容分解成一個個有效標記;而解析器負責根據語言的語法規則分析文件的結構,從而構建解析樹。詞法分析器知道如何將無關的字元(比如空格和換行符)分離出來。

從源文件到解析樹

圖6: 從源文件到解析樹

解析是一個迭代的過程。通常,解析器會向詞法分析器請求一個新標記,並嘗試將其與某條語法規則進行匹配。如果發現了匹配規則,解析器會將一個對應於該標記的節點新增到解析樹中,然後繼續請求下一個標記。

如果沒有規則可以匹配,解析器就會將標記儲存到內部,並繼續請求標記,直至找到可與所有內部儲存的標記匹配的規則。如果找不到任何匹配規則,解析器就會引發一個異常。這意味著文件無效,包含語法錯誤。

翻譯

很多時候,解析樹還不是最終產品。解析通常是在翻譯過程中使用的,而翻譯是指將輸入文件轉換成另一種格式。編譯就是這樣一個例子。編譯器可將原始碼編譯成機器程式碼,具體過程是首先將原始碼解析成解析樹,然後將解析樹翻譯成機器程式碼文件。

編譯流程

圖7: 編譯流程
解析示例

在圖 5 中,我們通過一個數學表示式建立了解析樹。現在,讓我們試著定義一個簡單的數學語言,用來演示解析的過程。

詞彙:我們用的語言可包含整數、加號和減號。

語法:

構成語言的語法單位是表示式、項和運算子。 我們用的語言可以包含任意數量的表示式。 表示式的定義是:一個“項”接一個“運算子”,然後再接一個“項”。 運算子是加號或減號。 項是一個整數或一個表示式。 讓我們分析一下 2 + 3 - 1。 匹配語法規則的第一個子串是 2,而根據第 5 條語法規則,這是一個項。匹配語法規則的第二個子串是 2 + 3,而根據第 3 條規則(一個項接一個運算子,然後再接一個項),這是一個表示式。下一個匹配項已經到了輸入的結束。2 + 3 - 1 是一個表示式,因為我們已經知道 2 + 3 是一個項,這樣就符合“一個項接一個運算子,然後再接一個項”的規則。2 + + 不與任何規則匹配,因此是無效的輸入。

詞彙和語法的正式定義

詞彙通常用正規表示式表示。

例如,我們的示例語言可以定義如下:

    INTEGER :0|[1-9][0-9]*
    PLUS : +
    MINUS: -
複製程式碼

正如您所看到的,這裡用正規表示式給出了整數的定義。

語法通常使用一種稱為 BNF 的格式來定義。我們的示例語言可以定義如下:

    expression :=  term  operation  term
    operation :=  PLUS | MINUS
    term := INTEGER | expression
複製程式碼

之前我們說過,如果語言的語法是與上下文無關的語法,就可以由常規解析器進行解析。與上下文無關的語法的直觀定義就是可以完全用 BNF 格式表達的語法。

解析器型別

有兩種基本型別的解析器:自上而下解析器和自下而上解析器。直觀地來說,自上而下的解析器從語法的高層結構出發,嘗試從中找到匹配的結構。而自下而上的解析器從低層規則出發,將輸入內容逐步轉化為語法規則,直至滿足高層規則。

讓我們來看看這兩種解析器會如何解析我們的示例:

自上而下的解析器會從高層的規則開始:首先將 2 + 3 標識為一個表示式,然後將 2 + 3 - 1 標識為一個表示式(標識表示式的過程涉及到匹配其他規則,但是起點是最高階別的規則)。

自下而上的解析器將掃描輸入內容,找到匹配的規則後,將匹配的輸入內容替換成規則。如此繼續替換,直到輸入內容的結尾。部分匹配的表示式儲存在解析器的堆疊中。

堆疊 輸入
2 + 3 - 1
+ 3 - 1
項運算 3 - 1
表示式 - 1
表示式運算子 1
表示式

這種自下而上的解析器稱為移位歸約解析器,因為輸入在向右移位(設想有一個指標從輸入內容的開頭移動到結尾),並且逐漸歸約到語法規則上。

自動生成解析器

有一些工具可以幫助您生成解析器,它們稱為解析器生成器。您只要向其提供您所用語言的語法(詞彙和語法規則),它就會生成相應的解析器。建立解析器需要對解析有深刻理解,而人工建立並優化解析器並不是一件容易的事情,所以解析器生成器是非常實用的。

WebKit 使用了兩種非常有名的解析器生成器:用於建立詞法分析器的 Flex 以及用於建立解析器的 Bison(您也可能遇到 Lex 和 Yacc 這樣的別名)。Flex 的輸入是包含標記的正規表示式定義的檔案。Bison 的輸入是採用 BNF 格式的語言語法規則。

HTML解析器

HTML 解析器的任務是將 HTML 標記解析成解析樹。

HTML語法定義

HTML 的詞彙和語法在 W3C 組織建立的規範中進行了定義。當前的版本是 HTML4,HTML5 正在處理過程中。

非與上下文無關語法

正如我們在解析過程的簡介中已經瞭解到的,語法可以用 BNF 等格式進行正式定義。

很遺憾,所有的常規解析器都不適用於 HTML(我並不是開玩笑,它們可以用於解析 CSS 和 JavaScript)。HTML 並不能很容易地用解析器所需的與上下文無關的語法來定義。

有一種可以定義 HTML 的正規格式:DTD(Document Type Definition,文件型別定義),但它不是與上下文無關的語法。

這初看起來很奇怪:HTML 和 XML 非常相似。有很多 XML 解析器可以使用。HTML 存在一個 XML 變體 (XHTML),那麼有什麼大的區別呢?

區別在於 HTML 的處理更為“寬容”,它允許您省略某些隱式新增的標記,有時還能省略一些起始或者結束標記等等。和 XML 嚴格的語法不同,HTML 整體來看是一種“軟性”的語法。

顯然,這種看上去細微的差別實際上卻帶來了巨大的影響。一方面,這是 HTML 如此流行的原因:它能包容您的錯誤,簡化網路開發。另一方面,這使得它很難編寫正式的語法。概括地說,HTML 無法很容易地通過常規解析器解析(因為它的語法不是與上下文無關的語法),也無法通過 XML 解析器來解析。

HTML DTD

HTML 的定義採用了 DTD 格式。此格式可用於定義 SGML 族的語言。它包括所有允許使用的元素及其屬性和層次結構的定義。如上文所述,HTML DTD 無法構成與上下文無關的語法。

DTD 存在一些變體。嚴格模式完全遵守 HTML 規範,而其他模式可支援以前的瀏覽器所使用的標記。這樣做的目的是確保向下相容一些早期版本的內容。最新的嚴格模式 DTD 可以在這裡找到:www.w3.org/TR/html4/st…

DOM

解析器的輸出“解析樹”是由 DOM 元素和屬性節點構成的樹結構。DOM 是文件物件模型 (Document Object Model) 的縮寫。它是 HTML 文件的物件表示,同時也是外部內容(例如 JavaScript)與 HTML 元素之間的介面。 解析樹的根節點是“Document”物件。

DOM 與標記之間幾乎是一一對應的關係。比如下面這段標記:

    <html>
    <body>
        <p>
        Hello World
        </p>
        <div> <img src="example.png"/></div>
    </body>
    </html>
複製程式碼

可翻譯成如下的 DOM 樹:

示例標記的 DOM 樹

圖8: 示例標記的 DOM 樹

和 HTML 一樣,DOM 也是由 W3C 組織指定的。請參見 www.w3.org/DOM/DOMTR。這… HTML 的元素。HTML 的定義可以在這裡找到:www.w3.org/TR/2003/REC…

這裡的樹包含 DOM 節點,指的是樹是由實現了某個 DOM 介面的元素構成的。瀏覽器在具體的實現中會有一些供內部使用的其他屬性。

解析演算法

我們在之前章節已經說過,HTML 無法用常規的自上而下或自下而上的解析器進行解析。

原因在於:

  1. 語言的寬容本質。
  2. 瀏覽器歷來對一些常見的無效 HTML 用法採取包容態度。
  3. 解析過程需要不斷地反覆。源內容在解析過程中通常不會改變,但是在 HTML 中,指令碼標記如果包含 document.write,就會新增額外的標記,這樣解析過程實際上就更改了輸入內容。

由於不能使用常規的解析技術,瀏覽器就建立了自定義的解析器來解析 HTML。

HTML5 規範詳細地描述瞭解析演算法。此演算法由兩個階段組成:標記化和樹構建。

標記化是詞法分析過程,將輸入內容解析成多個標記。HTML 標記包括起始標記、結束標記、屬性名稱和屬性值。

標記生成器識別標記,傳遞給樹構造器,然後接受下一個字元以識別下一個標記;如此反覆直到輸入的結束。

HTML 解析流程

圖9: HTML 解析流程
標記化演算法

該演算法的輸出結果是 HTML 標記。該演算法使用狀態機來表示。每一個狀態接收來自輸入資訊流的一個或多個字元,並根據這些字元更新下一個狀態。當前的標記化狀態和樹結構狀態會影響進入下一狀態的決定。這意味著,即使接收的字元相同,對於下一個正確的狀態也會產生不同的結果,具體取決於當前的狀態。該演算法相當複雜,無法在此詳述,所以我們通過一個簡單的示例來幫助大家理解其原理。

基本示例 - 將下面的 HTML 程式碼標記化:

    <html>
    <body>
        Hello world
    </body>
    </html>
複製程式碼

初始狀態是資料狀態。遇到字元 < 時,狀態更改為“標記開啟狀態”。接收一個 a-z 字元會建立“起始標記”,狀態更改為“標記名稱狀態”。這個狀態會一直保持到接收 > 字元。在此期間接收的每個字元都會附加到新的標記名稱上。在本例中,我們建立的標記是 html 標記。

遇到 > 標記時,會傳送當前的標記,狀態改回“資料狀態”。 標記也會進行同樣的處理。目前 html 和 body 標記均已發出。現在我們回到“資料狀態”。接收到 Hello world 中的 H 字元時,將建立併傳送字元標記,直到接收 中的 <。我們將為 Hello world 中的每個字元都傳送一個字元標記。

現在我們回到“標記開啟狀態”。接收下一個輸入字元 / 時,會建立 end tag token 並改為“標記名稱狀態”。我們會再次保持這個狀態,直到接收 >。然後將傳送新的標記,並回到“資料狀態”。 輸入也會進行同樣的處理。

對示例輸入進行標記化

圖10: 對示例輸入進行標記化
樹構建演算法

在建立解析器的同時,也會建立 Document 物件。在樹構建階段,以 Document 為根節點的 DOM 樹也會不斷進行修改,向其中新增各種元素。標記生成器傳送的每個節點都會由樹構建器進行處理。規範中定義了每個標記所對應的 DOM 元素,這些元素會在接收到相應的標記時建立。這些元素不僅會新增到 DOM 樹中,還會新增到開放元素的堆疊中。此堆疊用於糾正巢狀錯誤和處理未關閉的標記。其演算法也可以用狀態機來描述。這些狀態稱為“插入模式”。

讓我們來看看示例輸入的樹構建過程:

    <html>
    <body>
        Hello world
    </body>
    </html>
複製程式碼

樹構建階段的輸入是一個來自標記化階段的標記序列。第一個模式是“initial mode”。接收 HTML 標記後轉為“before html”模式,並在這個模式下重新處理此標記。這樣會建立一個 HTMLHtmlElement 元素,並將其附加到 Document 根物件上。

然後狀態將改為“before head”。此時我們接收“body”標記。即使我們的示例中沒有“head”標記,系統也會隱式建立一個 HTMLHeadElement,並將其新增到樹中。

現在我們進入了“in head”模式,然後轉入“after head”模式。系統對 body 標記進行重新處理,建立並插入 HTMLBodyElement,同時模式轉變為“in body”。

現在,接收由“Hello world”字串生成的一系列字元標記。接收第一個字元時會建立並插入“Text”節點,而其他字元也將附加到該節點。

接收 body 結束標記會觸發“after body”模式。現在我們將接收 HTML 結束標記,然後進入“after after body”模式。接收到檔案結束標記後,解析過程就此結束。

示例 HTML 的樹構建

圖11: 示例 HTML 的樹構建
解析結束後的操作

在此階段,瀏覽器會將文件標註為互動狀態,並開始解析那些處於“deferred”模式的指令碼,也就是那些應在文件解析完成後才執行的指令碼。然後,文件狀態將設定為“完成”,一個“載入”事件將隨之觸發。

您可以在 HTML5 規範中檢視標記化和樹構建的完整演算法

瀏覽器的容錯機制

您在瀏覽 HTML 網頁時從來不會看到“語法無效”的錯誤。這是因為瀏覽器會糾正任何無效內容,然後繼續工作。

以下面的 HTML 程式碼為例:

    <html>
    <mytag>
    </mytag>
    <div>
    <p>
    </div>
        Really lousy HTML
    </p>
    </html>
複製程式碼

在這裡,已經違反了很多語法規則(“mytag”不是標準的標記,“p”和“div”元素之間的巢狀有誤等等),但是瀏覽器仍然會正確地顯示這些內容,並且毫無怨言。因為有大量的解析器程式碼會糾正 HTML 網頁作者的錯誤。

不同瀏覽器的錯誤處理機制相當一致,但令人稱奇的是,這種機制並不是 HTML 當前規範的一部分。和書籤管理以及前進/後退按鈕一樣,它也是瀏覽器在多年發展中的產物。很多網站都普遍存在著一些已知的無效 HTML 結構,每一種瀏覽器都會嘗試通過和其他瀏覽器一樣的方式來修復這些無效結構。

HTML5 規範定義了一部分這樣的要求。WebKit 在 HTML 解析器類的開頭註釋中對此做了很好的概括。

解析器對標記化輸入內容進行解析,以構建文件樹。如果文件的格式正確,就直接進行解析。

遺憾的是,我們不得不處理很多格式錯誤的 HTML 文件,所以解析器必須具備一定的容錯性。

我們至少要能夠處理以下錯誤情況:

  1. 明顯不能在某些外部標記中新增的元素。在此情況下,我們應該關閉所有標記,直到出現禁止新增的元素,然後再加入該元素。
  2. 我們不能直接新增的元素。這很可能是網頁作者忘記新增了其中的一些標記(或者其中的標記是可選的)。這些標籤可能包括:HTML HEAD BODY TBODY TR TD LI(還有遺漏的嗎?)。
  3. 向 inline 元素內新增 block 元素。關閉所有 inline 元素,直到出現下一個較高階的 block 元素。
  4. 如果這樣仍然無效,可關閉所有元素,直到可以新增元素為止,或者忽略該標記。

讓我們看一些 WebKit 容錯的示例:

  • 使用了
    而不是
    有些網站使用了
    而不是
    。為了與 IE 和 Firefox 相容,WebKit 將其與
    做同樣的處理。 程式碼如下:
if (t->isCloseTag(brTag) && m_document->inCompatMode()) {
     reportError(MalformedBRError);
     t->beginTag = true;
}
複製程式碼

請注意,錯誤處理是在內部進行的,使用者並不會看到這個過程。

  • 離散表格 離散表格是指位於其他表格內容中,但又不在任何一個單元格內的表格。 比如以下的示例:
    <table>
        <table>
            <tr><td>inner table</td></tr>
        </table>
        <tr><td>outer table</td></tr>
    </table>
複製程式碼

WebKit 會將其層次結構更改為兩個同級表格:

    <table>
        <tr><td>outer table</td></tr>
    </table>
    <table>
        <tr><td>inner table</td></tr>
    </table>
複製程式碼

程式碼如下:

if (m_inStrayTableContent && localName == tableTag){
    popBlock(tableTag);
}
複製程式碼

WebKit 使用一個堆疊來儲存當前的元素內容,它會從外部表格的堆疊中彈出內部表格。現在,這兩個表格就變成了同級關係。

  • 巢狀的表單元素 如果使用者在一個表單元素中又放入了另一個表單,那麼第二個表單將被忽略。 程式碼如下:
if (!m_currentFormElement) {
        m_currentFormElement = new HTMLFormElement(formTag,    m_document);
}
複製程式碼
  • 過於複雜的標記層次結構 程式碼的註釋已經說得很清楚了。
示例網站 www.liceo.edu.mx 巢狀了約 1500 個標記,全都來自一堆 標記。我們只允許最多 20 層同型別標記的巢狀,如果再巢狀更多,就會全部忽略。
  • 放錯位置的 html 或者 body 結束標記

同樣,程式碼的註釋已經說得很清楚了。

支援格式非常糟糕的 HTML 程式碼。我們從不關閉 body 標記,因為一些愚蠢的網頁會在實際文件結束之前就關閉。我們通過呼叫 end() 來執行關閉操作。
if (t->tagName == htmlTag || t->tagName == bodyTag )
        return;
複製程式碼

所以網頁作者需要注意,除非您想作為反面教材出現在 WebKit 容錯程式碼段的示例中,否則還請編寫格式正確的 HTML 程式碼。

CSS解析

還記得簡介中解析的概念嗎?和 HTML 不同,CSS 是上下文無關的語法,可以使用簡介中描述的各種解析器進行解析。事實上,CSS 規範定義了 CSS 的詞法和語法。

讓我們來看一些示例: 詞法語法(詞彙)是針對各個標記用正規表示式定義的:

comment   \/\*[^*]*\*+([^/*][^*]*\*+)*\/
num   [0-9]+|[0-9]*"."[0-9]+
nonascii  [\200-\377]
nmstart   [_a-z]|{nonascii}|{escape}
nmchar    [_a-z0-9-]|{nonascii}|{escape}
name    {nmchar}+
ident   {nmstart}{nmchar}*
複製程式碼

“ident”是識別符號 (identifier) 的縮寫,比如類名。“name”是元素的 ID(通過“#”來引用)。

語法是採用 BNF 格式描述的。

ruleset
  : selector [ ',' S* selector ]*
    '{' S* declaration [ ';' S* declaration ]* '}' S*
  ;
selector
  : simple_selector [ combinator selector | S+ [ combinator? selector ]? ]?
  ;
simple_selector
  : element_name [ HASH | class | attrib | pseudo ]*
  | [ HASH | class | attrib | pseudo ]+
  ;
class
  : '.' IDENT
  ;
element_name
  : IDENT | '*'
  ;
attrib
  : '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S*
    [ IDENT | STRING ] S* ] ']'
  ;
pseudo
  : ':' [ IDENT | FUNCTION S* [IDENT S*] ')' ]
  ;
複製程式碼

解釋:這是一個規則集的結構:

div.error , a.error {
  color:red;
  font-weight:bold;
}
複製程式碼

div.error 和 a.error 是選擇器。大括號內的部分包含了由此規則集應用的規則。此結構的正式定義是這樣的:

ruleset
  : selector [ ',' S* selector ]*
    '{' S* declaration [ ';' S* declaration ]* '}' S*
  ;
複製程式碼

這表示一個規則集就是一個選擇器,或者由逗號和空格(S 表示空格)分隔的多個(數量可選)選擇器。規則集包含了大括號,以及其中的一個或多個(數量可選)由分號分隔的宣告。“宣告”和“選擇器”將由下面的 BNF 格式定義。

WebKit CSS解析器

WebKit 使用 Flex 和 Bison 解析器生成器,通過 CSS 語法檔案自動建立解析器。正如我們之前在解析器簡介中所說,Bison 會建立自下而上的移位歸約解析器。Firefox 使用的是人工編寫的自上而下的解析器。這兩種解析器都會將 CSS 檔案解析成 styleSheet 物件,且每個物件都包含 CSS 規則。CSS 規則物件則包含選擇器和宣告物件,以及其他與 CSS 語法對應的物件。

解析 CSS

圖12: 解析 CSS

處理指令碼和樣式表的順序

指令碼

網路的模型是同步的。網頁作者希望解析器遇到 script 標記時立即解析並執行指令碼。文件的解析將停止,直到指令碼執行完畢。如果指令碼是外部的,那麼解析過程會停止,直到從網路同步抓取資源完成後再繼續。此模型已經使用了多年,也在 HTML4 和 HTML5 規範中進行了指定。作者也可以將指令碼標註為“defer”,這樣它就不會停止文件解析,而是等到解析結束才執行。HTML5 增加了一個選項,可將指令碼標記為非同步,以便由其他執行緒解析和執行。

預解析

WebKit 和 Firefox 都進行了這項優化。在執行指令碼時,其他執行緒會解析文件的其餘部分,找出並載入需要通過網路載入的其他資源。通過這種方式,資源可以在並行連線上載入,從而提高總體速度。請注意,預解析器不會修改 DOM 樹,而是將這項工作交由主解析器處理;預解析器只會解析外部資源(例如外部指令碼、樣式表和圖片)的引用。

樣式表

另一方面,樣式表有著不同的模型。理論上來說,應用樣式表不會更改 DOM 樹,因此似乎沒有必要等待樣式表並停止文件解析。但這涉及到一個問題,就是指令碼在文件解析階段會請求樣式資訊。如果當時還沒有載入和解析樣式,指令碼就會獲得錯誤的回覆,這樣顯然會產生很多問題。這看上去是一個非典型案例,但事實上非常普遍。Firefox 在樣式表載入和解析的過程中,會禁止所有指令碼。而對於 WebKit 而言,僅當指令碼嘗試訪問的樣式屬性可能受尚未載入的樣式表影響時,它才會禁止該指令碼。

渲染樹構建

在 DOM 樹構建的同時,瀏覽器還會構建另一個樹結構:渲染樹。這是由視覺化元素按照其顯示順序而組成的樹,也是文件的視覺化表示。它的作用是讓您按照正確的順序繪製內容。

Firefox 將渲染樹中的元素稱為“框架”。WebKit 使用的術語是渲染器或渲染物件。 渲染器知道如何佈局並將自身及其子元素繪製出來。 WebKits RenderObject 類是所有渲染器的基類,其定義如下:

    class RenderObject{
        virtual void layout();
        virtual void paint(PaintInfo);
        virtual void rect repaintRect();
        Node* node;  //the DOM node
        Renderstyle* style;  // the computed style
        RenderLayer* containgLayer; //the containing z-index layer
    }
複製程式碼

每一個渲染器都代表了一個矩形的區域,通常對應於相關節點的 CSS 框,這一點在 CSS2 規範中有所描述。它包含諸如寬度、高度和位置等幾何資訊。 框的型別會受到與節點相關的“display”樣式屬性的影響(請參閱樣式計算章節)。下面這段 WebKit 程式碼描述了根據 display 屬性的不同,針對同一個 DOM 節點應建立什麼型別的渲染器。

    RenderObject* RenderObject::createObject(Node* node, Renderstyle* style)
    {
        Document* doc = node->document();
        RenderArena* arena = doc->renderArena();
        ...
        RenderObject* o = 0;

        switch (style->display()) {
            case NONE:
                break;
            case INLINE:
                o = new (arena) RenderInline(node);
                break;
            case BLOCK:
                o = new (arena) RenderBlock(node);
                break;
            case INLINE_BLOCK:
                o = new (arena) RenderBlock(node);
                break;
            case LIST_ITEM:
                o = new (arena) RenderListItem(node);
                break;
        ...
        }

        return o;
    }
複製程式碼

元素型別也是考慮因素之一,例如表單控制元件和表格都對應特殊的框架。 在 WebKit 中,如果一個元素需要建立特殊的渲染器,就會替換 createRenderer 方法。渲染器所指向的樣式物件中包含了一些和幾何無關的資訊。

渲染樹和dom樹的關係

渲染器是和 DOM 元素相對應的,但並非一一對應。非視覺化的 DOM 元素不會插入渲染樹中,例如“head”元素。如果元素的 display 屬性值為“none”,那麼也不會顯示在渲染樹中(但是 visibility 屬性值為“hidden”的元素仍會顯示)。

有一些 DOM 元素對應多個視覺化物件。它們往往是具有複雜結構的元素,無法用單一的矩形來描述。例如,“select”元素有 3 個渲染器:一個用於顯示區域,一個用於下拉選單框,還有一個用於按鈕。如果由於寬度不夠,文字無法在一行中顯示而分為多行,那麼新的行也會作為新的渲染器而新增。

另一個關於多渲染器的例子是格式無效的 HTML。根據 CSS 規範,inline 元素只能包含 block 元素或 inline 元素中的一種。如果出現了混合內容,則應建立匿名的 block 渲染器,以包裹 inline 元素。

有一些渲染物件對應於 DOM 節點,但在樹中所在的位置與 DOM 節點不同。浮動定位和絕對定位的元素就是這樣,它們處於正常的流程之外,放置在樹中的其他地方,並對映到真正的框架,而放在原位的是佔位框架。

渲染樹及其對應的 DOM 樹。初始容器 block 為“viewport”,而在 WebKit 中則為“RenderView”物件。

圖13: 渲染樹及其對應的 DOM 樹。初始容器 block 為“viewport”,而在 WebKit 中則為“RenderView”物件。

構建渲染樹的流程

在 Firefox 中,系統會針對 DOM 更新註冊展示層,作為偵聽器。展示層將框架建立工作委託給 FrameConstructor,由該構造器解析樣式(請參閱樣式計算)並建立框架。

在 WebKit 中,解析樣式和建立渲染器的過程稱為“附加”。每個 DOM 節點都有一個“attach”方法。附加是同步進行的,將節點插入 DOM 樹需要呼叫新的節點“attach”方法。

處理 html 和 body 標記就會構建渲染樹根節點。這個根節點渲染物件對應於 CSS 規範中所說的容器 block,這是最上層的 block,包含了其他所有 block。它的尺寸就是視口,即瀏覽器視窗顯示區域的尺寸。Firefox 稱之為 ViewPortFrame,而 WebKit 稱之為 RenderView。這就是文件所指向的渲染物件。渲染樹的其餘部分以 DOM 樹節點插入的形式來構建。

樣式計算

構建渲染樹時,需要計算每一個渲染物件的視覺化屬性。這是通過計算每個元素的樣式屬性來完成的。

樣式包括來自各種來源的樣式表、inline 樣式元素和 HTML 中的視覺化屬性(例如“bgcolor”屬性)。其中後者將經過轉化以匹配 CSS 樣式屬性。

樣式表的來源包括瀏覽器的預設樣式表、由網頁作者提供的樣式表以及由瀏覽器使用者提供的使用者樣式表(瀏覽器允許您定義自己喜歡的樣式。以 Firefox 為例,使用者可以將自己喜歡的樣式表放在“Firefox Profile”資料夾下)。

樣式計算存在以下難點:

  1. 樣式資料是一個超大的結構,儲存了無數的樣式屬性,這可能造成記憶體問題。
  2. 如果不進行優化,為每一個元素查詢匹配的規則會造成效能問題。要為每一個元素遍歷整個規則列表來尋找匹配規則,這是一項浩大的工程。選擇器會具有很複雜的結構,這就會導致某個匹配過程一開始看起來很可能是正確的,但最終發現其實是徒勞的,必須嘗試其他匹配路徑。

例如下面這個組合選擇器:

    div div div div{
        ...
    }
複製程式碼

這意味著規則適用於作為 3 個 div 元素的子代的

.如果您要檢查規則是否適用於某個指定的
元素,應選擇樹上的一條向上路徑進行檢查。您可能需要向上遍歷節點樹,結果發現只有兩個 div,而且規則並不適用。然後,您必須嘗試樹中的其他路徑。

  1. 應用規則涉及到相當複雜的層疊規則(用於定義這些規則的層次)。

讓我們來看看瀏覽器是如何處理這些問題的:

共享樣式資料

WebKit 節點會引用樣式物件 (Renderstyle)。這些物件在某些情況下可以由不同節點共享。這些節點是同級關係,並且:

  1. 這些元素必須處於相同的滑鼠狀態(例如,不允許其中一個是“:hover”狀態,而另一個不是)
  2. 任何元素都沒有 ID
  3. 標記名稱應匹配
  4. 類屬性應匹配
  5. 對映屬性的集合必須是完全相同的
  6. 連結狀態必須匹配
  7. 焦點狀態必須匹配
  8. 任何元素都不應受屬性選擇器的影響,這裡所說的“影響”是指在選擇器中的任何位置有任何使用了屬性選擇器的選擇器匹配
  9. 元素中不能有任何 inline 樣式屬性
  10. 不能使用任何同級選擇器。WebCore 在遇到任何同級選擇器時,只會引發一個全域性開關,並停用整個文件的樣式共享(如果存在)。這包括 + 選擇器以及 :first-child 和 :last-child 等選擇器。
Firefox規則樹

為了簡化樣式計算,Firefox 還採用了另外兩種樹:規則樹和樣式上下文樹。WebKit 也有樣式物件,但它們不是儲存在類似樣式上下文樹這樣的樹結構中,只是由 DOM 節點指向此類物件的相關樣式。

Firefox 樣式上下文樹

圖14: Firefox 樣式上下文樹

樣式上下文包含端值。要計算出這些值,應按照正確順序應用所有的匹配規則,並將其從邏輯值轉化為具體的值。例如,如果邏輯值是螢幕大小的百分比,則需要換算成絕對的單位。規則樹的點子真的很巧妙,它使得節點之間可以共享這些值,以避免重複計算,還可以節約空間。

所有匹配的規則都儲存在樹中。路徑中的底層節點擁有較高的優先順序。規則樹包含了所有已知規則匹配的路徑。規則的儲存是延遲進行的。規則樹不會在開始的時候就為所有的節點進行計算,而是隻有當某個節點樣式需要進行計算時,才會向規則樹新增計算的路徑。

這個想法相當於將規則樹路徑視為詞典中的單詞。如果我們已經計算出如下的規則樹:

詞典規則樹

圖15: 詞典規則樹

假設我們需要為內容樹中的另一個元素匹配規則,並且找到匹配路徑是 B - E - I(按照此順序)。由於我們在樹中已經計算出了路徑 A - B - E - I - L,因此就已經有了此路徑,這就減少了現在所需的工作量。 讓我們看看規則樹如何幫助我們減少工作。

結構劃分

樣式上下文可分割成多個結構。這些結構體包含了特定類別(如 border 或 color)的樣式資訊。結構中的屬性都是繼承的或非繼承的。繼承屬性如果未由元素定義,則繼承自其父代。非繼承屬性(也稱為“重置”屬性)如果未進行定義,則使用預設值。

規則樹通過快取整個結構(包含計算出的端值)為我們提供幫助。這一想法假定底層節點沒有提供結構的定義,則可使用上層節點中的快取結構。

使用規則樹計算樣式上下文

在計算某個特定元素的樣式上下文時,我們首先計算規則樹中的對應路徑,或者使用現有的路徑。然後我們沿此路徑應用規則,在新的樣式上下文中填充結構。我們從路徑中擁有最高優先順序的底層節點(通常也是最特殊的選擇器)開始,並向上遍歷規則樹,直到結構填充完畢。如果該規則節點對於此結構沒有任何規範,那麼我們可以實現更好的優化:尋找路徑更上層的節點,找到後指定完整的規範並指向相關節點即可。這是最好的優化方法,因為整個結構都能共享。這可以減少端值的計算量並節約記憶體。 如果我們找到了部分定義,就會向上遍歷規則樹,直到結構填充完畢。

如果我們找不到結構的任何定義,那麼假如該結構是“繼承”型別,我們會在上下文樹中指向父代的結構,這樣也可以共享結構。如果是 reset 型別的結構,則會使用預設值。

如果最特殊的節點確實新增了值,那麼我們需要另外進行一些計算,以便將這些值轉化成實際值。然後我們將結果快取在樹節點中,供子代使用。

如果某個元素與其同級元素都指向同一個樹節點,那麼它們就可以共享整個樣式上下文。

讓我們來看一個例子,假設我們有如下 HTML 程式碼:

    <html>
    <body>
        <div class="err" id="div1">
        <p>
            this is a <span class="big"> big error </span>
            this is also a
            <span class="big"> very  big  error</span> error
        </p>
        </div>
        <div class="err" id="div2">another error</div>
    </body>
    </html>
複製程式碼

還有如下規則:

    div {margin:5px;color:black}
    .err {color:red}
    .big {margin-top:3px}
    div span {margin-bottom:4px}
    #div1 {color:blue}
    #div2 {color:green}
複製程式碼

為了簡便起見,我們只需要填充兩個結構:color 結構和 margin 結構。color 結構只包含一個成員(即“color”),而 margin 結構包含四條邊。 形成的規則樹如下圖所示(節點的標記方式為“節點名 : 指向的規則序號”):

規則樹

圖16: 規則樹

上下文樹如下圖所示(節點名 : 指向的規則節點):

上下文樹

圖17: 上下文樹

假設我們解析 HTML 時遇到了第二個

標記,我們需要為此節點建立樣式上下文,並填充其樣式結構。 經過規則匹配,我們發現該
的匹配規則是第 1、2 和 6 條。這意味著規則樹中已有一條路徑可供我們的元素使用,我們只需要再為其新增一個節點以匹配第 6 條規則(規則樹中的 F 節點)。 我們將建立樣式上下文並將其放入上下文樹中。新的樣式上下文將指向規則樹中的 F 節點。

現在我們需要填充樣式結構。首先要填充的是 margin 結構。由於最後的規則節點 (F) 並沒有新增到 margin 結構,我們需要上溯規則樹,直至找到在先前節點插入中計算過的快取結構,然後使用該結構。我們會在指定 margin 規則的最上層節點(即 B 節點)上找到該結構。

我們已經有了 color 結構的定義,因此不能使用快取的結構。由於 color 有一個屬性,我們無需上溯規則樹以填充其他屬性。我們將計算端值(將字串轉化為 RGB 等)並在此節點上快取經過計算的結構。

第二個 元素處理起來更加簡單。我們將匹配規則,最終發現它和之前的 span 一樣指向規則 G。由於我們找到了指向同一節點的同級,就可以共享整個樣式上下文了,只需指向之前 span 的上下文即可。

對於包含了繼承自父代的規則的結構,快取是在上下文樹中進行的(事實上 color 屬性是繼承的,但是 Firefox 將其視為 reset 屬性,並快取到規則樹上)。 例如,如果我們在某個段落中新增 font 規則:

    p {font-family:Verdana;font size:10px;font-weight:bold}
複製程式碼

那麼,該段落元素作為上下文樹中的 div 的子代,就會共享與其父代相同的 font 結構(前提是該段落沒有指定 font 規則)。 在 WebKit 中沒有規則樹,因此會對匹配的宣告遍歷 4 次。首先應用非重要高優先順序的屬性(由於作為其他屬性的依據而應首先應用的屬性,例如 display),接著是高優先順序重要規則,然後是普通優先順序非重要規則,最後是普通優先順序重要規則。這意味著多次出現的屬性會根據正確的層疊順序進行解析。最後出現的最終生效。 因此概括來說,共享樣式物件(整個物件或者物件中的部分結構)可以解決問題 1 和問題 3。Firefox 規則樹還有助於按照正確的順序應用屬性。

對規則進行處理以簡化匹配

樣式規則有一些來源:

  • 外部樣式表或樣式元素中的 CSS 規則
p {color:blue}
複製程式碼
  • inline 樣式屬性及類似內容
<p style="color:blue" />
複製程式碼
  • HTML 視覺化屬性(對映到相關的樣式規則)
<p bgcolor="blue" />
複製程式碼

後兩種很容易和元素進行匹配,因為元素擁有樣式屬性,而且 HTML 屬性可以使用元素作為鍵值進行對映。

我們之前在第 2 個問題中提到過,CSS 規則匹配可能比較棘手。為了解決這一難題,可以對 CSS 規則進行一些處理,以便訪問。

樣式表解析完畢後,系統會根據選擇器將 CSS 規則新增到某個雜湊表中。這些雜湊表的選擇器各不相同,包括 ID、類名稱、標記名稱等,還有一種通用雜湊表,適合不屬於上述類別的規則。如果選擇器是 ID,規則就會新增到 ID 表中;如果選擇器是類,規則就會新增到類表中,依此類推。 這種處理可以大大簡化規則匹配。我們無需檢視每一條宣告,只要從雜湊表中提取元素的相關規則即可。這種優化方法可排除掉 95% 以上規則,因此在匹配過程中根本就不用考慮這些規則了 (4.1)。

我們以如下的樣式規則為例:

p.error {color:red}
#messageDiv {height:50px}
div {margin:5px} 
複製程式碼

第一條規則將插入類表,第二條將插入 ID 表,而第三條將插入標記表。

對於下面的 HTML 程式碼段:

<p class="error">an error occurred </p>
<div id="messageDiv">this is a message</div>
複製程式碼

我們首先會為 p 元素尋找匹配的規則。類表中有一個“error”鍵,在下面可以找到“p.error”的規則。div 元素在 ID 表(鍵為 ID)和標記表中有相關的規則。剩下的工作就是找出哪些根據鍵提取的規則是真正匹配的了。 例如,如果 div 的對應規則如下:

table div {margin:5px} 
複製程式碼

這條規則仍然會從標記表中提取出來,因為鍵是最右邊的選擇器,但這條規則並不匹配我們的 div 元素,因為 div 沒有 table 祖先。

WebKit 和 Firefox 都進行了這一處理。

以正確的層疊順序應用規則

樣式物件具有與每個視覺化屬性一一對應的屬性(均為 CSS 屬性但更為通用)。如果某個屬性未由任何匹配規則所定義,那麼部分屬性就可由父代元素樣式物件繼承。其他屬性具有預設值。

如果定義不止一個,就會出現問題,需要通過層疊順序來解決。

樣式表層疊順序

某個樣式屬性的宣告可能會出現在多個樣式表中,也可能在同一個樣式表中出現多次。這意味著應用規則的順序極為重要。這稱為“層疊”順序。根據 CSS2 規範,層疊的順序為(優先順序從低到高):

  1. 瀏覽器宣告
  2. 使用者普通宣告
  3. 作者普通宣告
  4. 作者重要宣告
  5. 使用者重要宣告

瀏覽器宣告是重要程度最低的,而使用者只有將該宣告標記為“重要”才可以替換網頁作者的宣告。同樣順序的宣告會根據特異性進行排序,然後再是其指定順序。HTML 視覺化屬性會轉換成匹配的 CSS 宣告。它們被視為低優先順序的網頁作者規則。

特異性

選擇器的特異性由 CSS2 規範定義如下:

  • 如果宣告來自於“style”屬性,而不是帶有選擇器的規則,則記為 1,否則記為 0 (= a)
  • 記為選擇器中 ID 屬性的個數 (= b)
  • 記為選擇器中其他屬性和偽類的個數 (= c)
  • 記為選擇器中元素名稱和偽元素的個數 (= d)

將四個數字按 a-b-c-d 這樣連線起來(位於大數進位制的數字系統中),構成特異性。

您使用的進製取決於上述類別中的最高計數。 例如,如果 a=14,您可以使用十六進位制。如果 a=17,那麼您需要使用十七進位制;當然不太可能出現這種情況,除非是存在如下的選擇器:html body div div p ...(在選擇器中出現了 17 個標記,這樣的可能性極低)。

一些示例:

    *             {}  /* a=0 b=0 c=0 d=0 -> specificity = 0,0,0,0 */
    li            {}  /* a=0 b=0 c=0 d=1 -> specificity = 0,0,0,1 */
    li:first-line {}  /* a=0 b=0 c=0 d=2 -> specificity = 0,0,0,2 */
    ul li         {}  /* a=0 b=0 c=0 d=2 -> specificity = 0,0,0,2 */
    ul ol+li      {}  /* a=0 b=0 c=0 d=3 -> specificity = 0,0,0,3 */
    h1 + *[rel=up]{}  /* a=0 b=0 c=1 d=1 -> specificity = 0,0,1,1 */
    ul ol li.red  {}  /* a=0 b=0 c=1 d=3 -> specificity = 0,0,1,3 */
    li.red.level  {}  /* a=0 b=0 c=2 d=1 -> specificity = 0,0,2,1 */
    #x34y         {}  /* a=0 b=1 c=0 d=0 -> specificity = 0,1,0,0 */
    style=""          /* a=1 b=0 c=0 d=0 -> specificity = 1,0,0,0 */
複製程式碼
規則排序

找到匹配的規則之後,應根據級聯順序將其排序。WebKit 對於較小的列表會使用氣泡排序,而對較大的列表則使用歸併排序。對於以下規則,WebKit 通過替換“>”運算子來實現排序:

static bool operator >(CSSRuleData& r1, CSSRuleData& r2)
{
    int spec1 = r1.selector()->specificity();
    int spec2 = r2.selector()->specificity();
    return (spec1 == spec2) : r1.position() > r2.position() : spec1 > spec2;
}
複製程式碼

漸進式處理

WebKit 使用一個標記來表示是否所有的頂級樣式表(包括 @imports)均已載入完畢。如果在附加過程中尚未完全載入樣式,則使用佔位符,並在文件中進行標註,等樣式表載入完畢後再重新計算。

佈局

渲染器在建立完成並新增到渲染樹時,並不包含位置和大小資訊。計算這些值的過程稱為佈局或重排。

HTML 採用基於流的佈局模型,這意味著大多數情況下只要一次遍歷就能計算出幾何資訊。處於流中靠後位置元素通常不會影響靠前位置元素的幾何特徵,因此佈局可以按從左至右、從上至下的順序遍歷文件。但是也有例外情況,比如 HTML 表格的計算就需要不止一次的遍歷 (3.5)。

座標系是相對於根框架而建立的,使用的是上座標和左座標。

佈局是一個遞迴的過程。它從根渲染器(對應於 HTML 文件的 元素)開始,然後遞迴遍歷部分或所有的框架層次結構,為每一個需要計算的渲染器計算幾何資訊。

根渲染器的位置左邊是 0,0,其尺寸為視口(也就是瀏覽器視窗的可見區域)。 所有的渲染器都有一個“layout”或者“reflow”方法,每一個渲染器都會呼叫其需要進行佈局的子代的 layout 方法。

Dirty佈局

為避免對所有細小更改都進行整體佈局,瀏覽器採用了一種“dirty 位”系統。如果某個渲染器發生了更改,或者將自身及其子代標註為“dirty”,則需要進行佈局。

有兩種標記:“dirty”和“children are dirty”。“children are dirty”表示儘管渲染器自身沒有變化,但它至少有一個子代需要佈局。

全域性佈局和增量佈局

全域性佈局是指觸發了整個渲染樹範圍的佈局,觸發原因可能包括:

  1. 影響所有渲染器的全域性樣式更改,例如字型大小更改。
  2. 螢幕大小調整。 佈局可以採用增量方式,也就是隻對 dirty 渲染器進行佈局(這樣可能存在需要進行額外佈局的弊端)。
    當渲染器為 dirty 時,會非同步觸發增量佈局。例如,當來自網路的額外內容新增到 DOM 樹之後,新的渲染器附加到了渲染樹中。

增量佈局 - 只有 dirty 渲染器及其子代進行佈局

圖18: 增量佈局 - 只有 dirty 渲染器及其子代進行佈局

非同步佈局和同步佈局

增量佈局是非同步執行的。Firefox 將增量佈局的“reflow 命令”加入佇列,而排程程式會觸發這些命令的批量執行。WebKit 也有用於執行增量佈局的計時器:對渲染樹進行遍歷,並對 dirty 渲染器進行佈局。 請求樣式資訊(例如“offsetHeight”)的指令碼可同步觸發增量佈局。 全域性佈局往往是同步觸發的。 有時,當初始佈局完成之後,如果一些屬性(如滾動位置)發生變化,佈局就會作為回撥而觸發。

優化

如果佈局是由“大小調整”或渲染器的位置(而非大小)改變而觸發的,那麼可以從快取中獲取渲染器的大小,而無需重新計算。 在某些情況下,只有一個子樹進行了修改,因此無需從根節點開始佈局。這適用於在本地進行更改而不影響周圍元素的情況,例如在文字欄位中插入文字(否則每次鍵盤輸入都將觸發從根節點開始的佈局)。

佈局處理

佈局通常具有以下模式:

  1. 父渲染器確定自己的寬度。
  2. 父渲染器依次處理子渲染器,並且:
    1. 放置子渲染器(設定 x,y 座標)。
    2. 如果有必要,呼叫子渲染器的佈局(如果子渲染器是 dirty 的,或者這是全域性佈局,或出於其他某些原因),這會計運算元渲染器的高度。
  3. 父渲染器根據子渲染器的累加高度以及邊距和補白的高度來設定自身高度,此值也可供父渲染器的父渲染器使用。
  4. 將其 dirty 位設定為 false。

Firefox 使用“state”物件 (nsHTMLReflowState) 作為佈局的引數(稱為“reflow”),這其中包括了父渲染器的寬度。
Firefox 佈局的輸出為“metrics”物件 (nsHTMLReflowMetrics),其包含計算得出的渲染器高度。

寬度計算

渲染器寬度是根據容器塊的寬度、渲染器樣式中的“width”屬性以及邊距和邊框計算得出的。
例如以下 div 的寬度:

<div style="width:30%"/> 
複製程式碼

將由 WebKit 計算如下(BenderBox 類,calcWidth 方法):

  • 容器的寬度取容器的 availableWidth 和 0 中的較大值。availableWidth 在本例中相當於 contentWidth,計算公式如下:
clientWidth() - paddingLeft() - paddingRight() 
複製程式碼

clientWidth 和 clientHeight 表示一個物件的內部(除去邊框和滾動條)。

  • 元素的寬度是“width”樣式屬性。它會根據容器寬度的百分比計算得出一個絕對值。
  • 然後加上水平方向的邊框和補白。

現在計算得出的是“preferred width”。然後需要計算最小寬度和最大寬度。 如果首選寬度大於最大寬度,那麼應使用最大寬度。如果首選寬度小於最小寬度(最小的不可破開單位),那麼應使用最小寬度。

這些值會快取起來,以用於需要佈局而寬度不變的情況。

換行

如果渲染器在佈局過程中需要換行,會立即停止佈局,並告知其父代需要換行。父代會建立額外的渲染器,並對其呼叫佈局。

繪製

在繪製階段,系統會遍歷渲染樹,並呼叫渲染器的“paint”方法,將渲染器的內容顯示在螢幕上。繪製工作是使用使用者介面基礎元件完成的。

全域性繪製和增量繪製

和佈局一樣,繪製也分為全域性(繪製整個渲染樹)和增量兩種。在增量繪製中,部分渲染器發生了更改,但是不會影響整個樹。更改後的渲染器將其在螢幕上對應的矩形區域設為無效,這導致 OS 將其視為一塊“dirty 區域”,並生成“paint”事件。OS 會很巧妙地將多個區域合併成一個。在 Chrome 瀏覽器中,情況要更復雜一些,因為 Chrome 瀏覽器的渲染器不在主程式上。Chrome 瀏覽器會在某種程度上模擬 OS 的行為。展示層會偵聽這些事件,並將訊息委託給渲染根節點。然後遍歷渲染樹,直到找到相關的渲染器,該渲染器會重新繪製自己(通常也包括其子代)。

繪製順序

CSS2 規範定義了繪製流程的順序。繪製的順序其實就是元素進入堆疊樣式上下文的順序。這些堆疊會從後往前繪製,因此這樣的順序會影響繪製。塊渲染器的堆疊順序如下:

  1. 背景顏色
  2. 背景圖片
  3. 邊框
  4. 子代
  5. 輪廓

Firefox顯示列表

Firefox 遍歷整個渲染樹,為繪製的矩形建立一個顯示列表。列表中按照正確的繪製順序(先是渲染器的背景,然後是邊框等等)包含了與矩形相關的渲染器。這樣等到重新繪製的時候,只需遍歷一次渲染樹,而不用多次遍歷(繪製所有背景,然後繪製所有圖片,再繪製所有邊框等等)。 Firefox 對此過程進行了優化,也就是不新增隱藏的元素,例如被不透明元素完全遮擋住的元素。

WebKit矩形儲存

在重新繪製之前,WebKit 會將原來的矩形另存為一張點陣圖,然後只繪製新舊矩形之間的差異部分。

動態變化

在發生變化時,瀏覽器會盡可能做出最小的響應。因此,元素的顏色改變後,只會對該元素進行重繪。元素的位置改變後,只會對該元素及其子元素(可能還有同級元素)進行佈局和重繪。新增 DOM 節點後,會對該節點進行佈局和重繪。一些重大變化(例如增大“html”元素的字型)會導致快取無效,使得整個渲染樹都會進行重新佈局和繪製。

渲染引擎的執行緒

渲染引擎採用了單執行緒。幾乎所有操作(除了網路操作)都是在單執行緒中進行的。在 Firefox 和 Safari 中,該執行緒就是瀏覽器的主執行緒。而在 Chrome 瀏覽器中,該執行緒是標籤程式的主執行緒。 網路操作可由多個並行執行緒執行。並行連線數是有限的(通常為 2 至 6 個,以 Firefox 3 為例是 6 個)。

事件迴圈

瀏覽器的主執行緒是事件迴圈。它是一個無限迴圈,永遠處於接受處理狀態,並等待事件(如佈局和繪製事件)發生,並進行處理。這是 Firefox 中關於主事件迴圈的程式碼:

CSS2視覺化模型

畫布

根據 CSS2 規範,“畫布”這一術語是指“用來渲染格式化結構的空間”,也就是供瀏覽器繪製內容的區域。畫布的空間尺寸大小是無限的,但是瀏覽器會根據視口的尺寸選擇一個初始寬度。

根據 www.w3.org/TR/CSS2/zin…

CSS框模型

CSS 框模型描述的是針對文件樹中的元素而生成,並根據視覺化格式模型進行佈局的矩形框。 每個框都有一個內容區域(例如文字、圖片等),還有可選的周圍補白、邊框和邊距區域。

CSS2 框模型

圖19: CSS2 框模型

每一個節點都會生成 0..n 個這樣的框。 所有元素都有一個“display”屬性,決定了它們所對應生成的框型別。示例:

block  - generates a block box.
inline - generates one or more inline boxes.
none - no box is generated.
複製程式碼

預設值是 inline,但是瀏覽器樣式表設定了其他預設值。例如,“div”元素的 display 屬性預設值是 block。 您可以在這裡找到預設樣式表示例:www.w3.org/TR/CSS2/sam…

定位方案

有三種定位方案:

  1. 普通:根據物件在文件中的位置進行定位,也就是說物件在渲染樹中的位置和它在 DOM 樹中的位置相似,並根據其框型別和尺寸進行佈局。
  2. 浮動:物件先按照普通流進行佈局,然後儘可能地向左或向右移動。
  3. 絕對:物件在渲染樹中的位置和它在 DOM 樹中的位置不同。

定位方案是由“position”屬性和“float”屬性設定的。

  • 如果值是 static 和 relative,就是普通流
  • 如果值是 absolute 和 fixed,就是絕對定位

static 定位無需定義位置,而是使用預設定位。對於其他方案,網頁作者需要指定位置:top、bottom、left、right。 框的佈局方式是由以下因素決定的:

  • 框型別
  • 框尺寸
    • 定位方案 外部資訊,例如圖片大小和螢幕大小

框型別

block 框:形成一個 block,在瀏覽器視窗中擁有其自己的矩形區域。

block 框

圖20: block 框

inline 框:沒有自己的 block,但是位於容器 block 內。

inline 框

圖21: inline 框

block 採用的是一個接一個的垂直格式,而 inline 採用的是水平格式。

inline 框

圖22: inline 框

inline 框放置在行中或“行框”中。這些行至少和最高的框一樣高,還可以更高,當框根據“底線”對齊時,這意味著元素的底部需要根據其他框中非底部的位置對齊。如果容器的寬度不夠,inline 元素就會分為多行放置。在段落中經常發生這種情況。

行

圖23: 行

定位

相對定位

相對定位:先按照普通方式定位,然後根據所需偏移量進行移動。

相對定位

圖24: 相對定位
浮動定位

浮動框會移動到行的左邊或右邊。有趣的特徵在於,其他框會浮動在它的周圍。下面這段 HTML 程式碼:

<p>
  <img style="float:right" src="images/image.gif" width="100" height="100">
  Lorem ipsum dolor sit amet, consectetuer...
</p>
複製程式碼

顯示效果如下:

浮動定位

圖25: 浮動定位
絕對定位和固定定位

這種佈局是準確定義的,與普通流無關。元素不參與普通流。尺寸是相對於容器而言的。在固定定位中,容器就是可視區域。

絕對定位和固定定位

圖26: 絕對定位和固定定位

分層展示

這是由 z-index CSS 屬性指定的。它代表了框的第三個維度,也就是沿“z 軸”方向的位置。

這些框分散到多個堆疊(稱為堆疊上下文)中。在每一個堆疊中,會首先繪製後面的元素,然後在頂部繪製前面的元素,以便更靠近使用者。如果出現重疊,新繪製的元素就會覆蓋之前的元素。 堆疊是按照 z-index 屬性進行排序的。具有“z-index”屬性的框形成了本地堆疊。視口具有外部堆疊。

示例:

<style type="text/css">
      div {
        position: absolute;
        left: 2in;
        top: 2in;
      }
</style>

<p>
    <div
         style="z-index: 3;background-color:red; width: 1in; height: 1in; ">
    </div>
    <div
         style="z-index: 1;background-color:green;width: 2in; height: 2in;">
    </div>
 </p>
複製程式碼

結果如下:

固定定位

圖27: 固定定位

雖然紅色 div 在標記中的位置比綠色 div 靠前(按理應該在常規流程中優先繪製),但是 z-index 屬性的優先順序更高,因此它移動到了根框所保持的堆疊中更靠前的位置。

參考

How Browsers Work: Behind the scenes of modern web browsers

結束語

所有內容到此結束,這是我在掘金的第一篇文章,希望大家有所收穫。整理不容易,大家留個贊再走唄!

相關文章