从零开始实现一个玩具版浏览器渲染引擎(一)

简介: 从零开始实现一个玩具版浏览器渲染引擎

前言

浏览器渲染原理作为前端必须要了解的知识点之一,在面试中经常会被问到。在一些前端书籍或者培训课程里也会经常被提及,比如 MDN 文档中就有渲染原理的相关描述。

作为一名工作多年的前端,我对于渲染原理自然也是了解的,但是对于它的理解只停留在理论知识层面。所以我决定自己动手实现一个玩具版的渲染引擎。

渲染引擎是浏览器的一部分,它负责将网页内容(HTML、CSS、JavaScript 等)转化为用户可阅读、观看、听到的形式。但是要独自实现一个完整的渲染引擎工作量实在太大了,而且也很困难。于是我决定退一步,打算实现一个玩具版的渲染引擎。刚好 Github 上有一个开源的用 Rust 写的玩具版渲染引擎 robinson,于是决定模仿其源码自己用 JavaScript 实现一遍,并且也在 Github 上开源了 从零开始实现一个玩具版浏览器渲染引擎

这个玩具版的渲染引擎一共分为五个阶段:

分别是:

  1. 解析 HTML,生成 DOM 树
  2. 解析 CSS,生成 CSS 规则集合
  3. 生成 Style 树
  4. 生成布局树
  5. 绘制

每个阶段的代码我在仓库上都用一个分支来表示。由于直接看整个渲染引擎的代码可能会比较困难,所以我建议大家从第一个分支开始进行学习,从易到难,这样学习效果更好。

现在我们先看一下如何编写一个 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 的功能进行约束:

  1. 标签必须要成对出现:<div>...</div>
  2. HTML 属性值必须要有引号包起来 <div class="test">...</div>
  3. 不支持注释
  4. 尽量不做错误处理
  5. 只支持两种类型节点 ElementText

对解析器的功能进行约束后,代码实现就变得简单多了,现在让我们继续吧。

节点类型

首先,为这两种节点 ElementText 定一个适当的数据结构:

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 文本为止:

  1. 判断当前字符是否为 <,如果是,则当作元素节点来解析,调用 parseElement(),否则调用 parseText()
  2. parseText() 比较简单,一直往前遍历字符串,直至遇到 < 字符为止。然后将之前遍历过的所有字符当作 Text 节点的值。
  3. parseElement() 则相对复杂一点,它首先要解析出当前的元素标签名称,这段文本用 parseTag() 来解析。
  4. 然后再进入 parseAttrs() 方法,判断是否有属性节点,如果该节点有 class 或者其他 HTML 属性,则会调用 parseAttr() 把 HTML 属性或者 class 解析出来。
  5. 至此,整个元素节点的前半段已经解析完了。接下来需要解析它的子节点。这时就会进入无限递归循环回到第一步,继续解析元素节点或文本节点。
  6. 当所有子节点解析完后,需要调用 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 多行。



目录
相关文章
|
9月前
|
JavaScript 前端开发 容器
从零开始实现一个玩具版浏览器渲染引擎(三)
从零开始实现一个玩具版浏览器渲染引擎(三)
32 0
|
9月前
|
前端开发 JavaScript
从零开始实现一个玩具版浏览器渲染引擎(二)
从零开始实现一个玩具版浏览器渲染引擎(二)
46 0
|
9月前
|
前端开发 JavaScript API
从零开始实现一个玩具版浏览器渲染引擎(四)
从零开始实现一个玩具版浏览器渲染引擎(四)
50 0
|
Web App开发 JavaScript 前端开发
浏览器渲染引擎工作原理|学习笔记
快速学习浏览器渲染引擎工作原理
216 0
浏览器渲染引擎工作原理|学习笔记
|
Web App开发 存储 移动开发
浏览器内核(渲染引擎)介绍|学习笔记
快速学习浏览器内核(渲染引擎)介绍
262 0
|
Web App开发 存储 JavaScript
浏览器渲染引擎与阻塞
浏览器渲染引擎与阻塞
|
Web App开发 JavaScript 前端开发
画了20张图,详解浏览器渲染引擎工作原理(下)
今天我们来学习一下浏览器渲染引擎的工作原理,文章内容较多,建议先收藏再学习! 先来看看Chrome浏览器的架构图:
269 0
|
4天前
|
JavaScript
浏览器插件crx文件--JS混淆与解密
浏览器插件crx文件--JS混淆与解密
25 0
|
4天前
|
JavaScript 前端开发 UED
JS:如何获取浏览器窗口尺寸?
JS:如何获取浏览器窗口尺寸?
57 1
|
4天前
|
JavaScript 前端开发 算法
Node.js中的process.nextTick与浏览器环境中的nextTick有何不同?
Node.js中的process.nextTick与浏览器环境中的nextTick有何不同?