让我们来构建一个浏览器引擎吧(建议收藏)

前端Q

共 7981字,需浏览 16分钟

 · 2021-01-24


来源:https://segmentfault.com/a/1190000038859456

DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)
官方交流:添加DevUI小助手(devui-official)
DevUIHelper插件:DevUIHelper-LSP(欢迎Star)

引言

前端有一个经典的面试题:在浏览器地址栏输入URL到最终呈现出页面,中间发生了什么?

中间有一个过程是获取后台返回的HTML文本,浏览器渲染引擎将其解析成DOM树,并将HTML中的CSS解析成样式树,然后将DOM树和样式树合并成布局树,并最终由绘图程序绘制到浏览器画板上。

本文通过亲自动手实践,教你一步一步实现一个迷你版浏览器引擎,进而深入理解渲染引擎的工作原理,干货满满。

主要分成七个部分:

  • 第一部分:开始
  • 第二部分:HTML
  • 第三部分:CSS
  • 第四部分:样式
  • 第五部分:盒子
  • 第六部分:块布局
  • 第七部分:绘制 101

原文写于2014.8.8。

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

以下是正文:

第一部分:开始

我正在构建一个“玩具”渲染引擎,我认为你也应该这样做。这是一系列文章中的第一篇。

完整的系列文章将描述我编写的代码,并向你展示如何编写自己的代码。但首先,让我解释一下原因。

你在造什么?

让我们谈谈术语。浏览器引擎是web浏览器的一部分,它在“底层”工作,从Internet上获取网页,并将其内容转换成可以阅读、观看、听等形式。Blink、Gecko、WebKit和Trident都是浏览器引擎。相比之下,浏览器本身的用户界面(标签、工具栏、菜单等)被称为chrome。Firefox和SeaMonkey是两个浏览器,使用不同的chrome,但使用相同的Gecko引擎。

浏览器引擎包括许多子组件: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,

// 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;

最后,一些构造函数使创建新节点变得容易:

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实现将包含更多的数据和几十个方法,但这就是我们开始所需要的。

练习

这些只是一些在家可以遵循的建议。做你感兴趣的练习,跳过不感兴趣的。

  1. 用你选择的语言启动一个新程序,并编写代码来表示DOM文本节点和元素树。
  2. 安装最新版本的Rust,然后下载并构建robinson。打开dom.rs和扩展NodeType以包含其他类型,如注释节点
  3. 编写代码来美化DOM节点树。

在下一篇文章中,我们将添加一个将HTML源代码转换为这些DOM节点树的解析器。

参考文献

有关浏览器引擎内部结构的更多详细信息,请参阅Tali Garsiel非常精彩的浏览器的工作原理及其到更多资源的链接。

例如代码,这里有一个“小型”开源web呈现引擎的简短列表。它们大多比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++)

你可能会发现这些有用的灵感或参考。如果你知道任何其他类似的项目,或者如果你开始自己的项目,请让我知道!

第二部分:HTML

这是构建一个玩具浏览器渲染引擎系列文章的第二篇。

本文是关于解析HTML源代码以生成DOM节点树的。解析是一个很吸引人的话题,但是我没有足够的时间或专业知识来介绍它。你可以从任何关于编译器的优秀课程或书籍中获得关于解析的详细介绍。或者通过阅读与你选择的编程语言一起工作的解析器生成器的文档来获得动手操作的开始。

HTML有自己独特的解析算法。与大多数编程语言和文件格式的解析器不同,HTML解析算法不会拒绝无效的输入。相反,它包含了特定的错误处理指令,因此web浏览器可以就如何显示每个web页面达成一致,即使是那些不符合语法规则的页面。Web浏览器必须做到这一点才能使用:因为不符合标准的HTML在Web早期就得到了支持,所以现在大部分现有Web页面都在使用它。

简单的HTML方言

我甚至没有尝试实现标准的HTML解析算法。相反,我为HTML语法的一小部分编写了一个基本解析器。我的解析器可以处理这样的简单页面:



Title



Hello world!





允许使用以下语法:

  • 闭合的标签:

  • 带引号的属性:id="main"
  • 文本节点:world

其他所有内容都不支持,包括:

  • 评论
  • Doctype声明
  • 转义字符(如&)和CDATA节
  • 自结束标签:

    没有结束标签
  • 错误处理(例如未闭合或不正确嵌套的标签)
  • 名称空间和其他XHTML语法:
  • 字符编码检测

在这个项目的每个阶段,我都或多或少地编写了支持后面阶段所需的最小代码。但是如果你想学习更多的解析理论和工具,你可以在你自己的项目中更加雄心勃勃!

示例代码

接下来,让我们回顾一下我的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(&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 {
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代码):

// Hello, world!
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,
}

一条规则包括一个或多个用逗号分隔的选择器,后跟一系列用大括号括起来的声明。

struct Rule {
selectors: Vec,
declarations: Vec,
}

一个选择器可以是一个简单的选择器,也可以是一个由_组合符_连接的选择器链。Robinson目前只支持简单的选择器。

注意:令人困惑的是,新的Selectors Level 3标准使用相同的术语来表示略有不同的东西。在本文中,我主要引用CSS2.1。尽管过时了,但它是一个有用的起点,因为它更小,更独立(与CSS3相比,CSS3被分成无数互相依赖和CSS2.1的规范)。

在robinson中,一个简单选择器可以包括一个标记名,一个以'#'为前缀的ID,任意数量的以'.'为前缀的类名,或以上几种情况的组合。如果标签名为空或'*',那么它是一个“通用选择器”,可以匹配任何标签。

还有许多其他类型的选择器(特别是在CSS3中),但现在这样就可以了。

enum Selector {
Simple(SimpleSelector),
}

struct SimpleSelector {
tag_name: Option,
id: Option,
class: Vec,
}

声明只是一个名称/值对,由冒号分隔并以分号结束。例如,“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: ` {  }`.
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部分中还没有这样做,那么现在是尝试解析器生成器的绝佳时机。我的手卷解析器完成了简单示例文件的工作,但它有很多漏洞,如果您违反了它的假设,它将严重失败。有一天,我可能会用rust-peg或类似的东西来取代它。

练习

和以前一样,你应该决定你想做哪些练习,并跳过其余的:

  1. 实现您自己的简化CSS解析器和优先级计算。
  2. 扩展robinson的CSS解析器,以支持更多的值,或一个或多个选择器组合符。
  3. 扩展CSS解析器,丢弃任何包含解析错误的声明,并遵循错误处理规则,在声明结束后继续解析。
  4. 让HTML解析器将任何