讓咱們來構建一個瀏覽器引擎吧(建議收藏)

image

DevUI是一支兼具設計視角和工程視角的團隊,服務於華爲雲 DevCloud平臺和華爲內部數箇中後臺系統,服務於設計師和前端工程師。

官方網站: devui.design

Ng組件庫: ng-devui(歡迎Star)

官方交流:添加DevUI小助手(devui-official)

DevUIHelper插件:DevUIHelper-LSP(歡迎Star)

引言

前端有一個經典的面試題:在瀏覽器地址欄輸入URL到最終呈現出頁面,中間發生了什麼?css

中間有一個過程是獲取後臺返回的HTML文本,瀏覽器渲染引擎將其解析成DOM樹,並將HTML中的CSS解析成樣式樹,而後將DOM樹和樣式樹合併成佈局樹,並最終由繪圖程序繪製到瀏覽器畫板上。html

本文經過親自動手實踐,教你一步一步實現一個迷你版瀏覽器引擎,進而深刻理解渲染引擎的工做原理,乾貨滿滿。前端

主要分紅七個部分:html5

  • 第一部分:開始
  • 第二部分:HTML
  • 第三部分:CSS
  • 第四部分:樣式
  • 第五部分:盒子
  • 第六部分:塊佈局
  • 第七部分:繪製 101

原文寫於2014.8.8。node

原文地址:https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.htmlcss3

如下是正文:c++

第一部分:開始

我正在構建一個「玩具」渲染引擎,我認爲你也應該這樣作。這是一系列文章中的第一篇。git

完整的系列文章將描述我編寫的代碼,並向你展現如何編寫本身的代碼。但首先,讓我解釋一下緣由。github

你在造什麼?

讓咱們談談術語。瀏覽器引擎是web瀏覽器的一部分,它在「底層」工做,從Internet上獲取網頁,並將其內容轉換成能夠閱讀、觀看、聽等形式。Blink、Gecko、WebKit和Trident都是瀏覽器引擎。相比之下,瀏覽器自己的用戶界面(標籤、工具欄、菜單等)被稱爲chrome。Firefox和SeaMonkey是兩個瀏覽器,使用不一樣的chrome,但使用相同的Gecko引擎。web

瀏覽器引擎包括許多子組件:HTTP客戶端、HTML解析器、CSS解析器、JavaScript引擎(自己由解析器、解釋器和編譯器組成)等等。那些涉及解析HTML和CSS等web格式,並將其轉換成你在屏幕上看到的內容的組件,有時被稱爲佈局引擎或渲染引擎。

爲何是一個「玩具」渲染引擎?

一個功能齊全的瀏覽器引擎很是複雜。BlinkGeckoWebKit,它們每個都有數百萬行代碼。更年輕、更簡單的渲染引擎,如ServoWeasyPrint,也有成千上萬行。這對一個新手來講是不容易理解的!

說到很是複雜的軟件:若是你參加了編譯器或操做系統的課程,在某些時候你可能會建立或修改一個「玩具」編譯器或內核。這是一個爲學習而設計的簡單模型;它可能永遠不會由做者之外的任何人管理。可是

製做一個玩具系統對於瞭解真實的東西是如何工做的是一個有用的工具。

即便你從未構建過真實的編譯器或內核,

瞭解它們的工做方式也能夠幫助你在編寫本身的程序時更好地使用它們。

所以,若是你想成爲一名瀏覽器開發人員,或者只是想了解瀏覽器引擎內部發生了什麼,爲何不構建一個玩具呢?就像實現「真正的」編程語言子集的玩具編譯器同樣,玩具渲染引擎也能夠實現HTML和CSS的一小部分。它不會取代平常瀏覽器中的引擎,但應該可以說明呈現一個簡單HTML文檔所需的基本步驟。

在家試試吧。

我但願我已經說服你去試一試了。若是你已經有一些紮實的編程經驗並瞭解一些高級HTML和CSS概念,那麼學習本系列將會很是容易。然而,若是你剛剛開始學習這些東西,或者遇到你不理解的東西,請隨意問問題,我會盡可能讓它更清楚。

在你開始以前,我想告訴你一些你能夠作的選擇:

關於編程語言

你能夠用任何編程語言構建一個玩具式的佈局引擎,真的!用一門你瞭解和喜好的語言吧。若是這聽起來頗有趣,你也能夠

以此爲藉口學習一門新語言。

若是你想開始爲主要的瀏覽器引擎(如Gecko或WebKit)作貢獻,你可能但願使用C++,由於C++是這些引擎中使用的主要語言,使用C++能夠更容易地將你的代碼與它們的代碼進行比較。

我本身的玩具項目,robinson,是用Rust寫的。我是Mozilla的Servo團隊的一員,因此我很是喜歡Rust編程。此外,我建立這個項目的目標之一是瞭解更多的Servo的實現。Robinson有時會使用Servo的簡化版本的數據結構和代碼。

關於庫和捷徑

在這樣的學習練習中,你必須決定是使用別人的代碼,仍是從頭編寫本身的代碼。個人建議是

爲你真正想要理解的部分編寫你本身的代碼,可是不要羞於爲其餘的部分使用庫。

學習如何使用特定的庫自己就是一項有價值的練習。

我寫robinson不只僅是爲了我本身,也是爲了做爲這些文章和練習的示例代碼。出於這樣或那樣的緣由,我但願它儘量地小巧和獨立。到目前爲止,除了Rust標準庫以外,我沒有使用任何外部代碼。(這也避免了使用同一版本的Rust來構建多個依賴的小麻煩,而該語言仍在開發中。)不過,這個規則並非一成不變的。例如,我之後可能會決定使用圖形庫,而不是編寫本身的低級繪圖代碼。

另外一種避免編寫代碼的方法是省略一些內容。例如,robinson尚未網絡代碼;它只能讀取本地文件。在一個玩具程序中,若是你想跳過一些東西,你能夠跳過。我將在討論過程當中指出相似的潛在捷徑,這樣你就能夠繞過不感興趣的步驟,直接跳到好的內容。若是你改變了主意,你能夠在之後再補上空白。

第一步:DOM

準備好寫代碼了嗎?咱們將從一些小的東西開始:DOM的數據結構。讓咱們看看robinson的dom模塊

DOM是一個節點樹。一個節點有零個或多個子節點。(它還有其餘各類屬性和方法,但咱們如今能夠忽略其中的大部分。)

struct Node {
  // data common to all nodes:
  children: Vec<Node>,

  // data specific to each node type:
  node_type: NodeType,
}

有多種節點類型,但如今咱們將忽略其中的大多數,並將節點定義爲元素節點文本節點。在具備繼承的語言中,這些是Node的子類型。在Rust中,它們能夠是枚舉enum(Rust的關鍵字用於「tagged union」或「sum type」):

enum NodeType {
  Text(String),
  Element(ElementData),
}

元素包括一個標記名稱和任意數量的屬性,它們能夠存儲爲從名稱到值的映射。Robinson不支持名稱空間,因此它只將標記和屬性名稱存儲爲簡單的字符串。

struct ElementData {
    tag_name: String,
    attributes: AttrMap,
}

type AttrMap = HashMap<String, String>;

最後,一些構造函數使建立新節點變得容易:

fn text(data: String) -> Node {
    Node { children: Vec::new(), node_type: NodeType::Text(data) }
}

fn elem(name: String, attrs: AttrMap, children: Vec<Node>) -> Node {
    Node {
        children: children,
        node_type: NodeType::Element(ElementData {
            tag_name: name,
            attributes: attrs,
        })
    }
}

這是它!一個成熟的DOM實現將包含更多的數據和幾十個方法,但這就是咱們開始所須要的。

練習

這些只是一些在家能夠遵循的建議。作你感興趣的練習,跳過不感興趣的。

  1. 用你選擇的語言啓動一個新程序,並編寫代碼來表示DOM文本節點和元素樹。
  2. 安裝最新版本的Rust,而後下載並構建robinson。打開dom.rs和擴展NodeType以包含其餘類型,如註釋節點
  3. 編寫代碼來美化DOM節點樹。

在下一篇文章中,咱們將添加一個將HTML源代碼轉換爲這些DOM節點樹的解析器。

參考文獻

有關瀏覽器引擎內部結構的更多詳細信息,請參閱Tali Garsiel很是精彩的瀏覽器的工做原理及其到更多資源的連接。

例如代碼,這裏有一個「小型」開源web呈現引擎的簡短列表。它們大多比robinson大不少倍,但仍然比Gecko或WebKit小得多。只有2000行代碼的WebWhirr是惟一一個我稱之爲「玩具」引擎的引擎。

你可能會發現這些有用的靈感或參考。若是你知道任何其餘相似的項目,或者若是你開始本身的項目,請讓我知道!

第二部分:HTML

這是構建一個玩具瀏覽器渲染引擎系列文章的第二篇。

本文是關於解析HTML源代碼以生成DOM節點樹的。解析是一個很吸引人的話題,可是我沒有足夠的時間或專業知識來介紹它。你能夠從任何關於編譯器的優秀課程書籍中得到關於解析的詳細介紹。或者經過閱讀與你選擇的編程語言一塊兒工做的解析器生成器的文檔來得到動手操做的開始。

HTML有本身獨特的解析算法。與大多數編程語言和文件格式的解析器不一樣,HTML解析算法不會拒絕無效的輸入。相反,它包含了特定的錯誤處理指令,所以web瀏覽器能夠就如何顯示每一個web頁面達成一致,即便是那些不符合語法規則的頁面。Web瀏覽器必須作到這一點才能使用:由於不符合標準的HTML在Web早期就獲得了支持,因此如今大部分現有Web頁面都在使用它。

簡單的HTML方言

我甚至沒有嘗試實現標準的HTML解析算法。相反,我爲HTML語法的一小部分編寫了一個基本解析器。個人解析器能夠處理這樣的簡單頁面:

<html>
    <body>
        <h1>Title</h1>
        <div id="main" class="test">
            <p>Hello <em>world</em>!</p>
        </div>
    </body>
</html>

容許使用如下語法:

  • 閉合的標籤:<p>…</p>
  • 帶引號的屬性:id="main"
  • 文本節點:<em>world</em>

其餘全部內容都不支持,包括:

  • 評論
  • Doctype聲明
  • 轉義字符(如&amp;)和CDATA節
  • 自結束標籤:<br/><br>沒有結束標籤
  • 錯誤處理(例如未閉合或不正確嵌套的標籤)
  • 名稱空間和其餘XHTML語法:<html:body>
  • 字符編碼檢測

在這個項目的每一個階段,我都或多或少地編寫了支持後面階段所需的最小代碼。可是若是你想學習更多的解析理論和工具,你能夠在你本身的項目中更加雄心勃勃!

示例代碼

接下來,讓咱們回顧一下個人HTML解析器,記住這只是一種方法(並且可能不是最好的方法)。它的結構鬆散地基於Servo的cssparser庫中的tokenizer模塊。它沒有真正的錯誤處理;在大多數狀況下,它只是在遇到意外的語法時停止。代碼是用Rust語言寫的,但我但願它對於使用相似語言(如Java、C++或C#)的人來講具備至關的可讀性。它使用了第一部分中的DOM數據結構。

解析器將其輸入字符串和當前位置存儲在字符串中。位置是咱們尚未處理的下一個字符的索引。

struct Parser {
    pos: usize, // "usize" is an unsigned integer, similar to "size_t" in C
    input: String,
}

咱們能夠用它來實現一些簡單的方法來窺視輸入中的下一個字符:

impl Parser {
    // Read the current character without consuming it.
    fn next_char(&self) -> char {
        self.input[self.pos..].chars().next().unwrap()
    }

    // Do the next characters start with the given string?
    fn starts_with(&self, s: &str) -> bool {
        self.input[self.pos ..].starts_with(s)
    }

    // Return true if all input is consumed.
    fn eof(&self) -> bool {
        self.pos >= self.input.len()
    }

    // ...
}

Rust字符串存儲爲UTF-8字節數組。要進入下一個字符,咱們不能只前進一個字節。相反,咱們使用char_indices來正確處理多字節字符。(若是咱們的字符串使用固定寬度的字符,咱們能夠只將pos加1。)

// Return the current character, and advance self.pos to the next character.
fn consume_char(&mut self) -> char {
    let mut iter = self.input[self.pos..].char_indices();
    let (_, cur_char) = iter.next().unwrap();
    let (next_pos, _) = iter.next().unwrap_or((1, ' '));
    self.pos += next_pos;
    return cur_char;
}

一般咱們想要使用一個連續的字符串。consume_while方法使用知足給定條件的字符,並將它們做爲字符串返回。這個方法的參數是一個函數,它接受一個char並返回一個bool值。

// Consume characters until `test` returns false.
fn consume_while<F>(&mut self, test: F) -> String
        where F: Fn(char) -> bool {
    let mut result = String::new();
    while !self.eof() && test(self.next_char()) {
        result.push(self.consume_char());
    }
    return result;
}

咱們可使用它來忽略空格字符序列,或者使用字母數字字符串:

// Consume and discard zero or more whitespace characters.
fn consume_whitespace(&mut self) {
    self.consume_while(CharExt::is_whitespace);
}

// Parse a tag or attribute name.
fn parse_tag_name(&mut self) -> String {
    self.consume_while(|c| match c {
        'a'...'z' | 'A'...'Z' | '0'...'9' => true,
        _ => false
    })
}

如今咱們已經準備好開始解析HTML了。要解析單個節點,咱們查看它的第一個字符,看它是元素節點仍是文本節點。在咱們簡化的HTML版本中,文本節點能夠包含除<以外的任何字符。

// Parse a single node.
fn parse_node(&mut self) -> dom::Node {
    match self.next_char() {
        '<' => self.parse_element(),
        _   => self.parse_text()
    }
}

// Parse a text node.
fn parse_text(&mut self) -> dom::Node {
    dom::text(self.consume_while(|c| c != '<'))
}

一個元素更爲複雜。它包括開始和結束標籤,以及在它們之間任意數量的子節點:

// Parse a single element, including its open tag, contents, and closing tag.
fn parse_element(&mut self) -> dom::Node {
    // Opening tag.
    assert!(self.consume_char() == '<');
    let tag_name = self.parse_tag_name();
    let attrs = self.parse_attributes();
    assert!(self.consume_char() == '>');

    // Contents.
    let children = self.parse_nodes();

    // Closing tag.
    assert!(self.consume_char() == '<');
    assert!(self.consume_char() == '/');
    assert!(self.parse_tag_name() == tag_name);
    assert!(self.consume_char() == '>');

    return dom::elem(tag_name, attrs, children);
}

在咱們簡化的語法中,解析屬性很是容易。在到達開始標記(>)的末尾以前,咱們重複地查找後面跟着=的名稱,而後是用引號括起來的字符串。

// Parse a single name="value" pair.
fn parse_attr(&mut self) -> (String, String) {
    let name = self.parse_tag_name();
    assert!(self.consume_char() == '=');
    let value = self.parse_attr_value();
    return (name, value);
}

// Parse a quoted value.
fn parse_attr_value(&mut self) -> String {
    let open_quote = self.consume_char();
    assert!(open_quote == '"' || open_quote == '\'');
    let value = self.consume_while(|c| c != open_quote);
    assert!(self.consume_char() == open_quote);
    return value;
}

// Parse a list of name="value" pairs, separated by whitespace.
fn parse_attributes(&mut self) -> dom::AttrMap {
    let mut attributes = HashMap::new();
    loop {
        self.consume_whitespace();
        if self.next_char() == '>' {
            break;
        }
        let (name, value) = self.parse_attr();
        attributes.insert(name, value);
    }
    return attributes;
}

爲了解析子節點,咱們在循環中遞歸地調用parse_node,直到到達結束標記。這個函數返回一個Vec,這是Rust對可增加數組的名稱。

// Parse a sequence of sibling nodes.
fn parse_nodes(&mut self) -> Vec<dom::Node> {
    let mut nodes = Vec::new();
    loop {
        self.consume_whitespace();
        if self.eof() || self.starts_with("</") {
            break;
        }
        nodes.push(self.parse_node());
    }
    return nodes;
}

最後,咱們能夠把全部這些放在一塊兒,將整個HTML文檔解析成DOM樹。若是文檔沒有顯式包含根節點,則該函數將爲文檔建立根節點;這與真正的HTML解析器的功能相似。

// Parse an HTML document and return the root element.
pub fn parse(source: String) -> dom::Node {
    let mut nodes = Parser { pos: 0, input: source }.parse_nodes();

    // If the document contains a root element, just return it. Otherwise, create one.
    if nodes.len() == 1 {
        nodes.swap_remove(0)
    } else {
        dom::elem("html".to_string(), HashMap::new(), nodes)
    }
}

就是這樣!robinson HTML解析器的所有代碼。整個程序總共只有100多行代碼(不包括空白行和註釋)。若是你使用一個好的庫或解析器生成器,你可能能夠在更少的空間中構建一個相似的玩具解析器。

練習

這裏有一些你能夠本身嘗試的替代方法。與前面同樣,你能夠選擇其中的一個或多個,並忽略其餘。

  1. 構建一個以HTML子集做爲輸入並生成DOM節點樹的解析器(「手動」或使用庫或解析器生成器)。
  2. 修改robinson的HTML解析器,添加一些缺失的特性,好比註釋。或者用更好的解析器替換它,可能使用庫或生成器構建。
  3. 建立一個無效的HTML文件,致使你的(或個人)解析器失敗。修改解析器以從錯誤中恢復,併爲測試文件生成DOM樹。

捷徑

若是想徹底跳過解析,能夠經過編程方式構建DOM樹,向程序中添加相似這樣的代碼(僞代碼,調整它以匹配第1部分中編寫的DOM代碼):

// <html><body>Hello, world!</body></html>
let root = element("html");
let body = element("body");
root.children.push(body);
body.children.push(text("Hello, world!"));

或者你能夠找到一個現有的HTML解析器並將其合併到你的程序中。

本系列的下一篇文章將討論CSS數據結構和解析。

第三部分:CSS

本文是構建玩具瀏覽器呈現引擎系列文章中的第三篇。

本文介紹了用於讀取層疊樣式表(CSS)的代碼。像往常同樣,我不會試圖涵蓋該規範中的全部內容。相反,我嘗試實現足以說明一些概念併爲後期渲染管道生成輸入的內容。

剖析樣式表

下面是一個CSS源代碼示例:

h1, h2, h3 { margin: auto; color: #cc0000; }
div.note { margin-bottom: 20px; padding: 10px; }
#answer { display: none; }

接下來,我將從個人玩具瀏覽器引擎robinson中瀏覽css模塊。雖然這些概念能夠很容易地轉換成其餘編程語言,但代碼仍是用Rust寫的。先閱讀前面的文章可能會幫助您理解下面的一些代碼。

CSS樣式表是一系列規則。(在上面的示例樣式表中,每行包含一條規則。)

struct Stylesheet {
    rules: Vec<Rule>,
}

一條規則包括一個或多個用逗號分隔的選擇器,後跟一系列用大括號括起來的聲明。

struct Rule {
    selectors: Vec<Selector>,
    declarations: Vec<Declaration>,
}

一個選擇器能夠是一個簡單的選擇器,也能夠是一個由組合符鏈接的選擇器鏈。Robinson目前只支持簡單的選擇器。

注意:使人困惑的是,新的 Selectors Level 3標準使用相同的術語來表示略有不一樣的東西。在本文中,我主要引用CSS2.1。儘管過期了,但它是一個有用的起點,由於它更小,更獨立(與CSS3相比,CSS3被分紅無數互相依賴和CSS2.1的規範)。

在robinson中,一個簡單選擇器能夠包括一個標記名,一個以'#'爲前綴的ID,任意數量的以'.'爲前綴的類名,或以上幾種狀況的組合。若是標籤名爲空或'*',那麼它是一個「通用選擇器」,能夠匹配任何標籤。

還有許多其餘類型的選擇器(特別是在CSS3中),但如今這樣就能夠了。

enum Selector {
    Simple(SimpleSelector),
}

struct SimpleSelector {
    tag_name: Option<String>,
    id: Option<String>,
    class: Vec<String>,
}

聲明只是一個名稱/值對,由冒號分隔並以分號結束。例如,「margin: auto;」是一個聲明。

struct Declaration {
    name: String,
    value: Value,
}

個人玩具引擎只支持CSS衆多值類型中的一小部分。

enum Value {
    Keyword(String),
    Length(f32, Unit),
    ColorValue(Color),
    // insert more values here
}

enum Unit {
    Px,
    // insert more units here
}

struct Color {
    r: u8,
    g: u8,
    b: u8,
    a: u8,
}
注意:u8是一個8位無符號整數,f32是一個32位浮點數。

不支持全部其餘CSS語法,包括@-rules、註釋和上面沒有提到的任何選擇器/值/單元。

解析

CSS有一個規則的語法,這使得它比它古怪的表親HTML更容易正確解析。當符合標準的CSS解析器遇到解析錯誤時,它會丟棄樣式表中沒法識別的部分,但仍然處理其他部分。這是頗有用的,由於它容許樣式表包含新的語法,但在舊的瀏覽器中仍然產生定義良好的輸出。

Robinson使用了一個很是簡單(徹底不符合標準)的解析器,構建的方式與第2部分中的HTML解析器相同。我將粘貼一些代碼片斷,而不是一行一行地重複整個過程。例如,下面是解析單個選擇器的代碼:

// Parse one simple selector, e.g.: `type#id.class1.class2.class3`
fn parse_simple_selector(&mut self) -> SimpleSelector {
    let mut selector = SimpleSelector { tag_name: None, id: None, class: Vec::new() };
    while !self.eof() {
        match self.next_char() {
            '#' => {
                self.consume_char();
                selector.id = Some(self.parse_identifier());
            }
            '.' => {
                self.consume_char();
                selector.class.push(self.parse_identifier());
            }
            '*' => {
                // universal selector
                self.consume_char();
            }
            c if valid_identifier_char(c) => {
                selector.tag_name = Some(self.parse_identifier());
            }
            _ => break
        }
    }
    return selector;
}

注意沒有錯誤檢查。一些格式不正確的輸入,如###*foo*將成功解析併產生奇怪的結果。真正的CSS解析器會丟棄這些無效的選擇器。

優先級

優先級是渲染引擎在衝突中決定哪種樣式覆蓋另外一種樣式的方法之一。若是一個樣式表包含兩個匹配元素的規則,具備較高優先級的匹配選擇器的規則能夠覆蓋較低優先級的選擇器中的值。

選擇器的優先級基於它的組件。ID選擇器比類選擇器優先級更高,類選擇器比標籤選擇器優先級更高。在每一個「層級」中,選擇器越多優先級越高。

pub type Specificity = (usize, usize, usize);

impl Selector {
    pub fn specificity(&self) -> Specificity {
        // http://www.w3.org/TR/selectors/#specificity
        let Selector::Simple(ref simple) = *self;
        let a = simple.id.iter().count();
        let b = simple.class.len();
        let c = simple.tag_name.iter().count();
        (a, b, c)
    }
}

(若是咱們支持鏈選擇器,咱們能夠經過將鏈各部分的優先級相加來計算鏈的優先級。)

每一個規則的選擇器都存儲在排序的向量中,優先級最高的優先。這對於匹配很是重要,我將在下一篇文章中介紹。

// Parse a rule set: `<selectors> { <declarations> }`.
fn parse_rule(&mut self) -> Rule {
    Rule {
        selectors: self.parse_selectors(),
        declarations: self.parse_declarations()
    }
}

// Parse a comma-separated list of selectors.
fn parse_selectors(&mut self) -> Vec<Selector> {
    let mut selectors = Vec::new();
    loop {
        selectors.push(Selector::Simple(self.parse_simple_selector()));
        self.consume_whitespace();
        match self.next_char() {
            ',' => { self.consume_char(); self.consume_whitespace(); }
            '{' => break, // start of declarations
            c   => panic!("Unexpected character {} in selector list", c)
        }
    }
    // Return selectors with highest specificity first, for use in matching.
    selectors.sort_by(|a,b| b.specificity().cmp(&a.specificity()));
    return selectors;
}

CSS解析器的其他部分至關簡單。你能夠在GitHub上閱讀全文。若是您在第2部分中尚未這樣作,那麼如今是嘗試解析器生成器的絕佳時機。個人手卷解析器完成了簡單示例文件的工做,但它有不少漏洞,若是您違反了它的假設,它將嚴重失敗。有一天,我可能會用rust-peg或相似的東西來取代它。

練習

和之前同樣,你應該決定你想作哪些練習,並跳過其他的:

  1. 實現您本身的簡化CSS解析器和優先級計算。
  2. 擴展robinson的CSS解析器,以支持更多的值,或一個或多個選擇器組合符。
  3. 擴展CSS解析器,丟棄任何包含解析錯誤的聲明,並遵循錯誤處理規則,在聲明結束後繼續解析。
  4. 讓HTML解析器將任何<style>節點的內容傳遞給CSS解析器,並返回一個文檔對象,該對象除了DOM樹以外還包含一個樣式表列表。

捷徑

就像在第2部分中同樣,您能夠經過直接將CSS數據結構硬編碼到您的程序中來跳過解析,或者經過使用已經有解析器的JSON等替代格式來編寫它們。

未完待續

下一篇文章將介紹style模塊。在這裏,全部的一切都開始結合在一塊兒,選擇器匹配以將CSS樣式應用到DOM節點。

這個系列的進度可能很快就會慢下來,由於這個月晚些時候我會很忙,我甚至尚未爲即將發表的一些文章編寫代碼。我會讓他們儘快趕到的!

第四部分:樣式

歡迎回到我關於構建本身的玩具瀏覽器引擎的系列文章。

本文將介紹CSS標準所稱的爲屬性值賦值,也就是我所說的樣式模塊。此模塊將DOM節點和CSS規則做爲輸入,並將它們匹配起來,以肯定任何給定節點的每一個CSS屬性的值。

這部分不包含不少代碼,由於我沒有實現真正複雜的部分。然而,我認爲剩下的部分仍然頗有趣,我還將解釋一些缺失的部分如何實現。

樣式樹

robinson的樣式模塊的輸出是我稱之爲樣式樹的東西。這棵樹中的每一個節點都包含一個指向DOM節點的指針,以及它的CSS屬性值:

// Map from CSS property names to values.
type PropertyMap = HashMap<String, Value>;

// A node with associated style data.
struct StyledNode<'a> {
    node: &'a Node, // pointer to a DOM node
    specified_values: PropertyMap,
    children: Vec<StyledNode<'a>>,
}
這些 'a是什麼?這些都是 生存期,這是Rust如何保證指針是內存安全的,而不須要進行垃圾回收的部分緣由。若是你不是在Rust的環境中工做,你能夠忽略它們;它們對代碼的意義並不重要。

咱們能夠向dom::Node結構添加新的字段,而不是建立一個新的樹,但我想讓樣式代碼遠離早期的「教訓」。這也讓我有機會討論大多數渲染引擎中的平行樹。

瀏覽器引擎模塊一般以一個樹做爲輸入,而後產生一個不一樣但相關的樹做爲輸出。例如,Gecko的佈局代碼獲取一個DOM樹並生成一個框架樹,而後使用它來構建一個視圖樹。Blink和WebKit將DOM樹轉換爲渲染樹。全部這些引擎的後期階段會產生更多的樹,包括層樹和部件樹。

在咱們完成了更多的階段後,咱們的玩具瀏覽器引擎的管道將看起來像這樣:

在個人實現中,DOM樹中的每一個節點在樣式樹中只有一個節點。但在更復雜的管道階段,幾個輸入節點可能會分解爲一個輸出節點。或者一個輸入節點可能擴展爲幾個輸出節點,或者徹底跳過。例如,樣式樹能夠排除顯示屬性設置爲'none'的元素。(相反,我將在佈局階段刪除這些內容,由於這樣個人代碼會變得更簡單一些。)

選擇器匹配

構建樣式樹的第一步是選擇器匹配。這將很是容易,由於個人CSS解析器只支持簡單的選擇器。您能夠經過查看元素自己來判斷一個簡單的選擇器是否匹配一個元素。匹配複合選擇器須要遍歷DOM樹以查看元素的兄弟元素、父元素等。

fn matches(elem: &ElementData, selector: &Selector) -> bool {
    match *selector {
        Simple(ref simple_selector) => matches_simple_selector(elem, simple_selector)
    }
}

爲了有所幫助,咱們將向DOM元素類型添加一些方便的ID和類訪問器。class屬性能夠包含多個用空格分隔的類名,咱們在散列表中返回這些類名。

impl ElementData {
    pub fn id(&self) -> Option<&String> {
        self.attributes.get("id")
    }

    pub fn classes(&self) -> HashSet<&str> {
        match self.attributes.get("class") {
            Some(classlist) => classlist.split(' ').collect(),
            None => HashSet::new()
        }
    }
}

要測試一個簡單的選擇器是否匹配一個元素,只需查看每一個選擇器組件,若是元素沒有匹配的類、ID或標記名,則返回false。

fn matches_simple_selector(elem: &ElementData, selector: &SimpleSelector) -> bool {
    // Check type selector
    if selector.tag_name.iter().any(|name| elem.tag_name != *name) {
        return false;
    }

    // Check ID selector
    if selector.id.iter().any(|id| elem.id() != Some(id)) {
        return false;
    }

    // Check class selectors
    let elem_classes = elem.classes();
    if selector.class.iter().any(|class| !elem_classes.contains(&**class)) {
        return false;
    }

    // We didn't find any non-matching selector components.
    return true;
}
注意:這個函數使用any方法,若是迭代器包含一個經過所提供的測試的元素,則該方法返回true。這與Python中的any函數(或Haskell)或JavaScript中的some方法相同。

構建樣式樹

接下來,咱們須要遍歷DOM樹。對於樹中的每一個元素,咱們將在樣式表中搜索匹配規則。

當比較兩個匹配相同元素的規則時,咱們須要使用來自每一個匹配的最高優先級選擇器。由於咱們的CSS解析器存儲了從優先級從高低的選擇器,因此只要找到了匹配的選擇器,咱們就能夠中止,並返回它的優先級以及指向規則的指針。

type MatchedRule<'a> = (Specificity, &'a Rule);

// If `rule` matches `elem`, return a `MatchedRule`. Otherwise return `None`.
fn match_rule<'a>(elem: &ElementData, rule: &'a Rule) -> Option<MatchedRule<'a>> {
    // Find the first (highest-specificity) matching selector.
    rule.selectors.iter()
        .find(|selector| matches(elem, *selector))
        .map(|selector| (selector.specificity(), rule))
}

爲了找到與一個元素匹配的全部規則,咱們稱之爲filter_map,它對樣式表進行線性掃描,檢查每一個規則並排除不匹配的規則。真正的瀏覽器引擎會根據標籤名稱、id、類等將規則存儲在多個散列表中,從而加快速度。

// Find all CSS rules that match the given element.
fn matching_rules<'a>(elem: &ElementData, stylesheet: &'a Stylesheet) -> Vec<MatchedRule<'a>> {
    stylesheet.rules.iter().filter_map(|rule| match_rule(elem, rule)).collect()
}

一旦有了匹配規則,就能夠爲元素找到指定的值。咱們將每一個規則的屬性值插入到HashMap中。咱們根據優先級對匹配進行排序,所以在較不特定的規則以後處理更特定的規則,並能夠覆蓋它們在HashMap中的值。

// Apply styles to a single element, returning the specified values.
fn specified_values(elem: &ElementData, stylesheet: &Stylesheet) -> PropertyMap {
    let mut values = HashMap::new();
    let mut rules = matching_rules(elem, stylesheet);

    // Go through the rules from lowest to highest specificity.
    rules.sort_by(|&(a, _), &(b, _)| a.cmp(&b));
    for (_, rule) in rules {
        for declaration in &rule.declarations {
            values.insert(declaration.name.clone(), declaration.value.clone());
        }
    }
    return values;
}

如今,咱們已經擁有遍歷DOM樹和構建樣式樹所需的一切。注意,選擇器匹配只對元素有效,所以文本節點的指定值只是一個空映射。

// Apply a stylesheet to an entire DOM tree, returning a StyledNode tree.
pub fn style_tree<'a>(root: &'a Node, stylesheet: &'a Stylesheet) -> StyledNode<'a> {
    StyledNode {
        node: root,
        specified_values: match root.node_type {
            Element(ref elem) => specified_values(elem, stylesheet),
            Text(_) => HashMap::new()
        },
        children: root.children.iter().map(|child| style_tree(child, stylesheet)).collect(),
    }
}

這就是robinson構建樣式樹的所有代碼。接下來我將討論一些明顯的遺漏。

級聯

由web頁面的做者提供的樣式表稱爲做者樣式表。除此以外,瀏覽器還經過用戶代理樣式表提供默認樣式。它們可能容許用戶經過用戶樣式表(如Gecko的userContent.css)添加自定義樣式。

級聯定義這三個「起源」中哪一個優先於另外一個。級聯有6個級別:一個用於每一個起源的「正常」聲明,另外一個用於每一個起源的!important聲明。

Robinson的風格代碼沒有實現級聯;它只須要一個樣式表。缺乏默認樣式表意味着HTML元素將不具備任何您可能指望的默認樣式。例如,<head>元素的內容不會被隱藏,除非你顯式地把這個規則添加到你的樣式表中:

head { display: none; }

實現級聯應該至關簡單:只需跟蹤每一個規則的起源,並根據起源和重要性以及特殊性對聲明進行排序。一個簡化的、兩級的級聯應該足以支持最多見的狀況:普通用戶代理樣式和普通做者樣式。

計算的值

除了上面提到的「指定值」以外,CSS還定義了初始值計算值使用值實際值

初始值是沒有在級聯中指定的屬性的默認值。計算值基於指定值,但可能應用一些特定於屬性的規範化規則。

根據CSS規範中的定義,正確實現這些須要爲每一個屬性單獨編寫代碼。對於一個真實的瀏覽器引擎來講,這項工做是必要的,但我但願在這個玩具項目中避免它。在後面的階段,當指定的值缺失時,使用這些值的代碼將(某種程度上)經過使用默認值模擬初始值。

使用值實際值是在佈局期間和以後計算的,我將在之後的文章中介紹。

繼承

若是文本節點不能匹配選擇器,它們如何得到顏色、字體和其餘樣式?答案是繼承

當屬性被繼承時,任何沒有級聯值的節點都將接收該屬性的父節點值。有些屬性,如'color',是默認繼承的;其餘僅當級聯指定特殊值「inherit」時使用。

個人代碼不支持繼承。要實現它,能夠將父類的樣式數據傳遞到specified_values函數,並使用硬編碼的查找表來決定應該繼承哪些屬性。

樣式屬性

任何HTML元素均可以包含一個包含CSS聲明列表的樣式屬性。沒有選擇器,由於這些聲明自動只應用於元素自己。

<span style="color: red; background: yellow;">

若是您想要支持style屬性,請使用specified_values函數檢查該屬性。若是存在該屬性,則將其從CSS解析器傳遞給parse_declarations。在普通的做者聲明以後應用結果聲明,由於屬性比任何CSS選擇器都更特定。

練習

除了編寫本身的選擇器匹配和值賦值代碼以外,你還能夠在本身的項目或robinson的分支中實現上面討論的一個或多個缺失的部分:

  1. 級聯
  2. 初始值和/或計算值
  3. 繼承
  4. 樣式屬性

另外,若是您從第3部分擴展了CSS解析器以包含複合選擇器,那麼如今能夠實現對這些複合選擇器的匹配。

未完待續

第5部分將介紹佈局模塊。我尚未完成代碼,因此在我開始寫這篇文章以前還會有另外一個延遲。我計劃將佈局分紅至少兩篇文章(一篇是塊佈局,一篇多是內聯佈局)。

與此同時,我但願看到您根據這些文章或練習建立的任何東西。若是你的代碼在某個地方,請在下面添加一個連接!到目前爲止,我已經看到了Martin Tomasi的Java實現和Pohl longsin的Swift版本

第5部分:盒子

這是關於編寫一個簡單的HTML渲染引擎的系列文章中的第5篇。

本文將開始佈局模塊,該模塊獲取樣式樹並將其轉換爲二維空間中的一堆矩形。這是一個很大的模塊,因此我將把它分紅幾篇文章。另外,在我爲後面的部分編寫代碼時,我在本文中分享的一些代碼可能須要更改。

佈局模塊的輸入是第4部分中的樣式樹,它的輸出是另外一棵樹,即佈局樹。這使咱們的迷你渲染管道更進一步:

我將從基本的HTML/CSS佈局模型開始討論。若是您曾經學習過如何開發web頁面,那麼您可能已經熟悉了這一點,可是從實現者的角度來看,它可能有點不一樣。

盒模型

佈局就是方框。方框是網頁的一個矩形部分。它具備頁面上的寬度、高度和位置。這個矩形稱爲內容區域,由於它是框的內容繪製的地方。內容能夠是文本、圖像、視頻或其餘框。

框還能夠在其內容區域周圍有內邊距、邊框和邊距。CSS規範中有一個圖表顯示全部這些層是如何組合在一塊兒的。

Robinson將盒子的內容區域和周圍區域存儲在下面的結構中。[Rust注:f32是32位浮點型。]

// CSS box model. All sizes are in px.

struct Dimensions {
    // Position of the content area relative to the document origin:
    content: Rect,

    // Surrounding edges:
    padding: EdgeSizes,
    border: EdgeSizes,
    margin: EdgeSizes,
}

struct Rect {
    x: f32,
    y: f32,
    width: f32,
    height: f32,
}

struct EdgeSizes {
    left: f32,
    right: f32,
    top: f32,
    bottom: f32,
}

塊和內聯佈局

注意:這部分包含的圖表若是沒有相關的視覺樣式,就沒有意義。若是您是在一個提要閱讀器中閱讀這篇文章,嘗試在一個常規的瀏覽器選項卡中打開原始頁面。我還爲使用屏幕閱讀器或其餘輔助技術的讀者提供了文本描述。

CSS display屬性決定一個元素生成哪一種類型的框。CSS定義了幾種框類型,每種都有本身的佈局規則。我只講其中的兩種:塊和內聯。

我將使用這一點僞html來講明區別:

<container>
  <a></a>
  <b></b>
  <c></c>
  <d></d>
</container>

塊級框從上到下垂直地放置在容器中。

a, b, c, d { display: block; }

行內框從左到右水平地放置在容器中。若是它們到達了容器的右邊緣,它們將環繞並繼續在下面的新行。

a, b, c, d { display: inline; }

每一個框必須只包含塊級子元素或行內子元素。當DOM元素包含塊級子元素和內聯子元素時,佈局引擎會插入匿名框來分隔這兩種類型。(這些框是「匿名的」,由於它們與DOM樹中的節點沒有關聯。)

在這個例子中,內聯框b和c被一個匿名塊框包圍,粉紅色顯示:

a    { display: block; }
b, c { display: inline; }
d    { display: block; }

注意,內容默認垂直增加。也就是說,向容器中添加子元素一般會使容器更高,而不是更寬。另外一種說法是,默認狀況下,塊或行的寬度取決於其容器的寬度,而容器的高度取決於其子容器的高度。

若是你覆蓋了屬性的默認值,好比寬度和高度,這將變得更加複雜,若是你想要支持像垂直書寫這樣的特性,這將變得更加複雜。

佈局樹

佈局樹是一個框的集合。一個盒子有尺寸,它可能包含子盒子。

struct LayoutBox<'a> {
    dimensions: Dimensions,
    box_type: BoxType<'a>,
    children: Vec<LayoutBox<'a>>,
}

框能夠是塊節點、內聯節點或匿名塊框。(當我實現文本佈局時,這須要改變,由於行換行會致使一個內聯節點被分割成多個框。但如今就能夠了。)

enum BoxType<'a> {
    BlockNode(&'a StyledNode<'a>),
    InlineNode(&'a StyledNode<'a>),
    AnonymousBlock,
}

要構建佈局樹,咱們須要查看每一個DOM節點的display屬性。我向style模塊添加了一些代碼,以獲取節點的顯示值。若是沒有指定值,則返回初始值'inline'。

enum Display {
    Inline,
    Block,
    None,
}

impl StyledNode {
    // Return the specified value of a property if it exists, otherwise `None`.
    fn value(&self, name: &str) -> Option<Value> {
        self.specified_values.get(name).map(|v| v.clone())
    }

    // The value of the `display` property (defaults to inline).
    fn display(&self) -> Display {
        match self.value("display") {
            Some(Keyword(s)) => match &*s {
                "block" => Display::Block,
                "none" => Display::None,
                _ => Display::Inline
            },
            _ => Display::Inline
        }
    }
}

如今咱們能夠遍歷樣式樹,爲每一個節點構建一個LayoutBox,而後爲節點的子節點插入框。若是一個節點的display屬性被設置爲'none',那麼它就不包含在佈局樹中。

// Build the tree of LayoutBoxes, but don't perform any layout calculations yet.
fn build_layout_tree<'a>(style_node: &'a StyledNode<'a>) -> LayoutBox<'a> {
    // Create the root box.
    let mut root = LayoutBox::new(match style_node.display() {
        Block => BlockNode(style_node),
        Inline => InlineNode(style_node),
        DisplayNone => panic!("Root node has display: none.")
    });

    // Create the descendant boxes.
    for child in &style_node.children {
        match child.display() {
            Block => root.children.push(build_layout_tree(child)),
            Inline => root.get_inline_container().children.push(build_layout_tree(child)),
            DisplayNone => {} // Skip nodes with `display: none;`
        }
    }
    return root;
}

impl LayoutBox {
    // Constructor function
    fn new(box_type: BoxType) -> LayoutBox {
        LayoutBox {
            box_type: box_type,
            dimensions: Default::default(), // initially set all fields to 0.0
            children: Vec::new(),
        }
    }
    // ...
}

若是塊節點包含內聯子節點,則建立一個匿名塊框來包含它。若是一行中有幾個內聯子元素,則將它們都放在同一個匿名容器中。

// Where a new inline child should go.
fn get_inline_container(&mut self) -> &mut LayoutBox {
    match self.box_type {
        InlineNode(_) | AnonymousBlock => self,
        BlockNode(_) => {
            // If we've just generated an anonymous block box, keep using it.
            // Otherwise, create a new one.
            match self.children.last() {
                Some(&LayoutBox { box_type: AnonymousBlock,..}) => {}
                _ => self.children.push(LayoutBox::new(AnonymousBlock))
            }
            self.children.last_mut().unwrap()
        }
    }
}

這是有意從標準CSS框生成算法的多種方式簡化的。例如,它不處理內聯框包含塊級子框的狀況。此外,若是塊級節點只有內聯子節點,則會生成一個沒必要要的匿名框。

未完待續

哇,比我想象的要長。我想我就講到這裏,可是不要擔憂:第6部分很快就會到來,它將討論塊級佈局。

一旦塊佈局完成,咱們就能夠跳轉到管道的下一個階段:繪製!我想我可能會這麼作,由於這樣咱們最終能夠看到渲染引擎的輸出是漂亮的圖片而不是數字。

然而,這些圖片將只是一堆彩色的矩形,除非咱們經過實現內聯佈局和文本佈局來完成佈局模塊。若是我在開始繪畫以前沒有實現這些,我但願以後再回到它們上來。

第六部分:塊佈局

歡迎回到我關於構建一個玩具HTML渲染引擎的系列文章,這是系列文章的第6篇。

本文將繼續咱們在第5部分中開始的佈局模塊。這一次,咱們將添加布局塊框的功能。這些框是垂直堆疊的,好比標題和段落。

爲了簡單起見,這段代碼只實現了正常流:沒有浮動,沒有絕對定位,也沒有固定定位。

遍歷佈局樹

該代碼的入口點是layout函數,它接受一個LayoutBox並計算其尺寸。咱們將把這個函數分爲三種狀況,目前只實現其中一種:

impl LayoutBox {
    // Lay out a box and its descendants.
    fn layout(&mut self, containing_block: Dimensions) {
        match self.box_type {
            BlockNode(_) => self.layout_block(containing_block),
            InlineNode(_) => {} // TODO
            AnonymousBlock => {} // TODO
        }
    }

    // ...
}

一個塊的佈局取決於它所包含塊的尺寸。對於正常流中的塊框,這只是框的父。對於根元素,它是瀏覽器窗口(或「視口」)的大小。

您可能還記得在前一篇文章中,一個塊的寬度取決於它的父塊,而它的高度取決於它的子塊。這意味着咱們的代碼在計算寬度時須要自頂向下遍歷樹,所以它能夠在父類的寬度已知以後佈局子類,並自底向上遍歷以計算高度,所以父類的高度在其子類的高度以後計算。

fn layout_block(&mut self, containing_block: Dimensions) {
    // Child width can depend on parent width, so we need to calculate
    // this box's width before laying out its children.
    self.calculate_block_width(containing_block);

    // Determine where the box is located within its container.
    self.calculate_block_position(containing_block);

    // Recursively lay out the children of this box.
    self.layout_block_children();

    // Parent height can depend on child height, so `calculate_height`
    // must be called *after* the children are laid out.
    self.calculate_block_height();
}

該函數對佈局樹執行一次遍歷,向下時進行寬度計算,向上時進行高度計算。一個真正的佈局引擎可能會執行幾回樹遍歷,一些是自頂向下,一些是自底向上。

計算寬度

寬度計算是塊佈局函數的第一步,也是最複雜的一步。我要一步一步來。首先,咱們須要CSS寬度屬性的值和全部左右邊的大小:

fn calculate_block_width(&mut self, containing_block: Dimensions) {
    let style = self.get_style_node();

    // `width` has initial value `auto`.
    let auto = Keyword("auto".to_string());
    let mut width = style.value("width").unwrap_or(auto.clone());

    // margin, border, and padding have initial value 0.
    let zero = Length(0.0, Px);

    let mut margin_left = style.lookup("margin-left", "margin", &zero);
    let mut margin_right = style.lookup("margin-right", "margin", &zero);

    let border_left = style.lookup("border-left-width", "border-width", &zero);
    let border_right = style.lookup("border-right-width", "border-width", &zero);

    let padding_left = style.lookup("padding-left", "padding", &zero);
    let padding_right = style.lookup("padding-right", "padding", &zero);

    // ...
}

這使用了一個名爲lookup的助手函數,它只是按順序嘗試一系列值。若是第一個屬性沒有設置,它將嘗試第二個屬性。若是沒有設置,它將返回給定的默認值。這提供了一個不完整(但簡單)的簡寫屬性和初始值實現。

注意:這相似於JavaScript或Ruby中的如下代碼:
margin_left = style["margin-left"] || style["margin"] || zero;

由於子對象不能改變父對象的寬度,因此它須要確保本身的寬度與父對象的寬度相符。CSS規範將其表達爲一組約束和解決它們的算法。下面的代碼實現了該算法。

首先,咱們將邊距、內邊距、邊框和內容寬度相加。to_px幫助器方法將長度轉換爲它們的數值。若是一個屬性被設置爲'auto',它會返回0,所以它不會影響和。

let total = [&margin_left, &margin_right, &border_left, &border_right,
             &padding_left, &padding_right, &width].iter().map(|v| v.to_px()).sum();

這是盒子所須要的最小水平空間。若是它不等於容器的寬度,咱們須要調整一些東西使它相等。

若是寬度或邊距設置爲「auto」,它們能夠擴展或收縮以適應可用的空間。按照說明書,咱們首先檢查盒子是否太大。若是是這樣,咱們將任何可擴展邊距設置爲零。

// If width is not auto and the total is wider than the container, treat auto margins as 0.
if width != auto && total > containing_block.content.width {
    if margin_left == auto {
        margin_left = Length(0.0, Px);
    }
    if margin_right == auto {
        margin_right = Length(0.0, Px);
    }
}

若是盒子對容器來講太大,就會溢出容器。若是過小,它就會下泄,留下額外的空間。咱們將計算下溢量,即容器內剩餘空間的大小。(若是這個數字是負數,它其實是一個溢出。)

let underflow = containing_block.content.width - total;

咱們如今遵循規範的算法,經過調整可擴展的尺寸來消除任何溢出或下溢。若是沒有「自動」尺寸,咱們調整右邊的邊距。(是的,這意味着在溢出的狀況下,邊界多是負的!)

match (width == auto, margin_left == auto, margin_right == auto) {
    // If the values are overconstrained, calculate margin_right.
    (false, false, false) => {
        margin_right = Length(margin_right.to_px() + underflow, Px);
    }

    // If exactly one size is auto, its used value follows from the equality.
    (false, false, true) => { margin_right = Length(underflow, Px); }
    (false, true, false) => { margin_left  = Length(underflow, Px); }

    // If width is set to auto, any other auto values become 0.
    (true, _, _) => {
        if margin_left == auto { margin_left = Length(0.0, Px); }
        if margin_right == auto { margin_right = Length(0.0, Px); }

        if underflow >= 0.0 {
            // Expand width to fill the underflow.
            width = Length(underflow, Px);
        } else {
            // Width can't be negative. Adjust the right margin instead.
            width = Length(0.0, Px);
            margin_right = Length(margin_right.to_px() + underflow, Px);
        }
    }

    // If margin-left and margin-right are both auto, their used values are equal.
    (false, true, true) => {
        margin_left = Length(underflow / 2.0, Px);
        margin_right = Length(underflow / 2.0, Px);
    }
}

此時,約束已經知足,任何'auto'值都已經轉換爲長度。結果是水平框尺寸的使用值,咱們將把它存儲在佈局樹中。你能夠在layout.rs中看到最終的代碼。

定位

下一步比較簡單。這個函數查找剩餘的邊距/內邊距/邊框樣式,並使用這些樣式和包含的塊尺寸來肯定這個塊在頁面上的位置。

fn calculate_block_position(&mut self, containing_block: Dimensions) {
    let style = self.get_style_node();
    let d = &mut self.dimensions;

    // margin, border, and padding have initial value 0.
    let zero = Length(0.0, Px);

    // If margin-top or margin-bottom is `auto`, the used value is zero.
    d.margin.top = style.lookup("margin-top", "margin", &zero).to_px();
    d.margin.bottom = style.lookup("margin-bottom", "margin", &zero).to_px();

    d.border.top = style.lookup("border-top-width", "border-width", &zero).to_px();
    d.border.bottom = style.lookup("border-bottom-width", "border-width", &zero).to_px();

    d.padding.top = style.lookup("padding-top", "padding", &zero).to_px();
    d.padding.bottom = style.lookup("padding-bottom", "padding", &zero).to_px();

    d.content.x = containing_block.content.x +
                  d.margin.left + d.border.left + d.padding.left;

    // Position the box below all the previous boxes in the container.
    d.content.y = containing_block.content.height + containing_block.content.y +
                  d.margin.top + d.border.top + d.padding.top;
}

仔細看看最後一條語句,它設置了y的位置。這就是爲何塊佈局具備獨特的垂直堆疊行爲。爲了實現這一點,咱們須要確保父節點的內容。高度在佈局每一個子元素後更新。

子元素

下面是遞歸佈局框內容的代碼。當它循環遍歷子框時,它會跟蹤總內容高度。定位代碼(上面)使用這個函數來查找下一個子元素的垂直位置。

fn layout_block_children(&mut self) {
    let d = &mut self.dimensions;
    for child in &mut self.children {
        child.layout(*d);
        // Track the height so each child is laid out below the previous content.
        d.content.height = d.content.height + child.dimensions.margin_box().height;
    }
}

每一個子節點佔用的總垂直空間是其邊距框的高度,咱們是這樣計算的:

impl Dimensions {
    // The area covered by the content area plus its padding.
    fn padding_box(self) -> Rect {
        self.content.expanded_by(self.padding)
    }
    // The area covered by the content area plus padding and borders.
    fn border_box(self) -> Rect {
        self.padding_box().expanded_by(self.border)
    }
    // The area covered by the content area plus padding, borders, and margin.
    fn margin_box(self) -> Rect {
        self.border_box().expanded_by(self.margin)
    }
}

impl Rect {
    fn expanded_by(self, edge: EdgeSizes) -> Rect {
        Rect {
            x: self.x - edge.left,
            y: self.y - edge.top,
            width: self.width + edge.left + edge.right,
            height: self.height + edge.top + edge.bottom,
        }
    }
}

爲簡單起見,這裏沒有實現邊距摺疊。一個真正的佈局引擎會容許一個框的底部邊緣與下一個框的頂部邊緣重疊,而不是每一個框都徹底放在前一個框的下面。

「高度」屬性

默認狀況下,框的高度等於其內容的高度。但若是'height'屬性被顯式設置爲長度,咱們將使用它來代替:

fn calculate_block_height(&mut self) {
    // If the height is set to an explicit length, use that exact length.
    // Otherwise, just keep the value set by `layout_block_children`.
    if let Some(Length(h, Px)) = self.get_style_node().value("height") {
        self.dimensions.content.height = h;
    }
}

這就是塊佈局算法。如今你能夠在一個HTML文檔上調用layout(),它會生成一堆矩形,包括寬度、高度、邊距等。很酷,對吧?

練習

對於雄心勃勃的實現者,一些額外的想法:

  1. 崩潰的垂直邊緣。
  2. 相對定位
  3. 並行化佈局過程,並測量對性能的影響。

若是您嘗試並行化項目,您可能想要將寬度計算和高度計算分離爲兩個不一樣的通道。經過爲每一個子任務生成一個單獨的任務,從上至下遍歷寬度很容易並行化。高度的計算要稍微複雜一些,由於您須要返回並在每一個子元素被佈局以後調整它們的y位置。

未完待續

感謝全部跟隨我走到這一步的人!

隨着我深刻到佈局和渲染的陌生領域,這些文章的編寫時間愈來愈長。在我試驗字體和圖形代碼的下一部分以前,會有一段較長的時間中斷,但我會盡快恢復這個系列。

更新:第7部分如今準備好了。

第七部分:繪製 101

歡迎回到個人關於構建一個簡單HTML渲染引擎的系列,這是第7篇,也是最後一篇。

在這篇文章中,我將添加很是基本的繪畫代碼。這段代碼從佈局模塊中獲取框樹,並將它們轉換爲像素數組。這個過程也稱爲「柵格化」。

瀏覽器一般在SkiaCairoDirect2D等圖形api和庫的幫助下實現光柵化。這些api提供了繪製多邊形、直線、曲線、漸變和文本的函數。如今,我將編寫我本身的光柵化程序,它只能繪製一種東西:矩形。

最後我想實現文本渲染。在這一點上,我可能會拋棄這個玩具繪畫代碼,轉而使用「真正的」2D圖形庫。但就目前而言,矩形足以將個人塊佈局算法的輸出轉換爲圖片。

迎頭遇上

從上一篇文章開始,我對之前文章中的代碼作了一些小的修改。這包括一些小的重構,以及一些更新,以保持代碼與最新的Rust夜間構建兼容。這些更改對理解代碼都不是相當重要的,可是若是您好奇的話,能夠查看提交歷史記錄

構建顯示列表

在繪製以前,咱們將遍歷佈局樹並構建一個顯示列表。這是一個圖形操做列表,如「繪製圓圈」或「繪製文本字符串」。或者在咱們的例子中,只是「畫一個矩形」。

爲何要將命令放入顯示列表中,而不是當即執行它們?顯示列表之因此有用有幾個緣由。你能夠經過搜索來找到被後期操做徹底掩蓋的物品,並將其移除,以消除浪費的油漆。在只知道某些項發生了更改的狀況下,能夠修改和重用顯示列表。您可使用相同的顯示列表生成不一樣類型的輸出:例如,用於在屏幕上顯示的像素,或用於發送到打印機的矢量圖形。

Robinson的顯示列表是顯示命令的向量。目前,只有一種類型的DisplayCommand,一個純色矩形:

type DisplayList = Vec<DisplayCommand>;

enum DisplayCommand {
    SolidColor(Color, Rect),
    // insert more commands here
}

爲了構建顯示列表,咱們遍歷佈局樹併爲每一個框生成一系列命令。首先,咱們繪製框的背景,而後在背景頂部繪製邊框和內容。

fn build_display_list(layout_root: &LayoutBox) -> DisplayList {
    let mut list = Vec::new();
    render_layout_box(&mut list, layout_root);
    return list;
}

fn render_layout_box(list: &mut DisplayList, layout_box: &LayoutBox) {
    render_background(list, layout_box);
    render_borders(list, layout_box);
    // TODO: render text

    for child in &layout_box.children {
        render_layout_box(list, child);
    }
}

默認狀況下,HTML元素是按照它們出現的順序堆疊的:若是兩個元素重疊,則後面的元素畫在前面的元素之上。這反映在咱們的顯示列表中,它將按照它們在DOM樹中出現的順序繪製元素。若是這段代碼支持z-index屬性,那麼各個元素將可以覆蓋這個堆疊順序,咱們須要相應地對顯示列表進行排序。

背景很簡單。它只是一個實心矩形。若是沒有指定背景顏色,那麼背景是透明的,咱們不須要生成顯示命令。

fn render_background(list: &mut DisplayList, layout_box: &LayoutBox) {
    get_color(layout_box, "background").map(|color|
        list.push(DisplayCommand::SolidColor(color, layout_box.dimensions.border_box())));
}

// Return the specified color for CSS property `name`, or None if no color was specified.
fn get_color(layout_box: &LayoutBox, name: &str) -> Option<Color> {
    match layout_box.box_type {
        BlockNode(style) | InlineNode(style) => match style.value(name) {
            Some(Value::ColorValue(color)) => Some(color),
            _ => None
        },
        AnonymousBlock => None
    }
}

邊框是類似的,可是咱們不是畫一個單獨的矩形,而是每條邊框都畫4 - 1。

fn render_borders(list: &mut DisplayList, layout_box: &LayoutBox) {
    let color = match get_color(layout_box, "border-color") {
        Some(color) => color,
        _ => return // bail out if no border-color is specified
    };

    let d = &layout_box.dimensions;
    let border_box = d.border_box();

    // Left border
    list.push(DisplayCommand::SolidColor(color, Rect {
        x: border_box.x,
        y: border_box.y,
        width: d.border.left,
        height: border_box.height,
    }));

    // Right border
    list.push(DisplayCommand::SolidColor(color, Rect {
        x: border_box.x + border_box.width - d.border.right,
        y: border_box.y,
        width: d.border.right,
        height: border_box.height,
    }));

    // Top border
    list.push(DisplayCommand::SolidColor(color, Rect {
        x: border_box.x,
        y: border_box.y,
        width: border_box.width,
        height: d.border.top,
    }));

    // Bottom border
    list.push(DisplayCommand::SolidColor(color, Rect {
        x: border_box.x,
        y: border_box.y + border_box.height - d.border.bottom,
        width: border_box.width,
        height: d.border.bottom,
    }));
}

接下來,渲染函數將繪製盒子的每一個子元素,直到整個佈局樹被轉換成顯示命令爲止。

光柵化

如今咱們已經構建了顯示列表,咱們須要經過執行每一個DisplayCommand將其轉換爲像素。咱們將把像素存儲在畫布中:

struct Canvas {
    pixels: Vec<Color>,
    width: usize,
    height: usize,
}

impl Canvas {
    // Create a blank canvas
    fn new(width: usize, height: usize) -> Canvas {
        let white = Color { r: 255, g: 255, b: 255, a: 255 };
        return Canvas {
            pixels: repeat(white).take(width * height).collect(),
            width: width,
            height: height,
        }
    }
    // ...
}

要在畫布上繪製矩形,只需循環遍歷它的行和列,使用helper方法確保不會超出畫布的範圍。

fn paint_item(&mut self, item: &DisplayCommand) {
    match item {
        &DisplayCommand::SolidColor(color, rect) => {
            // Clip the rectangle to the canvas boundaries.
            let x0 = rect.x.clamp(0.0, self.width as f32) as usize;
            let y0 = rect.y.clamp(0.0, self.height as f32) as usize;
            let x1 = (rect.x + rect.width).clamp(0.0, self.width as f32) as usize;
            let y1 = (rect.y + rect.height).clamp(0.0, self.height as f32) as usize;

            for y in (y0 .. y1) {
                for x in (x0 .. x1) {
                    // TODO: alpha compositing with existing pixel
                    self.pixels[x + y * self.width] = color;
                }
            }
        }
    }
}

注意,這段代碼只適用於不透明的顏色。若是咱們添加了透明度(經過讀取不透明度屬性,或在CSS解析器中添加對rgba()值的支持),那麼它就須要將每一個新像素與它所繪製的任何內容混合在一塊兒。

如今咱們能夠把全部東西都放到paint函數中,它會構建一個顯示列表,而後柵格化到畫布上:

// Paint a tree of LayoutBoxes to an array of pixels.
fn paint(layout_root: &LayoutBox, bounds: Rect) -> Canvas {
    let display_list = build_display_list(layout_root);
    let mut canvas = Canvas::new(bounds.width as usize, bounds.height as usize);
    for item in display_list {
        canvas.paint_item(&item);
    }
    return canvas;
}

最後,咱們能夠編寫幾行代碼,使用Rust圖像庫將像素數組保存爲PNG文件。
漂亮的圖片

最後,咱們已經到達渲染管道的末端。在不到1000行代碼中,robinson如今能夠解析這個HTML文件了:

<div class="a">
  <div class="b">
    <div class="c">
      <div class="d">
        <div class="e">
          <div class="f">
            <div class="g">
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

和這個CSS文件:

* { display: block; padding: 12px; }
.a { background: #ff0000; }
.b { background: #ffa500; }
.c { background: #ffff00; }
.d { background: #008000; }
.e { background: #0000ff; }
.f { background: #4b0082; }
.g { background: #800080; }

獲得如下效果:

rainbow

耶!

練習

若是你是獨自在家玩,這裏有一些你可能想嘗試的事情:

編寫一個替代的繪圖函數,它接受顯示列表並生成矢量輸出(例如,SVG文件),而不是柵格圖像。

添加對不透明度和alpha混合的支持。

編寫一個函數,經過剔除徹底超出畫布邊界的項來優化顯示列表。

若是你熟悉OpenGL,能夠編寫一個使用GL着色器繪製矩形的硬件加速繪製函數。

尾聲

如今咱們已經得到了渲染管道中每一個階段的基本功能,如今是時候回去填補一些缺失的特性了——特別是內聯佈局和文本渲染。之後的文章還可能添加額外的階段,如網絡和腳本。

我將在本月的灣區Rust聚會上作一個簡短的演講,「讓咱們構建一個瀏覽器引擎吧!」會議將於明天(11月6日,週四)晚上7點在Mozilla的舊金山辦公室舉行,屆時個人伺服開發夥伴們也將進行有關伺服的演講。會談的視頻將在Air Mozilla上進行直播,錄音將在稍後發佈。

原文寫於2014.8.8。

原文連接:https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html

加入咱們

咱們是DevUI團隊,歡迎來這裏和咱們一塊兒打造優雅高效的人機設計/研發體系。招聘郵箱:muyang2@huawei.com。

文/DevUI Kagol

往期文章推薦

《使用Lint工具「武裝」你的項目 》

《跟着華爲DevUI開源組件庫學寫單元測試用例》

《在瀑布下用火焰烤餅:三步法助你快速定位網站性能問題(超詳細)》

相關文章
相關標籤/搜索