前言
浏览器渲染原理作为前端必须要了解的知识点之一,在面试中经常会被问到。在一些前端书籍或者培训课程里也会经常被提及,比如 MDN 文档中就有渲染原理的相关描述。
作为一名工作多年的前端,我对于渲染原理自然也是了解的,但是对于它的理解只停留在理论知识层面。所以我决定自己动手实现一个玩具版的渲染引擎。
渲染引擎是浏览器的一部分,它负责将网页内容(HTML、CSS、JavaScript 等)转化为用户可阅读、观看、听到的形式。但是要独自实现一个完整的渲染引擎工作量实在太大了,而且也很困难。于是我决定退一步,打算实现一个玩具版的渲染引擎。刚好 Github 上有一个开源的用 Rust 写的玩具版渲染引擎 robinson,于是决定模仿其源码自己用 JavaScript 实现一遍,并且也在 Github 上开源了 从零开始实现一个玩具版浏览器渲染引擎。
这个玩具版的渲染引擎一共分为五个阶段:
分别是:
- 解析 HTML,生成 DOM 树
- 解析 CSS,生成 CSS 规则集合
- 生成 Style 树
- 生成布局树
- 绘制
每个阶段的代码我在仓库上都用一个分支来表示。由于直接看整个渲染引擎的代码可能会比较困难,所以我建议大家从第一个分支开始进行学习,从易到难,这样学习效果更好。
现在我们先看一下如何编写一个 HTML 解析器。
HTML 解析器
HTML 解析器的作用就是将一连串的 HTML 文本解析为 DOM 树。比如将这样的 HTML 文本:
<div class="lightblue test" id=" div " data-index="1">test!</div>
解析为一个 DOM 树:
{ "tagName": "div", "attributes": { "class": "lightblue test", "id": "div", "data-index": "1" }, "children": [ { "nodeValue": "test!", "nodeType": 3 } ], "nodeType": 1 }
写解析器需要懂一些编译原理的知识,比如词法分析、语法分析什么的。但是我们的玩具版解析器非常简单,即使不懂也没有关系,大家看源码就能明白了。
再回到上面的那段 HTML 文本,它的整个解析过程可以用下面的图来表示,每一段 HTML 文本都有对应的方法去解析。
为了让解析器实现起来简单一点,我们需要对 HTML 的功能进行约束:
- 标签必须要成对出现:
<div>...</div>
- HTML 属性值必须要有引号包起来
<div class="test">...</div>
- 不支持注释
- 尽量不做错误处理
- 只支持两种类型节点
Element
和Text
对解析器的功能进行约束后,代码实现就变得简单多了,现在让我们继续吧。
节点类型
首先,为这两种节点 Element
和 Text
定一个适当的数据结构:
export enum NodeType { Element = 1, Text = 3, } export interface Element { tagName: string attributes: Record<string, string> children: Node[] nodeType: NodeType.Element } interface Text { nodeValue: string nodeType: NodeType.Text } export type Node = Element | Text
然后为这两种节点各写一个生成函数:
export function element(tagName: string) { return { tagName, attributes: {}, children: [], nodeType: NodeType.Element, } as Element } export function text(data: string) { return { nodeValue: data, nodeType: NodeType.Text, } as Text }
这两个函数在解析到元素节点或者文本节点时调用,调用后会返回对应的 DOM 节点。
HTML 解析器的执行过程
下面这张图就是整个 HTML 解析器的执行过程:
HTML 解析器的入口方法为 parse()
,从这开始执行直到遍历完所有 HTML 文本为止:
- 判断当前字符是否为
<
,如果是,则当作元素节点来解析,调用parseElement()
,否则调用parseText()
parseText()
比较简单,一直往前遍历字符串,直至遇到<
字符为止。然后将之前遍历过的所有字符当作Text
节点的值。parseElement()
则相对复杂一点,它首先要解析出当前的元素标签名称,这段文本用parseTag()
来解析。- 然后再进入
parseAttrs()
方法,判断是否有属性节点,如果该节点有class
或者其他 HTML 属性,则会调用parseAttr()
把 HTML 属性或者class
解析出来。 - 至此,整个元素节点的前半段已经解析完了。接下来需要解析它的子节点。这时就会进入无限递归循环回到第一步,继续解析元素节点或文本节点。
- 当所有子节点解析完后,需要调用
parseTag()
,看看结束标签名和元素节点的开始标签名是否相同,如果相同,则parseElement()
或者parse()
结束,否则报错。
HTML 解析器各个方法详解
入口方法 parse()
HTML 的入口方法是 parse(rawText)
parse(rawText: string) { this.rawText = rawText.trim() this.len = this.rawText.length this.index = 0 this.stack = [] const root = element('root') while (this.index < this.len) { this.removeSpaces() if (this.rawText[this.index].startsWith('<')) { this.index++ this.parseElement(root) } else { this.parseText(root) } } }
入口方法需要遍历所有文本,在一开始它需要判断当前字符是否是 <
,如果是,则将它当作元素节点来解析,调用 parseElement()
,否则将当前字符作为文本来解析,调用 parseText()
。
解析元素节点 parseElement()
private parseElement(parent: Element) { // 解析标签 const tag = this.parseTag() // 生成元素节点 const ele = element(tag) this.stack.push(tag) parent.children.push(ele) // 解析属性 this.parseAttrs(ele) while (this.index < this.len) { this.removeSpaces() if (this.rawText[this.index].startsWith('<')) { this.index++ this.removeSpaces() // 判断是否是结束标签 if (this.rawText[this.index].startsWith('/')) { this.index++ const startTag = this.stack[this.stack.length - 1] // 结束标签 const endTag = this.parseTag() if (startTag !== endTag) { throw Error(`The end tagName ${endTag} does not match start tagName ${startTag}`) } this.stack.pop() while (this.index < this.len && this.rawText[this.index] !== '>') { this.index++ } break } else { this.parseElement(ele) } } else { this.parseText(ele) } } this.index++ }
parseElement()
会依次调用 parseTag()
parseAttrs()
解析标签和属性,然后再递归解析子节点,终止条件是遍历完所有的 HTML 文本。
解析文本节点 parseText()
private parseText(parent: Element) { let str = '' while ( this.index < this.len && !(this.rawText[this.index] === '<' && /\w|\//.test(this.rawText[this.index + 1])) ) { str += this.rawText[this.index] this.index++ } this.sliceText() parent.children.push(text(removeExtraSpaces(str))) }
解析文本相对简单一点,它会一直往前遍历,直至遇到 <
为止。比如这段文本 <div>test!</div>
,经过 parseText()
解析后拿到的文本是 test!
。
解析标签 parseTag()
在进入 parseElement()
后,首先调用就是 parseTag()
,它的作用是解析标签名:
private parseTag() { let tag = '' this.removeSpaces() // get tag name while (this.index < this.len && this.rawText[this.index] !== ' ' && this.rawText[this.index] !== '>') { tag += this.rawText[this.index] this.index++ } this.sliceText() return tag }
比如这段文本 <div>test!</div>
,经过 parseTag()
解析后拿到的标签名是 div
。
解析属性节点 parseAttrs()
解析完标签名后,接着再解析属性节点:
// 解析元素节点的所有属性 private parseAttrs(ele: Element) { // 一直遍历文本,直至遇到 '>' 字符为止,代表 <div ....> 这一段文本已经解析完了 while (this.index < this.len && this.rawText[this.index] !== '>') { this.removeSpaces() this.parseAttr(ele) this.removeSpaces() } this.index++ } // 解析单个属性,例如 class="foo bar" private parseAttr(ele: Element) { let attr = '' let value = '' while (this.index < this.len && this.rawText[this.index] !== '=' && this.rawText[this.index] !== '>') { attr += this.rawText[this.index++] } this.sliceText() attr = attr.trim() if (!attr.trim()) return this.index++ let startSymbol = '' if (this.rawText[this.index] === "'" || this.rawText[this.index] === '"') { startSymbol = this.rawText[this.index++] } while (this.index < this.len && this.rawText[this.index] !== startSymbol) { value += this.rawText[this.index++] } this.index++ ele.attributes[attr] = value.trim() this.sliceText() }
parseAttr()
可以将这样的文本 class="test"
解析为一个对象 { class: "test" }
。
其他辅助方法
有时不同的节点、属性之间有很多多余的空格,所以需要写一个方法将多余的空格清除掉。
protected removeSpaces() { while (this.index < this.len && (this.rawText[this.index] === ' ' || this.rawText[this.index] === '\n')) { this.index++ } this.sliceText() }
同时为了方便调试,开发者经常需要打断点看当前正在遍历的字符是什么。如果以前遍历过的字符串还在,那么是比较难调试的,因为开发者需要根据 index 的值自己去找当前遍历的字符是什么。所以所有解析完的 HTML 文本,都需要截取掉,确保当前的 HTML 文本都是没有被遍历:
protected sliceText() { this.rawText = this.rawText.slice(this.index) this.len = this.rawText.length this.index = 0 }
sliceText()
方法的作用就是截取已经遍历过的 HTML 文本。用下图来做例子,假设当前要解析 div
这个标签名:
那么解析后需要对 HTML 文本进行截取,就像下图这样:
小结
至此,整个 HTML 解析器的逻辑已经讲完了,所有代码加起来 200 行左右,如果不算 TS 各种类型声明,代码只有 100 多行。