讓我們來構(gòu)建一個瀏覽器引擎吧(建議收藏)

來源:https://segmentfault.com/a/1190000038859456
DevUI是一支兼具設(shè)計視角和工程視角的團隊,服務(wù)于華為云DevCloud平臺和華為內(nèi)部數(shù)個中后臺系統(tǒng),服務(wù)于設(shè)計師和前端工程師。
官方網(wǎng)站:devui.design
Ng組件庫:ng-devui(歡迎Star)
官方交流:添加DevUI小助手(devui-official)
DevUIHelper插件:DevUIHelper-LSP(歡迎Star)
引言
前端有一個經(jīng)典的面試題:在瀏覽器地址欄輸入URL到最終呈現(xiàn)出頁面,中間發(fā)生了什么?
中間有一個過程是獲取后臺返回的HTML文本,瀏覽器渲染引擎將其解析成DOM樹,并將HTML中的CSS解析成樣式樹,然后將DOM樹和樣式樹合并成布局樹,并最終由繪圖程序繪制到瀏覽器畫板上。
本文通過親自動手實踐,教你一步一步實現(xiàn)一個迷你版瀏覽器引擎,進而深入理解渲染引擎的工作原理,干貨滿滿。
主要分成七個部分:
第一部分:開始 第二部分:HTML 第三部分:CSS 第四部分:樣式 第五部分:盒子 第六部分:塊布局 第七部分:繪制 101
原文寫于2014.8.8。
原文地址:https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html
以下是正文:
第一部分:開始
我正在構(gòu)建一個“玩具”渲染引擎,我認為你也應(yīng)該這樣做。這是一系列文章中的第一篇。
完整的系列文章將描述我編寫的代碼,并向你展示如何編寫自己的代碼。但首先,讓我解釋一下原因。
你在造什么?
讓我們談?wù)勑g(shù)語。瀏覽器引擎是web瀏覽器的一部分,它在“底層”工作,從Internet上獲取網(wǎng)頁,并將其內(nèi)容轉(zhuǎn)換成可以閱讀、觀看、聽等形式。Blink、Gecko、WebKit和Trident都是瀏覽器引擎。相比之下,瀏覽器本身的用戶界面(標簽、工具欄、菜單等)被稱為chrome。Firefox和SeaMonkey是兩個瀏覽器,使用不同的chrome,但使用相同的Gecko引擎。
瀏覽器引擎包括許多子組件:HTTP客戶端、HTML解析器、CSS解析器、JavaScript引擎(本身由解析器、解釋器和編譯器組成)等等。那些涉及解析HTML和CSS等web格式,并將其轉(zhuǎn)換成你在屏幕上看到的內(nèi)容的組件,有時被稱為布局引擎或渲染引擎。
為什么是一個“玩具”渲染引擎?
一個功能齊全的瀏覽器引擎非常復雜。Blink,Gecko,WebKit,它們每一個都有數(shù)百萬行代碼。更年輕、更簡單的渲染引擎,如Servo和WeasyPrint,也有成千上萬行。這對一個新手來說是不容易理解的!
說到非常復雜的軟件:如果你參加了編譯器或操作系統(tǒng)的課程,在某些時候你可能會創(chuàng)建或修改一個“玩具”編譯器或內(nèi)核。這是一個為學習而設(shè)計的簡單模型;它可能永遠不會由作者以外的任何人管理。但是
制作一個玩具系統(tǒng)對于了解真實的東西是如何工作的是一個有用的工具。
即使你從未構(gòu)建過真實的編譯器或內(nèi)核,
了解它們的工作方式也可以幫助你在編寫自己的程序時更好地使用它們。
因此,如果你想成為一名瀏覽器開發(fā)人員,或者只是想了解瀏覽器引擎內(nèi)部發(fā)生了什么,為什么不構(gòu)建一個玩具呢?就像實現(xiàn)“真正的”編程語言子集的玩具編譯器一樣,玩具渲染引擎也可以實現(xiàn)HTML和CSS的一小部分。它不會取代日常瀏覽器中的引擎,但應(yīng)該能夠說明呈現(xiàn)一個簡單HTML文檔所需的基本步驟。
在家試試吧。
我希望我已經(jīng)說服你去試一試了。如果你已經(jīng)有一些扎實的編程經(jīng)驗并了解一些高級HTML和CSS概念,那么學習本系列將會非常容易。然而,如果你剛剛開始學習這些東西,或者遇到你不理解的東西,請隨意問問題,我會盡量讓它更清楚。
在你開始之前,我想告訴你一些你可以做的選擇:
關(guān)于編程語言
你可以用任何編程語言構(gòu)建一個玩具式的布局引擎,真的!用一門你了解和喜愛的語言吧。如果這聽起來很有趣,你也可以
以此為借口學習一門新語言。
如果你想開始為主要的瀏覽器引擎(如Gecko或WebKit)做貢獻,你可能希望使用C++,因為C++是這些引擎中使用的主要語言,使用C++可以更容易地將你的代碼與它們的代碼進行比較。
我自己的玩具項目,robinson,是用Rust寫的。我是Mozilla的Servo團隊的一員,所以我非常喜歡Rust編程。此外,我創(chuàng)建這個項目的目標之一是了解更多的Servo的實現(xiàn)。Robinson有時會使用Servo的簡化版本的數(shù)據(jù)結(jié)構(gòu)和代碼。
關(guān)于庫和捷徑
在這樣的學習練習中,你必須決定是使用別人的代碼,還是從頭編寫自己的代碼。我的建議是
為你真正想要理解的部分編寫你自己的代碼,但是不要羞于為其他的部分使用庫。
學習如何使用特定的庫本身就是一項有價值的練習。
我寫robinson不僅僅是為了我自己,也是為了作為這些文章和練習的示例代碼。出于這樣或那樣的原因,我希望它盡可能地小巧和獨立。到目前為止,除了Rust標準庫之外,我沒有使用任何外部代碼。(這也避免了使用同一版本的Rust來構(gòu)建多個依賴的小麻煩,而該語言仍在開發(fā)中。)不過,這個規(guī)則并不是一成不變的。例如,我以后可能決定使用圖形庫,而不是編寫自己的低級繪圖代碼。
另一種避免編寫代碼的方法是省略一些內(nèi)容。例如,robinson還沒有網(wǎng)絡(luò)代碼;它只能讀取本地文件。在一個玩具程序中,如果你想跳過一些東西,你可以跳過。我將在討論過程中指出類似的潛在捷徑,這樣你就可以繞過不感興趣的步驟,直接跳到好的內(nèi)容。如果你改變了主意,你可以在以后再補上空白。
第一步:DOM
準備好寫代碼了嗎?我們將從一些小的東西開始:DOM的數(shù)據(jù)結(jié)構(gòu)。讓我們看看robinson的dom模塊。
DOM是一個節(jié)點樹。一個節(jié)點有零個或多個子節(jié)點。(它還有其他各種屬性和方法,但我們現(xiàn)在可以忽略其中的大部分。)
struct Node {
// data common to all nodes:
children: Vec,
// data specific to each node type:
node_type: NodeType,
}
有多種節(jié)點類型,但現(xiàn)在我們將忽略其中的大多數(shù),并將節(jié)點定義為元素節(jié)點或文本節(jié)點。在具有繼承的語言中,這些是Node的子類型。在Rust中,它們可以是枚舉enum(Rust的關(guān)鍵字用于“tagged union”或“sum type”):
enum NodeType {
Text(String),
Element(ElementData),
}
元素包括一個標記名稱和任意數(shù)量的屬性,它們可以存儲為從名稱到值的映射。Robinson不支持名稱空間,所以它只將標記和屬性名稱存儲為簡單的字符串。
struct ElementData {
tag_name: String,
attributes: AttrMap,
}
type AttrMap = HashMap;
最后,一些構(gòu)造函數(shù)使創(chuàng)建新節(jié)點變得容易:
fn text(data: String) -> Node {
Node { children: Vec::new(), node_type: NodeType::Text(data) }
}
fn elem(name: String, attrs: AttrMap, children: Vec) -> Node {
Node {
children: children,
node_type: NodeType::Element(ElementData {
tag_name: name,
attributes: attrs,
})
}
}
這是它!一個成熟的DOM實現(xiàn)將包含更多的數(shù)據(jù)和幾十個方法,但這就是我們開始所需要的。
練習
這些只是一些在家可以遵循的建議。做你感興趣的練習,跳過不感興趣的。
用你選擇的語言啟動一個新程序,并編寫代碼來表示DOM文本節(jié)點和元素樹。 安裝最新版本的Rust,然后下載并構(gòu)建robinson。打開 dom.rs和擴展NodeType以包含其他類型,如注釋節(jié)點。編寫代碼來美化DOM節(jié)點樹。
在下一篇文章中,我們將添加一個將HTML源代碼轉(zhuǎn)換為這些DOM節(jié)點樹的解析器。
參考文獻
有關(guān)瀏覽器引擎內(nèi)部結(jié)構(gòu)的更多詳細信息,請參閱Tali Garsiel非常精彩的瀏覽器的工作原理及其到更多資源的鏈接。
例如代碼,這里有一個“小型”開源web呈現(xiàn)引擎的簡短列表。它們大多比robinson大很多倍,但仍然比Gecko或WebKit小得多。只有2000行代碼的WebWhirr是唯一一個我稱之為“玩具”引擎的引擎。
CSSBox (Java) Cocktail (Haxe) gngr (Java) litehtml (c++) LURE (Lua) NetSurf (C) Servo (Rust) Simple San Simon (Haskell) WeasyPrint (Python) WebWhirr (C++)
你可能會發(fā)現(xiàn)這些有用的靈感或參考。如果你知道任何其他類似的項目,或者如果你開始自己的項目,請讓我知道!
第二部分:HTML
這是構(gòu)建一個玩具瀏覽器渲染引擎系列文章的第二篇。
本文是關(guān)于解析HTML源代碼以生成DOM節(jié)點樹的。解析是一個很吸引人的話題,但是我沒有足夠的時間或?qū)I(yè)知識來介紹它。你可以從任何關(guān)于編譯器的優(yōu)秀課程或書籍中獲得關(guān)于解析的詳細介紹?;蛘咄ㄟ^閱讀與你選擇的編程語言一起工作的解析器生成器的文檔來獲得動手操作的開始。
HTML有自己獨特的解析算法。與大多數(shù)編程語言和文件格式的解析器不同,HTML解析算法不會拒絕無效的輸入。相反,它包含了特定的錯誤處理指令,因此web瀏覽器可以就如何顯示每個web頁面達成一致,即使是那些不符合語法規(guī)則的頁面。Web瀏覽器必須做到這一點才能使用:因為不符合標準的HTML在Web早期就得到了支持,所以現(xiàn)在大部分現(xiàn)有Web頁面都在使用它。
簡單的HTML方言
我甚至沒有嘗試實現(xiàn)標準的HTML解析算法。相反,我為HTML語法的一小部分編寫了一個基本解析器。我的解析器可以處理這樣的簡單頁面:
Title
Hello world!
允許使用以下語法:
閉合的標簽: …
帶引號的屬性: id="main"文本節(jié)點: world
其他所有內(nèi)容都不支持,包括:
評論 Doctype聲明 轉(zhuǎn)義字符(如 &)和CDATA節(jié)自結(jié)束標簽: 或沒有結(jié)束標簽錯誤處理(例如未閉合或不正確嵌套的標簽) 名稱空間和其他XHTML語法: 字符編碼檢測
在這個項目的每個階段,我都或多或少地編寫了支持后面階段所需的最小代碼。但是如果你想學習更多的解析理論和工具,你可以在你自己的項目中更加雄心勃勃!
示例代碼
接下來,讓我們回顧一下我的HTML解析器,記住這只是一種方法(而且可能不是最好的方法)。它的結(jié)構(gòu)松散地基于Servo的cssparser庫中的tokenizer模塊。它沒有真正的錯誤處理;在大多數(shù)情況下,它只是在遇到意外的語法時中止。代碼是用Rust語言寫的,但我希望它對于使用類似語言(如Java、C++或C#)的人來說具有相當?shù)目勺x性。它使用了第一部分中的DOM數(shù)據(jù)結(jié)構(gòu)。
解析器將其輸入字符串和當前位置存儲在字符串中。位置是我們還沒有處理的下一個字符的索引。
struct Parser {
pos: usize, // "usize" is an unsigned integer, similar to "size_t" in C
input: String,
}
我們可以用它來實現(xiàn)一些簡單的方法來窺視輸入中的下一個字符:
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字節(jié)數(shù)組。要進入下一個字符,我們不能只前進一個字節(jié)。相反,我們使用char_indices來正確處理多字節(jié)字符。(如果我們的字符串使用固定寬度的字符,我們可以只將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;
}
通常我們想要使用一個連續(xù)的字符串。consume_while方法使用滿足給定條件的字符,并將它們作為字符串返回。這個方法的參數(shù)是一個函數(shù),它接受一個char并返回一個bool值。
// Consume characters until `test` returns false.
fn consume_while(&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;
}
我們可以使用它來忽略空格字符序列,或者使用字母數(shù)字字符串:
// 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
})
}
現(xiàn)在我們已經(jīng)準備好開始解析HTML了。要解析單個節(jié)點,我們查看它的第一個字符,看它是元素節(jié)點還是文本節(jié)點。在我們簡化的HTML版本中,文本節(jié)點可以包含除<之外的任何字符。
// 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 != '<'))
}
一個元素更為復雜。它包括開始和結(jié)束標簽,以及在它們之間任意數(shù)量的子節(jié)點:
// 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;
}
為了解析子節(jié)點,我們在循環(huán)中遞歸地調(diào)用parse_node,直到到達結(jié)束標記。這個函數(shù)返回一個Vec,這是Rust對可增長數(shù)組的名稱。
// Parse a sequence of sibling nodes.
fn parse_nodes(&mut self) -> Vec {
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樹。如果文檔沒有顯式包含根節(jié)點,則該函數(shù)將為文檔創(chuàng)建根節(jié)點;這與真正的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多行代碼(不包括空白行和注釋)。如果你使用一個好的庫或解析器生成器,你可能可以在更少的空間中構(gòu)建一個類似的玩具解析器。
練習
這里有一些你可以自己嘗試的替代方法。與前面一樣,你可以選擇其中的一個或多個,并忽略其他。
構(gòu)建一個以HTML子集作為輸入并生成DOM節(jié)點樹的解析器(“手動”或使用庫或解析器生成器)。 修改robinson的HTML解析器,添加一些缺失的特性,比如注釋?;蛘哂酶玫慕馕銎魈鎿Q它,可能使用庫或生成器構(gòu)建。 創(chuàng)建一個無效的HTML文件,導致你的(或我的)解析器失敗。修改解析器以從錯誤中恢復,并為測試文件生成DOM樹。
捷徑
如果想完全跳過解析,可以通過編程方式構(gòu)建DOM樹,向程序中添加類似這樣的代碼(偽代碼,調(diào)整它以匹配第1部分中編寫的DOM代碼):
// Hello, world!
let root = element("html");
let body = element("body");
root.children.push(body);
body.children.push(text("Hello, world!"));
或者你可以找到一個現(xiàn)有的HTML解析器并將其合并到你的程序中。
本系列的下一篇文章將討論CSS數(shù)據(jù)結(jié)構(gòu)和解析。
第三部分:CSS
本文是構(gòu)建玩具瀏覽器呈現(xiàn)引擎系列文章中的第三篇。
本文介紹了用于讀取層疊樣式表(CSS)的代碼。像往常一樣,我不會試圖涵蓋該規(guī)范中的所有內(nèi)容。相反,我嘗試實現(xiàn)足以說明一些概念并為后期渲染管道生成輸入的內(nèi)容。
剖析樣式表
下面是一個CSS源代碼示例:
h1, h2, h3 { margin: auto; color: #cc0000; }
div.note { margin-bottom: 20px; padding: 10px; }
#answer { display: none; }
接下來,我將從我的玩具瀏覽器引擎robinson中瀏覽css模塊。雖然這些概念可以很容易地轉(zhuǎn)換成其他編程語言,但代碼還是用Rust寫的。先閱讀前面的文章可能會幫助您理解下面的一些代碼。
CSS樣式表是一系列規(guī)則。(在上面的示例樣式表中,每行包含一條規(guī)則。)
struct Stylesheet {
rules: Vec,
}
一條規(guī)則包括一個或多個用逗號分隔的選擇器,后跟一系列用大括號括起來的聲明。
struct Rule {
selectors: Vec,
declarations: Vec,
}
一個選擇器可以是一個簡單的選擇器,也可以是一個由_組合符_連接的選擇器鏈。Robinson目前只支持簡單的選擇器。
注意:令人困惑的是,新的Selectors Level 3標準使用相同的術(shù)語來表示略有不同的東西。在本文中,我主要引用CSS2.1。盡管過時了,但它是一個有用的起點,因為它更小,更獨立(與CSS3相比,CSS3被分成無數(shù)互相依賴和CSS2.1的規(guī)范)。
在robinson中,一個簡單選擇器可以包括一個標記名,一個以'#'為前綴的ID,任意數(shù)量的以'.'為前綴的類名,或以上幾種情況的組合。如果標簽名為空或'*',那么它是一個“通用選擇器”,可以匹配任何標簽。
還有許多其他類型的選擇器(特別是在CSS3中),但現(xiàn)在這樣就可以了。
enum Selector {
Simple(SimpleSelector),
}
struct SimpleSelector {
tag_name: Option,
id: Option,
class: Vec,
}
聲明只是一個名稱/值對,由冒號分隔并以分號結(jié)束。例如,“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位無符號整數(shù),f32是一個32位浮點數(shù)。
不支持所有其他CSS語法,包括@-rules、注釋和上面沒有提到的任何選擇器/值/單元。
解析
CSS有一個規(guī)則的語法,這使得它比它古怪的表親HTML更容易正確解析。當符合標準的CSS解析器遇到解析錯誤時,它會丟棄樣式表中無法識別的部分,但仍然處理其余部分。這是很有用的,因為它允許樣式表包含新的語法,但在舊的瀏覽器中仍然產(chǎn)生定義良好的輸出。
Robinson使用了一個非常簡單(完全不符合標準)的解析器,構(gòu)建的方式與第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*將成功解析并產(chǎn)生奇怪的結(jié)果。真正的CSS解析器會丟棄這些無效的選擇器。
優(yōu)先級
優(yōu)先級是渲染引擎在沖突中決定哪一種樣式覆蓋另一種樣式的方法之一。如果一個樣式表包含兩個匹配元素的規(guī)則,具有較高優(yōu)先級的匹配選擇器的規(guī)則可以覆蓋較低優(yōu)先級的選擇器中的值。
選擇器的優(yōu)先級基于它的組件。ID選擇器比類選擇器優(yōu)先級更高,類選擇器比標簽選擇器優(yōu)先級更高。在每個“層級”中,選擇器越多優(yōu)先級越高。
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)
}
}
(如果我們支持鏈選擇器,我們可以通過將鏈各部分的優(yōu)先級相加來計算鏈的優(yōu)先級。)
每個規(guī)則的選擇器都存儲在排序的向量中,優(yōu)先級最高的優(yōu)先。這對于匹配非常重要,我將在下一篇文章中介紹。
// Parse a rule set: ` { }`.
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 {
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部分中還沒有這樣做,那么現(xiàn)在是嘗試解析器生成器的絕佳時機。我的手卷解析器完成了簡單示例文件的工作,但它有很多漏洞,如果您違反了它的假設(shè),它將嚴重失敗。有一天,我可能會用rust-peg或類似的東西來取代它。
練習
和以前一樣,你應(yīng)該決定你想做哪些練習,并跳過其余的:
實現(xiàn)您自己的簡化CSS解析器和優(yōu)先級計算。 擴展robinson的CSS解析器,以支持更多的值,或一個或多個選擇器組合符。 擴展CSS解析器,丟棄任何包含解析錯誤的聲明,并遵循錯誤處理規(guī)則,在聲明結(jié)束后繼續(xù)解析。 讓HTML解析器將任何 節(jié)點的內(nèi)容傳遞給CSS解析器,并返回一個文檔對象,該對象除了DOM樹之外還包含一個樣式表列表。
捷徑
就像在第2部分中一樣,您可以通過直接將CSS數(shù)據(jù)結(jié)構(gòu)硬編碼到您的程序中來跳過解析,或者通過使用已經(jīng)有解析器的JSON等替代格式來編寫它們。
未完待續(xù)
下一篇文章將介紹style模塊。在這里,所有的一切都開始結(jié)合在一起,選擇器匹配以將CSS樣式應(yīng)用到DOM節(jié)點。
這個系列的進度可能很快就會慢下來,因為這個月晚些時候我會很忙,我甚至還沒有為即將發(fā)表的一些文章編寫代碼。我會讓他們盡快趕到的!
第四部分:樣式
歡迎回到我關(guān)于構(gòu)建自己的玩具瀏覽器引擎的系列文章。
本文將介紹CSS標準所稱的為屬性值賦值,也就是我所說的樣式模塊。此模塊將DOM節(jié)點和CSS規(guī)則作為輸入,并將它們匹配起來,以確定任何給定節(jié)點的每個CSS屬性的值。
這部分不包含很多代碼,因為我沒有實現(xiàn)真正復雜的部分。然而,我認為剩下的部分仍然很有趣,我還將解釋一些缺失的部分如何實現(xiàn)。
樣式樹
robinson的樣式模塊的輸出是我稱之為樣式樹的東西。這棵樹中的每個節(jié)點都包含一個指向DOM節(jié)點的指針,以及它的CSS屬性值:
// Map from CSS property names to values.
type PropertyMap = HashMap;
// A node with associated style data.
struct StyledNode<'a> {
node: &'a Node, // pointer to a DOM node
specified_values: PropertyMap,
children: Vec>,
}
這些
'a是什么?這些都是生存期,這是Rust如何保證指針是內(nèi)存安全的,而不需要進行垃圾回收的部分原因。如果你不是在Rust的環(huán)境中工作,你可以忽略它們;它們對代碼的意義并不重要。
我們可以向dom::Node結(jié)構(gòu)添加新的字段,而不是創(chuàng)建一個新的樹,但我想讓樣式代碼遠離早期的“教訓”。這也讓我有機會討論大多數(shù)渲染引擎中的平行樹。
瀏覽器引擎模塊通常以一個樹作為輸入,然后產(chǎn)生一個不同但相關(guān)的樹作為輸出。例如,Gecko的布局代碼獲取一個DOM樹并生成一個框架樹,然后使用它來構(gòu)建一個視圖樹。Blink和WebKit將DOM樹轉(zhuǎn)換為渲染樹。所有這些引擎的后期階段會產(chǎn)生更多的樹,包括層樹和部件樹。
在我們完成了更多的階段后,我們的玩具瀏覽器引擎的管道將看起來像這樣:

在我的實現(xiàn)中,DOM樹中的每個節(jié)點在樣式樹中只有一個節(jié)點。但在更復雜的管道階段,幾個輸入節(jié)點可能會分解為一個輸出節(jié)點。或者一個輸入節(jié)點可能擴展為幾個輸出節(jié)點,或者完全跳過。例如,樣式樹可以排除顯示屬性設(shè)置為'none'的元素。(相反,我將在布局階段刪除這些內(nèi)容,因為這樣我的代碼會變得更簡單一些。)
選擇器匹配
構(gòu)建樣式樹的第一步是選擇器匹配。這將非常容易,因為我的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;
}
注意:這個函數(shù)使用any方法,如果迭代器包含一個通過所提供的測試的元素,則該方法返回true。這與Python中的any函數(shù)(或Haskell)或JavaScript中的some方法相同。
構(gòu)建樣式樹
接下來,我們需要遍歷DOM樹。對于樹中的每個元素,我們將在樣式表中搜索匹配規(guī)則。
當比較兩個匹配相同元素的規(guī)則時,我們需要使用來自每個匹配的最高優(yōu)先級選擇器。因為我們的CSS解析器存儲了從優(yōu)先級從高低的選擇器,所以只要找到了匹配的選擇器,我們就可以停止,并返回它的優(yōu)先級以及指向規(guī)則的指針。
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> {
// Find the first (highest-specificity) matching selector.
rule.selectors.iter()
.find(|selector| matches(elem, *selector))
.map(|selector| (selector.specificity(), rule))
}
為了找到與一個元素匹配的所有規(guī)則,我們稱之為filter_map,它對樣式表進行線性掃描,檢查每個規(guī)則并排除不匹配的規(guī)則。真正的瀏覽器引擎會根據(jù)標簽名稱、id、類等將規(guī)則存儲在多個散列表中,從而加快速度。
// Find all CSS rules that match the given element.
fn matching_rules<'a>(elem: &ElementData, stylesheet: &'a Stylesheet) -> Vec> {
stylesheet.rules.iter().filter_map(|rule| match_rule(elem, rule)).collect()
}
一旦有了匹配規(guī)則,就可以為元素找到指定的值。我們將每個規(guī)則的屬性值插入到HashMap中。我們根據(jù)優(yōu)先級對匹配進行排序,因此在較不特定的規(guī)則之后處理更特定的規(guī)則,并可以覆蓋它們在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;
}
現(xiàn)在,我們已經(jīng)擁有遍歷DOM樹和構(gòu)建樣式樹所需的一切。注意,選擇器匹配只對元素有效,因此文本節(jié)點的指定值只是一個空映射。
// 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構(gòu)建樣式樹的全部代碼。接下來我將討論一些明顯的遺漏。
級聯(lián)
由web頁面的作者提供的樣式表稱為_作者樣式表_。除此之外,瀏覽器還通過_用戶代理樣式表_提供默認樣式。它們可能允許用戶通過_用戶樣式表_(如Gecko的userContent.css)添加自定義樣式。
級聯(lián)定義這三個“起源”中哪個優(yōu)先于另一個。級聯(lián)有6個級別:一個用于每個起源的“正常”聲明,另一個用于每個起源的!important聲明。
Robinson的風格代碼沒有實現(xiàn)級聯(lián);它只需要一個樣式表。缺少默認樣式表意味著HTML元素將不具有任何您可能期望的默認樣式。例如,元素的內(nèi)容不會被隱藏,除非你顯式地把這個規(guī)則添加到你的樣式表中:
head { display: none; }
實現(xiàn)級聯(lián)應(yīng)該相當簡單:只需跟蹤每個規(guī)則的起源,并根據(jù)起源和重要性以及特殊性對聲明進行排序。一個簡化的、兩級的級聯(lián)應(yīng)該足以支持最常見的情況:普通用戶代理樣式和普通作者樣式。
計算的值
除了上面提到的“指定值”之外,CSS還定義了初始值、計算值、使用值和實際值。
_初始值_是沒有在級聯(lián)中指定的屬性的默認值。_計算值_基于指定值,但可能應(yīng)用一些特定于屬性的規(guī)范化規(guī)則。
根據(jù)CSS規(guī)范中的定義,正確實現(xiàn)這些需要為每個屬性單獨編寫代碼。對于一個真實的瀏覽器引擎來說,這項工作是必要的,但我希望在這個玩具項目中避免它。在后面的階段,當指定的值缺失時,使用這些值的代碼將(某種程度上)通過使用默認值模擬初始值。
_使用值_和_實際值_是在布局期間和之后計算的,我將在以后的文章中介紹。
繼承
如果文本節(jié)點不能匹配選擇器,它們?nèi)绾潍@得顏色、字體和其他樣式?答案是繼承。
當屬性被繼承時,任何沒有級聯(lián)值的節(jié)點都將接收該屬性的父節(jié)點值。有些屬性,如'color',是默認繼承的;其他僅當級聯(lián)指定特殊值“inherit”時使用。
我的代碼不支持繼承。要實現(xiàn)它,可以將父類的樣式數(shù)據(jù)傳遞到specified_values函數(shù),并使用硬編碼的查找表來決定應(yīng)該繼承哪些屬性。
樣式屬性
任何HTML元素都可以包含一個包含CSS聲明列表的樣式屬性。沒有選擇器,因為這些聲明自動只應(yīng)用于元素本身。
如果您想要支持style屬性,請使用specified_values函數(shù)檢查該屬性。如果存在該屬性,則將其從CSS解析器傳遞給parse_declarations。在普通的作者聲明之后應(yīng)用結(jié)果聲明,因為屬性比任何CSS選擇器都更特定。
練習
除了編寫自己的選擇器匹配和值賦值代碼之外,你還可以在自己的項目或robinson的分支中實現(xiàn)上面討論的一個或多個缺失的部分:
級聯(lián) 初始值和/或計算值 繼承 樣式屬性
另外,如果您從第3部分擴展了CSS解析器以包含復合選擇器,那么現(xiàn)在可以實現(xiàn)對這些復合選擇器的匹配。
未完待續(xù)
第5部分將介紹布局模塊。我還沒有完成代碼,所以在我開始寫這篇文章之前還會有另一個延遲。我計劃將布局分成至少兩篇文章(一篇是塊布局,一篇可能是內(nèi)聯(lián)布局)。
與此同時,我希望看到您根據(jù)這些文章或練習創(chuàng)建的任何東西。如果你的代碼在某個地方,請在下面添加一個鏈接!到目前為止,我已經(jīng)看到了Martin Tomasi的Java實現(xiàn)和Pohl longsin的Swift版本。
第5部分:盒子
這是關(guān)于編寫一個簡單的HTML渲染引擎的系列文章中的第5篇。
本文將開始布局模塊,該模塊獲取樣式樹并將其轉(zhuǎn)換為二維空間中的一堆矩形。這是一個很大的模塊,所以我將把它分成幾篇文章。另外,在我為后面的部分編寫代碼時,我在本文中分享的一些代碼可能需要更改。
布局模塊的輸入是第4部分中的樣式樹,它的輸出是另一棵樹,即布局樹。這使我們的迷你渲染管道更進一步:

我將從基本的HTML/CSS布局模型開始討論。如果您曾經(jīng)學習過如何開發(fā)web頁面,那么您可能已經(jīng)熟悉了這一點,但是從實現(xiàn)者的角度來看,它可能有點不同。
盒模型
布局就是方框。方框是網(wǎng)頁的一個矩形部分。它具有頁面上的寬度、高度和位置。這個矩形稱為內(nèi)容區(qū)域,因為它是框的內(nèi)容繪制的地方。內(nèi)容可以是文本、圖像、視頻或其他框。
框還可以在其內(nèi)容區(qū)域周圍有內(nèi)邊距、邊框和邊距。CSS規(guī)范中有一個圖表顯示所有這些層是如何組合在一起的。
Robinson將盒子的內(nèi)容區(qū)域和周圍區(qū)域存儲在下面的結(jié)構(gòu)中。[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,
}
塊和內(nèi)聯(lián)布局
注意:這部分包含的圖表如果沒有相關(guān)的視覺樣式,就沒有意義。如果您是在一個提要閱讀器中閱讀這篇文章,嘗試在一個常規(guī)的瀏覽器選項卡中打開原始頁面。我還為使用屏幕閱讀器或其他輔助技術(shù)的讀者提供了文本描述。
CSS display屬性決定一個元素生成哪種類型的框。CSS定義了幾種框類型,每種都有自己的布局規(guī)則。我只講其中的兩種:塊和內(nèi)聯(lián)。
我將使用這一點偽html來說明區(qū)別:
塊級框從上到下垂直地放置在容器中。
a, b, c, d { display: block; }

行內(nèi)框從左到右水平地放置在容器中。如果它們到達了容器的右邊緣,它們將環(huán)繞并繼續(xù)在下面的新行。
a, b, c, d { display: inline; }

每個框必須只包含塊級子元素或行內(nèi)子元素。當DOM元素包含塊級子元素和內(nèi)聯(lián)子元素時,布局引擎會插入匿名框來分隔這兩種類型。(這些框是“匿名的”,因為它們與DOM樹中的節(jié)點沒有關(guān)聯(lián)。)
在這個例子中,內(nèi)聯(lián)框b和c被一個匿名塊框包圍,粉紅色顯示:
a { display: block; }
b, c { display: inline; }
d { display: block; }

注意,內(nèi)容默認垂直增長。也就是說,向容器中添加子元素通常會使容器更高,而不是更寬。另一種說法是,默認情況下,塊或行的寬度取決于其容器的寬度,而容器的高度取決于其子容器的高度。
如果你覆蓋了屬性的默認值,比如寬度和高度,這將變得更加復雜,如果你想要支持像垂直書寫這樣的特性,這將變得更加復雜。
布局樹
布局樹是一個框的集合。一個盒子有尺寸,它可能包含子盒子。
struct LayoutBox<'a> {
dimensions: Dimensions,
box_type: BoxType<'a>,
children: Vec>,
}
框可以是塊節(jié)點、內(nèi)聯(lián)節(jié)點或匿名塊框。(當我實現(xiàn)文本布局時,這需要改變,因為行換行會導致一個內(nèi)聯(lián)節(jié)點被分割成多個框。但現(xiàn)在就可以了。)
enum BoxType<'a> {
BlockNode(&'a StyledNode<'a>),
InlineNode(&'a StyledNode<'a>),
AnonymousBlock,
}
要構(gòu)建布局樹,我們需要查看每個DOM節(jié)點的display屬性。我向style模塊添加了一些代碼,以獲取節(jié)點的顯示值。如果沒有指定值,則返回初始值'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 {
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
}
}
}
現(xiàn)在我們可以遍歷樣式樹,為每個節(jié)點構(gòu)建一個LayoutBox,然后為節(jié)點的子節(jié)點插入框。如果一個節(jié)點的display屬性被設(shè)置為'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(),
}
}
// ...
}
如果塊節(jié)點包含內(nèi)聯(lián)子節(jié)點,則創(chuàng)建一個匿名塊框來包含它。如果一行中有幾個內(nèi)聯(lián)子元素,則將它們都放在同一個匿名容器中。
// 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框生成算法的多種方式簡化的。例如,它不處理內(nèi)聯(lián)框包含塊級子框的情況。此外,如果塊級節(jié)點只有內(nèi)聯(lián)子節(jié)點,則會生成一個不必要的匿名框。
未完待續(xù)
哇,比我想象的要長。我想我就講到這里,但是不要擔心:第6部分很快就會到來,它將討論塊級布局。
一旦塊布局完成,我們就可以跳轉(zhuǎn)到管道的下一個階段:繪制!我想我可能會這么做,因為這樣我們最終可以看到渲染引擎的輸出是漂亮的圖片而不是數(shù)字。
然而,這些圖片將只是一堆彩色的矩形,除非我們通過實現(xiàn)內(nèi)聯(lián)布局和文本布局來完成布局模塊。如果我在開始繪畫之前沒有實現(xiàn)這些,我希望之后再回到它們上來。
第六部分:塊布局
歡迎回到我關(guān)于構(gòu)建一個玩具HTML渲染引擎的系列文章,這是系列文章的第6篇。
本文將繼續(xù)我們在第5部分中開始的布局模塊。這一次,我們將添加布局塊框的功能。這些框是垂直堆疊的,比如標題和段落。
為了簡單起見,這段代碼只實現(xiàn)了正常流:沒有浮動,沒有絕對定位,也沒有固定定位。
遍歷布局樹
該代碼的入口點是layout函數(shù),它接受一個LayoutBox并計算其尺寸。我們將把這個函數(shù)分為三種情況,目前只實現(xiàn)其中一種:
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();
}
該函數(shù)對布局樹執(zhí)行一次遍歷,向下時進行寬度計算,向上時進行高度計算。一個真正的布局引擎可能會執(zhí)行幾次樹遍歷,一些是自頂向下,一些是自底向上。
計算寬度
寬度計算是塊布局函數(shù)的第一步,也是最復雜的一步。我要一步一步來。首先,我們需要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的助手函數(shù),它只是按順序嘗試一系列值。如果第一個屬性沒有設(shè)置,它將嘗試第二個屬性。如果沒有設(shè)置,它將返回給定的默認值。這提供了一個不完整(但簡單)的簡寫屬性和初始值實現(xiàn)。
注意:這類似于JavaScript或Ruby中的以下代碼:
margin_left = style["margin-left"] || style["margin"] || zero;
因為子對象不能改變父對象的寬度,所以它需要確保自己的寬度與父對象的寬度相符。CSS規(guī)范將其表達為一組約束和解決它們的算法。下面的代碼實現(xiàn)了該算法。
首先,我們將邊距、內(nèi)邊距、邊框和內(nèi)容寬度相加。to_px幫助器方法將長度轉(zhuǎn)換為它們的數(shù)值。如果一個屬性被設(shè)置為'auto',它會返回0,因此它不會影響和。
let total = [&margin_left, &margin_right, &border_left, &border_right,
&padding_left, &padding_right, &width].iter().map(|v| v.to_px()).sum();
這是盒子所需要的最小水平空間。如果它不等于容器的寬度,我們需要調(diào)整一些東西使它相等。
如果寬度或邊距設(shè)置為“auto”,它們可以擴展或收縮以適應(yīng)可用的空間。按照說明書,我們首先檢查盒子是否太大。如果是這樣,我們將任何可擴展邊距設(shè)置為零。
// 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);
}
}
如果盒子對容器來說太大,就會溢出容器。如果太小,它就會下泄,留下額外的空間。我們將計算下溢量,即容器內(nèi)剩余空間的大小。(如果這個數(shù)字是負數(shù),它實際上是一個溢出。)
let underflow = containing_block.content.width - total;
我們現(xiàn)在遵循規(guī)范的算法,通過調(diào)整可擴展的尺寸來消除任何溢出或下溢。如果沒有“自動”尺寸,我們調(diào)整右邊的邊距。(是的,這意味著在溢出的情況下,邊界可能是負的!)
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);
}
}
此時,約束已經(jīng)滿足,任何'auto'值都已經(jīng)轉(zhuǎn)換為長度。結(jié)果是水平框尺寸的使用值,我們將把它存儲在布局樹中。你可以在layout.rs中看到最終的代碼。
定位
下一步比較簡單。這個函數(shù)查找剩余的邊距/內(nèi)邊距/邊框樣式,并使用這些樣式和包含的塊尺寸來確定這個塊在頁面上的位置。
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;
}
仔細看看最后一條語句,它設(shè)置了y的位置。這就是為什么塊布局具有獨特的垂直堆疊行為。為了實現(xiàn)這一點,我們需要確保父節(jié)點的內(nèi)容。高度在布局每個子元素后更新。
子元素
下面是遞歸布局框內(nèi)容的代碼。當它循環(huán)遍歷子框時,它會跟蹤總內(nèi)容高度。定位代碼(上面)使用這個函數(shù)來查找下一個子元素的垂直位置。
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;
}
}
每個子節(jié)點占用的總垂直空間是其邊距框的高度,我們是這樣計算的:
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,
}
}
}
為簡單起見,這里沒有實現(xiàn)邊距折疊。一個真正的布局引擎會允許一個框的底部邊緣與下一個框的頂部邊緣重疊,而不是每個框都完全放在前一個框的下面。
“高度”屬性
默認情況下,框的高度等于其內(nèi)容的高度。但如果'height'屬性被顯式設(shè)置為長度,我們將使用它來代替:
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;
}
}
這就是塊布局算法?,F(xiàn)在你可以在一個HTML文檔上調(diào)用layout(),它會生成一堆矩形,包括寬度、高度、邊距等。很酷,對吧?
練習
對于雄心勃勃的實現(xiàn)者,一些額外的想法:
崩潰的垂直邊緣。 相對定位。 并行化布局過程,并測量對性能的影響。
如果您嘗試并行化項目,您可能想要將寬度計算和高度計算分離為兩個不同的通道。通過為每個子任務(wù)生成一個單獨的任務(wù),從上至下遍歷寬度很容易并行化。高度的計算要稍微復雜一些,因為您需要返回并在每個子元素被布局之后調(diào)整它們的y位置。
未完待續(xù)
感謝所有跟隨我走到這一步的人!
隨著我深入到布局和渲染的陌生領(lǐng)域,這些文章的編寫時間越來越長。在我試驗字體和圖形代碼的下一部分之前,會有一段較長的時間中斷,但我會盡快恢復這個系列。
更新:第7部分現(xiàn)在準備好了。
第七部分:繪制 101
歡迎回到我的關(guān)于構(gòu)建一個簡單HTML渲染引擎的系列,這是第7篇,也是最后一篇。
在這篇文章中,我將添加非?;镜睦L畫代碼。這段代碼從布局模塊中獲取框樹,并將它們轉(zhuǎn)換為像素數(shù)組。這個過程也稱為“柵格化”。

瀏覽器通常在Skia、Cairo、Direct2D等圖形api和庫的幫助下實現(xiàn)光柵化。這些api提供了繪制多邊形、直線、曲線、漸變和文本的函數(shù)。現(xiàn)在,我將編寫我自己的光柵化程序,它只能繪制一種東西:矩形。
最后我想實現(xiàn)文本渲染。在這一點上,我可能會拋棄這個玩具繪畫代碼,轉(zhuǎn)而使用“真正的”2D圖形庫。但就目前而言,矩形足以將我的塊布局算法的輸出轉(zhuǎn)換為圖片。
迎頭趕上
從上一篇文章開始,我對以前文章中的代碼做了一些小的修改。這包括一些小的重構(gòu),以及一些更新,以保持代碼與最新的Rust夜間構(gòu)建兼容。這些更改對理解代碼都不是至關(guān)重要的,但是如果您好奇的話,可以查看提交歷史記錄。
構(gòu)建顯示列表
在繪制之前,我們將遍歷布局樹并構(gòu)建一個顯示列表。這是一個圖形操作列表,如“繪制圓圈”或“繪制文本字符串”。或者在我們的例子中,只是“畫一個矩形”。
為什么要將命令放入顯示列表中,而不是立即執(zhí)行它們?顯示列表之所以有用有幾個原因。你可以通過搜索來找到被后期操作完全掩蓋的物品,并將其移除,以消除浪費的油漆。在只知道某些項發(fā)生了更改的情況下,可以修改和重用顯示列表。您可以使用相同的顯示列表生成不同類型的輸出:例如,用于在屏幕上顯示的像素,或用于發(fā)送到打印機的矢量圖形。
Robinson的顯示列表是顯示命令的向量。目前,只有一種類型的DisplayCommand,一個純色矩形:
type DisplayList = Vec;
enum DisplayCommand {
SolidColor(Color, Rect),
// insert more commands here
}
為了構(gòu)建顯示列表,我們遍歷布局樹并為每個框生成一系列命令。首先,我們繪制框的背景,然后在背景頂部繪制邊框和內(nèi)容。
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元素是按照它們出現(xiàn)的順序堆疊的:如果兩個元素重疊,則后面的元素畫在前面的元素之上。這反映在我們的顯示列表中,它將按照它們在DOM樹中出現(xiàn)的順序繪制元素。如果這段代碼支持z-index屬性,那么各個元素將能夠覆蓋這個堆疊順序,我們需要相應(yīng)地對顯示列表進行排序。
背景很簡單。它只是一個實心矩形。如果沒有指定背景顏色,那么背景是透明的,我們不需要生成顯示命令。
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 {
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,
}));
}
接下來,渲染函數(shù)將繪制盒子的每個子元素,直到整個布局樹被轉(zhuǎn)換成顯示命令為止。
光柵化
現(xiàn)在我們已經(jīng)構(gòu)建了顯示列表,我們需要通過執(zhí)行每個DisplayCommand將其轉(zhuǎn)換為像素。我們將把像素存儲在畫布中:
struct Canvas {
pixels: Vec,
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,
}
}
// ...
}
要在畫布上繪制矩形,只需循環(huán)遍歷它的行和列,使用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()值的支持),那么它就需要將每個新像素與它所繪制的任何內(nèi)容混合在一起。
現(xiàn)在我們可以把所有東西都放到paint函數(shù)中,它會構(gòu)建一個顯示列表,然后柵格化到畫布上:
// 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圖像庫將像素數(shù)組保存為PNG文件。漂亮的圖片
最后,我們已經(jīng)到達渲染管道的末端。在不到1000行代碼中,robinson現(xiàn)在可以解析這個HTML文件了:
和這個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; }
得到以下效果:

耶!
練習
如果你是獨自在家玩,這里有一些你可能想嘗試的事情:
編寫一個替代的繪圖函數(shù),它接受顯示列表并生成矢量輸出(例如,SVG文件),而不是柵格圖像。
添加對不透明度和alpha混合的支持。
編寫一個函數(shù),通過剔除完全超出畫布邊界的項來優(yōu)化顯示列表。
如果你熟悉OpenGL,可以編寫一個使用GL著色器繪制矩形的硬件加速繪制函數(shù)。
尾聲
現(xiàn)在我們已經(jīng)獲得了渲染管道中每個階段的基本功能,現(xiàn)在是時候回去填補一些缺失的特性了——特別是內(nèi)聯(lián)布局和文本渲染。以后的文章還可能添加額外的階段,如網(wǎng)絡(luò)和腳本。
我將在本月的灣區(qū)Rust聚會上做一個簡短的演講,“讓我們構(gòu)建一個瀏覽器引擎吧!”會議將于明天(11月6日,周四)晚上7點在Mozilla的舊金山辦公室舉行,屆時我的伺服開發(fā)伙伴們也將進行有關(guān)伺服的演講。會談的視頻將在Air Mozilla上進行直播,錄音將在稍后發(fā)布。
原文寫于2014.8.8。
最后
歡迎加我微信(winty230),拉你進技術(shù)群,長期交流學習...
歡迎關(guān)注「前端Q」,認真學前端,做個專業(yè)的技術(shù)人...


