博客 / 詳情

返回

從零開始實現一個玩具版瀏覽器渲染引擎

前言

瀏覽器渲染原理作為前端必須要了解的知識點之一,在面試中經常會被問到。在一些前端書籍或者培訓課程裏也會經常被提及,比如 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 多行。

CSS 解析器

CSS 樣式表是一系列的 CSS 規則集合,而 CSS 解析器的作用就是將 CSS 文本解析為 CSS 規則集合。

div, p {
    font-size: 88px;
    color: #000;
}

例如上面的 CSS 文本,經過解析器解析後,會生成下面的 CSS 規則集合:

[
    {
        "selectors": [
            {
                "id": "",
                "class": "",
                "tagName": "div"
            },
            {
                "id": "",
                "class": "",
                "tagName": "p"
            }
        ],
        "declarations": [
            {
                "name": "font-size",
                "value": "88px"
            },
            {
                "name": "color",
                "value": "#000"
            }
        ]
    }
]

每個規則都有一個 selectordeclarations 屬性,其中 selectors 表示 CSS 選擇器,declarations 表示 CSS 的屬性描述集合。

export interface Rule {
    selectors: Selector[]
    declarations: Declaration[]
}

export interface Selector {
    tagName: string
    id: string
    class: string
}

export interface Declaration {
    name: string
    value: string | number
}

在這裏插入圖片描述

每一條 CSS 規則都可以包含多個選擇器和多個 CSS 屬性。

解析 CSS 規則 parseRule()

private parseRule() {
    const rule: Rule = {
        selectors: [],
        declarations: [],
    }

    rule.selectors = this.parseSelectors()
    rule.declarations = this.parseDeclarations()

    return rule
}

parseRule() 裏,它分別調用了 parseSelectors() 去解析 CSS 選擇器,然後再對剩餘的 CSS 文本執行 parseDeclarations() 去解析 CSS 屬性。

解析選擇器 parseSelector()

private parseSelector() {
    const selector: Selector = {
        id: '',
        class: '',
        tagName: '',
    }

    switch (this.rawText[this.index]) {
        case '.':
            this.index++
            selector.class = this.parseIdentifier()
            break
        case '#':
            this.index++
            selector.id = this.parseIdentifier()
            break
        case '*':
            this.index++
            selector.tagName = '*'
            break
        default:
            selector.tagName = this.parseIdentifier()
    }

    return selector
}

private parseIdentifier() {
    let result = ''
    while (this.index < this.len && this.identifierRE.test(this.rawText[this.index])) {
        result += this.rawText[this.index++]
    }

    this.sliceText()
    return result
}

選擇器我們只支持標籤名稱、前綴為 # 的 ID 、前綴為任意數量的類名 . 或上述的某種組合。如果標籤名稱為 *,則表示它是一個通用選擇器,可以匹配任何標籤。

標準的 CSS 解析器在遇到無法識別的部分時,會將它丟掉,然後繼續解析其餘部分。主要是為了兼容舊瀏覽器和防止發生錯誤導致程序中斷。我們的 CSS 解析器為了實現簡單,沒有做這方面的做錯誤處理。

解析 CSS 屬性 parseDeclaration()

private parseDeclaration() {
    const declaration: Declaration = { name: '', value: '' }
    this.removeSpaces()
    declaration.name = this.parseIdentifier()
    this.removeSpaces()

    while (this.index < this.len && this.rawText[this.index] !== ':') {
        this.index++
    }

    this.index++ // clear :
    this.removeSpaces()
    declaration.value = this.parseValue()
    this.removeSpaces()

    return declaration
}

parseDeclaration() 會將 color: red; 解析為一個對象 { name: "color", value: "red" }

小結

CSS 解析器相對來説簡單多了,因為很多知識點在 HTML 解析器中已經講到。整個 CSS 解析器的代碼大概 100 多行,如果你閲讀過 HTML 解析器的源碼,相信看 CSS 解析器的源碼會更輕鬆。

構建樣式樹

本階段的目標是寫一個樣式構建器,輸入 DOM 樹和 CSS 規則集合,生成一棵樣式樹 Style tree。

在這裏插入圖片描述

樣式樹的每一個節點都包含了 CSS 屬性值以及它對應的 DOM 節點引用:

interface AnyObject {
    [key: string]: any
}

export interface StyleNode {
    node: Node // DOM 節點
    values: AnyObject // style 屬性值
    children: StyleNode[] // style 子節點
}

先來看一個簡單的示例:

<div>test</div>
div {
    font-size: 88px;
    color: #000;
}

上述的 HTML、CSS 文本在經過樣式樹構建器處理後生成的樣式樹如下:

{
    "node": { // DOM 節點
        "tagName": "div",
        "attributes": {},
        "children": [
            {
                "nodeValue": "test",
                "nodeType": 3
            }
        ],
        "nodeType": 1
    },
    "values": { // CSS 屬性值
        "font-size": "88px",
        "color": "#000"
    },
    "children": [ // style tree 子節點
        {
            "node": {
                "nodeValue": "test",
                "nodeType": 3
            },
            "values": { // text 節點繼承了父節點樣式
                "font-size": "88px",
                "color": "#000"
            },
            "children": []
        }
    ]
}

遍歷 DOM 樹

現在我們需要遍歷 DOM 樹。對於 DOM 樹中的每個節點,我們都要在樣式樹中查找是否有匹配的 CSS 規則。

export function getStyleTree(eles: Node | Node[], cssRules: Rule[], parent?: StyleNode) {
    if (Array.isArray(eles)) {
        return eles.map((ele) => getStyleNode(ele, cssRules, parent))
    }

    return getStyleNode(eles, cssRules, parent)
}

匹配選擇器

匹配選擇器實現起來非常容易,因為我們的CSS 解析器僅支持簡單的選擇器。 只需要查看元素本身即可判斷選擇器是否與元素匹配。

/**
 * css 選擇器是否匹配元素
 */
function isMatch(ele: Element, selectors: Selector[]) {
    return selectors.some((selector) => {
        // 通配符
        if (selector.tagName === '*') return true
        if (selector.tagName === ele.tagName) return true
        if (ele.attributes.id === selector.id) return true

        if (ele.attributes.class) {
            const classes = ele.attributes.class.split(' ').filter(Boolean)
            const classes2 = selector.class.split(' ').filter(Boolean)
            for (const name of classes) {
                if (classes2.includes(name)) return true
            }
        }

        return false
    })
}

當查找到匹配的 DOM 節點後,再將 DOM 節點和它匹配的 CSS 屬性組合在一起,生成樣式樹節點 styleNode:

function getStyleNode(ele: Node, cssRules: Rule[], parent?: StyleNode) {
    const styleNode: StyleNode = {
        node: ele,
        values: getStyleValues(ele, cssRules, parent),
        children: [],
    }

    if (ele.nodeType === NodeType.Element) {
        // 合併內聯樣式
        if (ele.attributes.style) {
            styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) }
        }

        styleNode.children = ele.children.map((e) => getStyleNode(e, cssRules, styleNode)) as unknown as StyleNode[]
    }

    return styleNode
}

function getStyleValues(ele: Node, cssRules: Rule[], parent?: StyleNode) {
    const inheritableAttrValue = getInheritableAttrValues(parent)

    // 文本節點繼承父元素的可繼承屬性
    if (ele.nodeType === NodeType.Text) return inheritableAttrValue

    return cssRules.reduce((result: AnyObject, rule) => {
        if (isMatch(ele as Element, rule.selectors)) {
            result = { ...result, ...cssValueArrToObject(rule.declarations) }
        }

        return result
    }, inheritableAttrValue)
}

在 CSS 選擇器中,不同的選擇器優先級是不同的,比如 id 選擇器就比類選擇器的優先級要高。但是我們這裏沒有實現選擇器優先級,為了實現簡單,所有的選擇器優先級是一樣的。

繼承屬性

文本節點無法匹配選擇器,那它的樣式從哪來?答案就是繼承,它可以繼承父節點的樣式。

在 CSS 中存在很多繼承屬性,即使子元素沒有聲明這些屬性,也可以從父節點裏繼承。比如字體顏色、字體家族等屬性,都是可以被繼承的。為了實現簡單,這裏只支持繼承父節點的 colorfont-size 屬性。

// 子元素可繼承的屬性,這裏只寫了兩個,實際上還有很多
const inheritableAttrs = ['color', 'font-size']

/**
 * 獲取父元素可繼承的屬性值
 */
function getInheritableAttrValues(parent?: StyleNode) {
    if (!parent) return {}
    const keys = Object.keys(parent.values)
    return keys.reduce((result: AnyObject, key) => {
        if (inheritableAttrs.includes(key)) {
            result[key] = parent.values[key]
        }

        return result
    }, {})
}

內聯樣式

在 CSS 中,內聯樣式的優先級是除了 !important 之外最高的。

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

我們可以在調用 getStyleValues() 函數獲得當前 DOM 節點的 CSS 屬性值後,再去取當前節點的內聯樣式值。並對當前 DOM 節點的 CSS 樣式值進行覆蓋。

styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) }

function getInlineStyle(str: string) {
    str = str.trim()
    if (!str) return {}
    const arr = str.split(';')
    if (!arr.length) return {}

    return arr.reduce((result: AnyObject, item: string) => {
        const data = item.split(':')
        if (data.length === 2) {
            result[data[0].trim()] = data[1].trim()
        }

        return result
    }, {})
}

佈局樹

第四階段講的是如何將樣式樹轉化為佈局樹,也是整個渲染引擎相對比較複雜的部分。

在這裏插入圖片描述

CSS 盒子模型

在 CSS 中,所有的 DOM 節點都可以當作一個盒子。這個盒子模型包含了內容、內邊距、邊框、外邊距以及在頁面中的位置信息。

在這裏插入圖片描述

我們可以用以下的數據結構來表示盒子模型:

export default class Dimensions {
    content: Rect
    padding: EdgeSizes
    border: EdgeSizes
    margin: EdgeSizes
}

export default class Rect {
    x: number
    y: number
    width: number
    height: number
}

export interface EdgeSizes {
    top: number
    right: number
    bottom: number
    left: number
}

塊佈局和內聯佈局

CSS 的 display 屬性決定了盒子在頁面中的佈局方式。display 的類型有很多種,例如 blockinlineflex 等等,但這裏只支持 blockinline 兩種佈局方式,並且所有盒子的默認佈局方式為 display: inline

我會用偽 HTML 代碼來描述它們之間的區別:

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

塊佈局會將盒子從上至下的垂直排列。

在這裏插入圖片描述

內聯佈局則會將盒子從左至右的水平排列。

在這裏插入圖片描述

如果容器內同時存在塊佈局和內聯佈局,則會用一個匿名佈局將內聯佈局包裹起來。

在這裏插入圖片描述

這樣就能將內聯佈局的盒子和其他塊佈局的盒子區別開來。

通常情況下內容是垂直增長的。也就是説,在容器中添加子節點通常會使容器更高,而不是更寬。另一種説法是,默認情況下,子節點的寬度取決於其容器的寬度,而容器的高度取決於其子節點的高度。

佈局樹

佈局樹是所有盒子節點的集合。

export default class LayoutBox {
    dimensions: Dimensions
    boxType: BoxType
    children: LayoutBox[]
    styleNode: StyleNode
}

盒子節點的類型可以是 blockinilneanonymous

export enum BoxType {
    BlockNode = 'BlockNode',
    InlineNode = 'InlineNode',
    AnonymousBlock = 'AnonymousBlock',
}

我們構建樣式樹時,需要根據每一個 DOM 節點的 display 屬性來生成對應的盒子節點。

export function getDisplayValue(styleNode: StyleNode) {
    return styleNode.values?.display ?? Display.Inline
}

如果 DOM 節點 display 屬性的值為 none,則在構建佈局樹的過程中,無需將這個 DOM 節點添加到佈局樹上,直接忽略它就可以了。

如果一個塊節點包含一個內聯子節點,則需要創建一個匿名塊(實際上就是塊節點)來包含它。如果一行中有多個子節點,則將它們全部放在同一個匿名容器中。

function buildLayoutTree(styleNode: StyleNode) {
    if (getDisplayValue(styleNode) === Display.None) {
        throw new Error('Root node has display: none.')
    }

    const layoutBox = new LayoutBox(styleNode)

    let anonymousBlock: LayoutBox | undefined
    for (const child of styleNode.children) {
        const childDisplay = getDisplayValue(child)
        // 如果 DOM 節點 display 屬性值為 none,直接跳過
        if (childDisplay === Display.None) continue

        if (childDisplay === Display.Block) {
            anonymousBlock = undefined
            layoutBox.children.push(buildLayoutTree(child))
        } else {
            // 創建一個匿名容器,用於容納內聯節點
            if (!anonymousBlock) {
                anonymousBlock = new LayoutBox()
                layoutBox.children.push(anonymousBlock)
            }

            anonymousBlock.children.push(buildLayoutTree(child))
        }
    }

    return layoutBox
}

遍歷佈局樹

現在開始構建佈局樹,入口函數是 getLayoutTree()

export function getLayoutTree(styleNode: StyleNode, parentBlock: Dimensions) {
    parentBlock.content.height = 0
    const root = buildLayoutTree(styleNode)
    root.layout(parentBlock)
    return root
}

它將遍歷樣式樹,利用樣式樹節點提供的相關信息,生成一個 LayoutBox 對象,然後調用 layout() 方法。計算每個盒子節點的位置、尺寸信息。

在本節內容的開頭有提到過,盒子的寬度取決於其父節點,而高度取決於子節點。這意味着,我們的代碼在計算寬度時需要自上而下遍歷樹,這樣它就可以在知道父節點的寬度後設置子節點的寬度。然後自下而上遍歷以計算高度,這樣父節點的高度就可以在計算子節點的相關信息後進行計算。

layout(parentBlock: Dimensions) {
    // 子節點的寬度依賴於父節點的寬度,所以要先計算當前節點的寬度,再遍歷子節點
    this.calculateBlockWidth(parentBlock)
    // 計算盒子節點的位置
    this.calculateBlockPosition(parentBlock)
    // 遍歷子節點並計算對位置、尺寸信息
    this.layoutBlockChildren()
    // 父節點的高度依賴於其子節點的高度,所以計算子節點的高度後,再計算自己的高度
    this.calculateBlockHeight()
}

這個方法執行佈局樹的單次遍歷,向下執行寬度計算,向上執行高度計算。一個真正的佈局引擎可能會執行幾次樹遍歷,有些是自上而下的,有些是自下而上的。

計算寬度

現在,我們先來計算盒子節點的寬度,這部分比較複雜,需要詳細的講解。

首先,我們要拿到當前節點的 width padding border margin 等信息:

calculateBlockWidth(parentBlock: Dimensions) {
    // 初始值
    const styleValues = this.styleNode?.values || {}
    
    // 初始值為 auto
    let width = styleValues.width ?? 'auto'
    let marginLeft = styleValues['margin-left'] || styleValues.margin || 0
    let marginRight = styleValues['margin-right'] || styleValues.margin || 0
    
    let borderLeft = styleValues['border-left'] || styleValues.border || 0
    let borderRight = styleValues['border-right'] || styleValues.border || 0
    
    let paddingLeft = styleValues['padding-left'] || styleValues.padding || 0
    let paddingRight = styleValues['padding-right'] || styleValues.padding || 0
    
    // 拿到父節點的寬度,如果某個屬性為 'auto',則將它設為 0
    let totalWidth = sum(width, marginLeft, marginRight, borderLeft, borderRight, paddingLeft, paddingRight)
    // ...

如果這些屬性沒有設置,就使用 0 作為默認值。拿到當前節點的總寬度後,還需要和父節點對比一下是否相等。如果寬度或邊距設置為 auto,則可以對這兩個屬性進行適當展開或收縮以適應可用空間。所以現在需要對當前節點的寬度進行檢查。

const isWidthAuto = width === 'auto'
const isMarginLeftAuto = marginLeft === 'auto'
const isMarginRightAuto = marginRight === 'auto'

// 當前塊的寬度如果超過了父元素寬度,則將它的可擴展外邊距設為 0
if (!isWidthAuto && totalWidth > parentWidth) {
    if (isMarginLeftAuto) {
        marginLeft = 0
    }

    if (isMarginRightAuto) {
        marginRight = 0
    }
}

// 根據父子元素寬度的差值,去調整當前元素的寬度
const underflow = parentWidth - totalWidth

// 如果三者都有值,則將差值填充到 marginRight
if (!isWidthAuto && !isMarginLeftAuto && !isMarginRightAuto) {
    marginRight += underflow
} else if (!isWidthAuto && !isMarginLeftAuto && isMarginRightAuto) {
    // 如果右邊距是 auto,則將 marginRight 設為差值
    marginRight = underflow
} else if (!isWidthAuto && isMarginLeftAuto && !isMarginRightAuto) {
    // 如果左邊距是 auto,則將 marginLeft 設為差值
    marginLeft = underflow
} else if (isWidthAuto) {
    // 如果只有 width 是 auto,則將另外兩個值設為 0
    if (isMarginLeftAuto) {
        marginLeft = 0
    }

    if (isMarginRightAuto) {
        marginRight = 0
    }

    if (underflow >= 0) {
        // 展開寬度,填充剩餘空間,原來的寬度是 auto,作為 0 來計算的
        width = underflow
    } else {
        // 寬度不能為負數,所以需要調整 marginRight 來代替
        width = 0
        // underflow 為負數,相加實際上就是縮小當前節點的寬度
        marginRight += underflow
    }
} else if (!isWidthAuto && isMarginLeftAuto && isMarginRightAuto) {
    // 如果只有 marginLeft 和 marginRight 是 auto,則將兩者設為 underflow 的一半
    marginLeft = underflow / 2
    marginRight = underflow / 2
}

詳細的計算過程請看上述代碼,重要的地方都已經標上註釋了。

通過對比當前節點和父節點的寬度,我們可以拿到一個差值:

// 根據父子元素寬度的差值,去調整當前元素的寬度
const underflow = parentWidth - totalWidth

如果這個差值為正數,説明子節點寬度小於父節點;如果差值為負數,説明子節點大於父節。上面這段代碼邏輯其實就是根據 underflow width padding margin 等值對子節點的寬度、邊距進行調整,以適應父節點的寬度。

定位

計算當前節點的位置相對來説簡單一點。這個方法會根據當前節點的 margin border padding 樣式以及父節點的位置信息對當前節點進行定位:

calculateBlockPosition(parentBlock: Dimensions) {
    const styleValues = this.styleNode?.values || {}
    const { x, y, height } = parentBlock.content
    const dimensions = this.dimensions
    
    dimensions.margin.top = transformValueSafe(styleValues['margin-top'] || styleValues.margin || 0)
    dimensions.margin.bottom = transformValueSafe(styleValues['margin-bottom'] || styleValues.margin || 0)
    
    dimensions.border.top = transformValueSafe(styleValues['border-top'] || styleValues.border || 0)
    dimensions.border.bottom = transformValueSafe(styleValues['border-bottom'] || styleValues.border || 0)
    
    dimensions.padding.top = transformValueSafe(styleValues['padding-top'] || styleValues.padding || 0)
    dimensions.padding.bottom = transformValueSafe(styleValues['padding-bottom'] || styleValues.padding || 0)
    
    dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left
    dimensions.content.y = y + height + dimensions.margin.top + dimensions.border.top + dimensions.padding.top
}
    
function transformValueSafe(val: number | string) {
    if (val === 'auto') return 0
    return parseInt(String(val))
}

比如獲取當前節點內容區域的 x 座標,計算方式如下:

dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left

在這裏插入圖片描述

遍歷子節點

在計算高度之前,需要先遍歷子節點,因為父節點的高度需要根據它下面子節點的高度進行適配。

layoutBlockChildren() {
    const { dimensions } = this
    for (const child of this.children) {
        child.layout(dimensions)
        // 遍歷子節點後,再計算父節點的高度
        dimensions.content.height += child.dimensions.marginBox().height
    }
}

每個節點的高度就是它上下兩個外邊距之間的差值,所以可以通過 marginBox() 獲得高度:

export default class Dimensions {
    content: Rect
    padding: EdgeSizes
    border: EdgeSizes
    margin: EdgeSizes

    constructor() {
        const initValue = {
            top: 0,
            right: 0,
            bottom: 0,
            left: 0,
        }

        this.content = new Rect()

        this.padding = { ...initValue }
        this.border = { ...initValue }
        this.margin = { ...initValue }
    }

    paddingBox() {
        return this.content.expandedBy(this.padding)
    }

    borderBox() {
        return this.paddingBox().expandedBy(this.border)
    }

    marginBox() {
        return this.borderBox().expandedBy(this.margin)
    }
}
export default class Rect {
    x: number
    y: number
    width: number
    height: number

    constructor() {
        this.x = 0
        this.y = 0
        this.width = 0
        this.height = 0
    }

    expandedBy(edge: EdgeSizes) {
        const rect = new Rect()
        rect.x = this.x - edge.left
        rect.y = this.y - edge.top
        rect.width = this.width + edge.left + edge.right
        rect.height = this.height + edge.top + edge.bottom

        return rect
    }
}

遍歷子節點並執行完相關計算方法後,再將各個子節點的高度進行相加,得到父節點的高度。

height 屬性

默認情況下,節點的高度等於其內容的高度。但如果手動設置了 height 屬性,則需要將節點的高度設為指定的高度:

calculateBlockHeight() {
    // 如果元素設置了 height,則使用 height,否則使用 layoutBlockChildren() 計算出來的高度
    const height = this.styleNode?.values.height
    if (height) {
        this.dimensions.content.height = parseInt(height)
    }
}

為了簡單起見,我們不需要實現外邊距摺疊。

小結

佈局樹是渲染引擎最複雜的部分,這一階段結束後,我們就瞭解了佈局樹中每個盒子節點在頁面中的具體位置和尺寸信息。下一步,就是如何把佈局樹渲染到頁面上了。

繪製

繪製階段主要是根據佈局樹中各個節點的位置、尺寸信息將它們繪製到頁面。目前大多數計算機使用光柵(raster,也稱為位圖)顯示技術。將佈局樹各個節點繪製到頁面的這個過程也被稱為“光柵化”。

瀏覽器通常在圖形API和庫(如Skia、Cairo、Direct2D等)的幫助下實現光柵化。這些API提供繪製多邊形、直線、曲線、漸變和文本的功能。

實際上繪製才是最難的部分,但是這一步我們有現成的 canvas 庫可以用,不用自己實現一個光柵器,所以相對來説就變得簡單了。在真正開始繪製階段之前,我們先來學習一些關於計算機如何繪製圖像、文本的基礎知識,有助於我們理解光柵化的具體實現過程。

計算機如何繪製圖像、文本

在計算機底層進行像素繪製屬於硬件操作,它依賴於屏幕和顯卡接口的具體細節。為了簡單起點,我們可以用一段內存區域來表示屏幕,內存的一個 bit 就代表了屏幕中的一個像素。比如在屏幕中的 (x,y) 座標繪製一個像素,可以用 memory[x + y * rowSize] = 1 來表示。從屏幕左上角開始,列是從左至右開始計數,行是從上至下開始計數。因此屏幕最左上角的座標是 (0,0)

為了簡單起見,我們用 1 bit 來表示屏幕的一個像素,0 代表白色,1 代表黑色。屏幕每一行的長度用變量 rowSzie 表示,每一列的高度用 colSize 表示。

在這裏插入圖片描述

繪製線條

如果我們要在計算機上繪製一條直線,那麼只要知道計算機的起點座標 (x1,y1) 和終點座標 (x2,y2) 就可以了。
在這裏插入圖片描述
然後根據 memory[x + y * rowSize] = 1 公式,將 (x1,y1)(x2,y2) 之間對應的內存區域置為 1,這樣就畫出來了一條直線。

繪製文本

為了在屏幕上顯示文本,首先必須將物理上基於像素點的屏幕,在邏輯上以字符為單位劃分成若干區域,每個區域能輸出單個完整的字符。假設有一個 256 行 512 列的屏幕,如果為每個字符分配一個 11*8 像素的網格,那麼屏幕上總共能顯示 23 行,每行 64 個字符(還有 3 行像素沒使用)。

有了這些前提條件後,我們現在打算在屏幕上畫一個 A

在這裏插入圖片描述
上圖的 A 在內存區域中用 11*8 像素的網格表示。為了在內存區域中繪製它,我們可以用一個二維數組來表示它:

const charA = [
    [0, 0, 1, 1, 0, 0, 0, 0], // 按從左至右的順序來讀取 bit,轉換成十進制數字就是 12
    [0, 1, 1, 1, 1, 0, 0, 0], // 30
    [1, 1, 0, 0, 1, 1, 0, 0], // 51
    [1, 1, 0, 0, 1, 1, 0, 0], // 51
    [1, 1, 1, 1, 1, 1, 0, 0], // 63
    [1, 1, 0, 0, 1, 1, 0, 0], // 51
    [1, 1, 0, 0, 1, 1, 0, 0], // 51
    [1, 1, 0, 0, 1, 1, 0, 0], // 51
    [1, 1, 0, 0, 1, 1, 0, 0], // 51
    [0, 0, 0, 0, 0, 0, 0, 0], // 0
    [0, 0, 0, 0, 0, 0, 0, 0], // 0
]

上面二維數組的第一項,代表了第一行內存區域每個 bit 的取值。一共 11 行,畫出了一個字母 A

如果我們為 26 個字母都建一個映射表,按 ascii 的編碼來排序,那麼 charsMap[65] 就代表字符 A,當用户在鍵盤上按下 A 鍵時,就把 charsMap[65] 對應的數據輸出到內存區域上,這樣屏幕上就顯示了一個字符 A

繪製佈局樹

科普完關於繪製屏幕的基礎知識後,我們現在正式開始繪製佈局樹(為了方便,我們使用 node-canvas 庫)。

首先要遍歷整個佈局樹,然後逐個節點進行繪製:

function renderLayoutBox(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox) {
    renderBackground(layoutBox, ctx)
    renderBorder(layoutBox, ctx)
    renderText(layoutBox, ctx, parent)
    for (const child of layoutBox.children) {
        renderLayoutBox(child, ctx, layoutBox)
    }
}

這個函數對每個節點依次繪製背景色、邊框、文本,然後再遞歸繪製所有子節點。

默認情況下,HTML 元素按照它們出現的順序進行繪製。如果兩個元素重疊,則後一個元素將繪製在前一個元素之上。這種排序反映在我們的佈局樹中,它將按照元素在 DOM 樹中出現的順序繪製元素。

繪製背景色

function renderBackground(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D) {
    const { width, height, x, y } = layoutBox.dimensions.borderBox()
    ctx.fillStyle = getStyleValue(layoutBox, 'background')
    ctx.fillRect(x, y, width, height)
}

首先拿到佈局節點的位置、尺寸信息,以 x,y 作為起點,繪製矩形區域。並且以 CSS 屬性 background 的值作為背景色進行填充。

繪製邊框

function renderBorder(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D) {
    const { width, height, x, y } = layoutBox.dimensions.borderBox()
    const { left, top, right, bottom } = layoutBox.dimensions.border
    const borderColor = getStyleValue(layoutBox, 'border-color')
    if (!borderColor) return

    ctx.fillStyle = borderColor

    // left
    ctx.fillRect(x, y, left, height)
    // top
    ctx.fillRect(x, y, width, top)
    // right
    ctx.fillRect(x + width - right, y, right, height)
    // bottom
    ctx.fillRect(x, y + height - bottom, width, bottom)
}

繪製邊框,其實我們繪製的是四個矩形,每一個矩形就是一條邊框。

繪製文本

function renderText(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox) {
    if (layoutBox.styleNode?.node.nodeType === NodeType.Text) {
        // get AnonymousBlock x y
        const { x = 0, y = 0, width } = parent?.dimensions.content || {}
        const styles = layoutBox.styleNode?.values || {}
        const fontSize = styles['font-size'] || '14px'
        const fontFamily = styles['font-family'] || 'serif'
        const fontWeight = styles['font-weight'] || 'normal'
        const fontStyle = styles['font-style'] || 'normal'

        ctx.fillStyle = styles.color
        ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`
        ctx.fillText(layoutBox.styleNode?.node.nodeValue, x, y + parseInt(fontSize), width)
    }
}

通過 canvas 的 fillText() 方法,我們可以很方便的繪製帶有字體風格、大小、顏色的文本。

輸出圖片

繪製完成後,我們可以藉助 canvas 的 API 輸出圖片。下面用一個簡單的示例來演示一下:

<html>
    <body id=" body " data-index="1" style="color: red; background: yellow;">
        <div>
            <div class="lightblue test">test1!</div>
            <div class="lightblue test">
                <div class="foo">foo</div>
            </div>
        </div>
    </body>
</html>
* {
    display: block;
}

div {
    font-size: 14px;
    width: 400px;
    background: #fff;
    margin-bottom: 20px;
    display: block;
    background: lightblue;
}

.lightblue {
    font-size: 16px;
    display: block;
    width: 200px;
    height: 200px;
    background: blue;
    border-color: green;
    border: 10px;
}

.foo {
    width: 100px;
    height: 100px;
    background: red;
    color: yellow;
    margin-left: 50px;
}

body {
    display: block;
    font-size: 88px;
    color: #000;
}

上面這段 HTML、CSS 代碼經過渲染引擎程序解析後生成的圖片如下:

在這裏插入圖片描述

總結

至此,這個玩具版的渲染引擎就完成了。雖然這個玩具並沒有什麼用,但如果能通過實現它來了解真實的渲染引擎是如何運作的,從這個角度來看,它還是“有用”的。

參考資料

  • Let's build a browser engine!
  • robinson
  • 渲染頁面:瀏覽器的工作原理
  • 關鍵渲染路徑
  • 計算機系統要素
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.