用原生 JS 實現 innerHTML 功能

劉新瓊發表於2019-04-02

都知道瀏覽器和服務端是通過 HTTP 協議進行資料傳輸的,而 HTTP 協議又是純文字協議,那麼瀏覽器在得到服務端傳輸過來的 HTML 字串,是如何解析成真實的 DOM 元素的呢,也就是我們常說的生成 DOM Tree,最近了解到狀態機這樣一個概念,於是就萌生一個想法,實現一個 innerHTML 功能的函式,也算是小小的實踐一下。

函式原型

我們實現一個如下的函式,引數是 DOM 元素和 HTML 字串,將 HTML 字串轉換成真實的 DOM 元素且 append 在引數一傳入的 DOM 元素中。

function html(element, htmlString) {
    // 1. 詞法分析

    // 2. 語法分析

    // 3. 解釋執行
}
複製程式碼

在上面的註釋我已經註明,這個步驟我們分成三個部分,分別是詞法分析、語法分析和解釋執行。

詞法分析

詞法分析是特別重要且核心的一部分,具體任務就是:把字元流變成 token 流。

詞法分析通常有兩種方案,一種是狀態機,一種是正規表示式,它們是等效的,選擇你喜歡的就好。我們這裡選擇狀態機。

首先我們需要確定 token 種類,我們這裡不考慮太複雜的情況,因為我們只對原理進行學習,不可能像瀏覽器那樣有強大的容錯能力。除了不考慮容錯之外,對於自閉合節點、註釋、CDATA 節點暫時均不做考慮。

接下來步入主題,假設我們有如下節點資訊,我們會分出哪些 token 來呢。

<p class="a" data="js">測試元素</p>
複製程式碼

對於上述節點資訊,我們可以拆分出如下 token

  • 開始標籤:<p
  • 屬性標籤:class="a"
  • 文字節點:測試元素
  • 結束標籤:</p>

狀態機的原理,將整個 HTML 字串進行遍歷,每次讀取一個字元,都要進行一次決策(下一個字元處於哪個狀態),而且這個決策是和當前狀態有關的,這樣一來,讀取的過程就會得到一個又一個完整的 token,記錄到我們最終需要的 tokens 中。

萬事開頭難,我們首先要確定起初可能處於哪種狀態,也就是確定一個 start 函式,在這之前,對詞法分析類進行簡單的封裝,具體如下

function HTMLLexicalParser(htmlString, tokenHandler) {
    this.token = [];
    this.tokens = [];
    this.htmlString = htmlString
    this.tokenHandler = tokenHandler
}
複製程式碼

簡單解釋下上面的每個屬性

  • token:token 的每個字元
  • tokens:儲存一個個已經得到的 token
  • htmlString:待處理字串
  • tokenHandler:token 處理函式,我們每得到一個 token 時,就已經可以進行流式解析

我們可以很容易的知道,字串要麼以普通文字開頭,要麼以<開頭,因此 start 程式碼如下

HTMLLexicalParser.prototype.start = function(c) {
    if(c === '<') {
        this.token.push(c)
        return this.tagState
    } else {
        return this.textState(c)
    }
}
複製程式碼

start處理的比較簡單,如果是<字元,表示開始標籤或結束標籤,因此我們需要下一個字元資訊才能確定到底是哪一類 token,所以返回tagState函式去進行再判斷,否則我們就認為是文字節點,返回文字狀態函式。

接下來分別展開tagStatetextState函式。tagState根據下一個字元,判斷進入開始標籤狀態還是結束標籤狀態,如果是/表示是結束標籤,否則是開始標籤,textState用來處理每一個文字節點字元,遇到<表示得到一個完整的文字節點 token,程式碼如下

HTMLLexicalParser.prototype.tagState = function(c) {
    this.token.push(c)
    if(c === '/') {
        return this.endTagState
    } else {
        return this.startTagState
    }
}
HTMLLexicalParser.prototype.textState = function(c) {
    if(c === '<') {
        this.emitToken('text', this.token.join(''))
        this.token = []
        return this.start(c)
    } else {
        this.token.push(c)
        return this.textState
    }
}
複製程式碼

這裡初次見面的函式是emitTokenstartTagStateendTagState

emitToken用來將產生的完整 token 儲存在 tokens 中,引數是 token 型別和值。

startTagState用來處理開始標籤,這裡有三種情形

  • 如果接下來的字元是字母,則認定依舊處於開始標籤態
  • 遇到空格,則認定開始標籤態結束,接下來是處理屬性了
  • 遇到>,同樣認定為開始標籤態結束,但接下來是處理新的節點資訊

endTagState用來處理結束標籤,結束標籤不存在屬性,因此只有兩種情形

  • 如果接下來的字元是字母,則認定依舊處於結束標籤態
  • 遇到>,同樣認定為結束標籤態結束,但接下來是處理新的節點資訊

邏輯上面說的比較清楚了,程式碼也比較簡單,看看就好啦

HTMLLexicalParser.prototype.emitToken = function(type, value) {
    var res = {
        type,
        value
    }
    this.tokens.push(res)
    // 流式處理
    this.tokenHandler && this.tokenHandler(res)
}

HTMLLexicalParser.prototype.startTagState = function(c) {
    if(c.match(/[a-zA-Z]/)) {
        this.token.push(c.toLowerCase())
        return this.startTagState
    }
    if(c === ' ') {
        this.emitToken('startTag', this.token.join(''))
        this.token = []
        return this.attrState
    }
    if(c === '>') {
        this.emitToken('startTag', this.token.join(''))
        this.token = []
        return this.start
    }
}

HTMLLexicalParser.prototype.endTagState = function(c) {
    if(c.match(/[a-zA-Z]/)) {
        this.token.push(c.toLowerCase())
        return this.endTagState
    }
    if(c === '>') {
        this.token.push(c)
        this.emitToken('endTag', this.token.join(''))
        this.token = []
        return this.start
    }
}
複製程式碼

最後只有屬性標籤需要處理了,也就是上面看到的attrState函式,也處理三種情形

  • 如果是字母、單引號、雙引號、等號,則認定為依舊處於屬性標籤態
  • 如果遇到空格,則表示屬性標籤態結束,接下來進入新的屬性標籤態
  • 如果遇到>,則認定為屬性標籤態結束,接下來開始新的節點資訊

程式碼如下

HTMLLexicalParser.prototype.attrState = function(c) {
    if(c.match(/[a-zA-Z'"=]/)) {
        this.token.push(c)
        return this.attrState
    }
    if(c === ' ') {
        this.emitToken('attr', this.token.join(''))
        this.token = []
        return this.attrState
    }
    if(c === '>') {
        this.emitToken('attr', this.token.join(''))
        this.token = []
        return this.start
    }
}
複製程式碼

最後我們提供一個parse解析函式,和可能用到的getOutPut函式來獲取結果即可,就不囉嗦了,上程式碼

HTMLLexicalParser.prototype.parse = function() {
    var state = this.start;
    for(var c of this.htmlString.split('')) {
        state = state.bind(this)(c)
    }
}

HTMLLexicalParser.prototype.getOutPut = function() {
    return this.tokens
}
複製程式碼

接下來簡單測試一下,對於<p class="a" data="js">測試並列元素的</p><p class="a" data="js">測試並列元素的</p>HTML 字串,輸出結果為

1.png

看上去結果很 nice,接下來進入語法分析步驟

語法分析

首先們需要考慮到的情況有兩種,一種是有多個根元素的,一種是隻有一個根元素的。

我們的節點有兩種型別,文字節點和正常節點,因此宣告兩個資料結構。

function Element(tagName) {
    this.tagName = tagName
    this.attr = {}
    this.childNodes = []
}

function Text(value) {
    this.value = value || ''
}
複製程式碼

目標:將元素建立起父子關係,因為真實的 DOM 結構就是父子關係,這裡我一開始實踐的時候,將 childNodes 屬性的處理放在了 startTag token 中,還給 Element 增加了 isEnd 屬性,實屬愚蠢,不但複雜化了,而且還很難實現。仔細思考 DOM 結構,token 也是有順序的,合理利用棧資料結構,這個問題就變的簡單了,將 childNodes 處理放在 endTag 中處理。具體邏輯如下

  • 如果是 startTag token,直接 push 一個新 element
  • 如果是 endTag token,則表示當前節點處理完成,此時出棧一個節點,同時將該節點歸入棧頂元素節點的 childNodes 屬性,這裡需要做個判斷,如果出棧之後棧空了,表示整個節點處理完成,考慮到可能有平行元素,將元素 push 到 stacks。
  • 如果是 attr token,直接寫入棧頂元素的 attr 屬性
  • 如果是 text token,由於文字節點的特殊性,不存在有子節點、屬性等,就認定為處理完成。這裡需要做個判斷,因為文字節點可能是根級別的,判斷是否存在棧頂元素,如果存在直接壓入棧頂元素的 childNodes 屬性,不存在 push 到 stacks。

程式碼如下

function HTMLSyntacticalParser() {
    this.stack = []
    this.stacks = []
}
HTMLSyntacticalParser.prototype.getOutPut = function() {
    return this.stacks
}
// 一開始搞複雜了,合理利用基本資料結構真是一件很酷炫的事
HTMLSyntacticalParser.prototype.receiveInput = function(token) {
    var stack = this.stack
    if(token.type === 'startTag') {
        stack.push(new Element(token.value.substring(1)))
    } else if(token.type === 'attr') {
        var t = token.value.split('='), key = t[0], value  = t[1].replace(/'|"/g, '')
        stack[stack.length - 1].attr[key] = value
    } else if(token.type === 'text') {
        if(stack.length) {
            stack[stack.length - 1].childNodes.push(new Text(token.value))
        } else {
            this.stacks.push(new Text(token.value))
        }
    } else if(token.type === 'endTag') {
        var parsedTag = stack.pop()
        if(stack.length) {
            stack[stack.length - 1].childNodes.push(parsedTag)
        } else {
            this.stacks.push(parsedTag)
        }
    }
}
複製程式碼

簡單測試如下:

2.png

沒啥大問題哈

解釋執行

對於上述語法分析的結果,可以理解成 vdom 結構了,接下來就是對映成真實的 DOM,這裡其實比較簡單,用下遞迴即可,直接上程式碼吧

function vdomToDom(array) {
    var res = []
    for(let item of array) {
        res.push(handleDom(item))
    }
    return res
}

function handleDom(item) {
    if(item instanceof Element) {
        var element = document.createElement(item.tagName)
        for(let key in item.attr) {
            element.setAttribute(key, item.attr[key])
        }
        if(item.childNodes.length) {
            for(let i = 0; i < item.childNodes.length; i++) {
                element.appendChild(handleDom(item.childNodes[i]))
            }
        }
        return element
    } else if(item instanceof Text) {
        return document.createTextNode(item.value)
    }
}
複製程式碼

實現函式

上面三步驟完成後,來到了最後一步,實現最開始提出的函式

function html(element, htmlString) {
    // parseHTML
    var syntacticalParser = new HTMLSyntacticalParser()
    var lexicalParser = new HTMLLexicalParser(htmlString, syntacticalParser.receiveInput.bind(syntacticalParser))
    lexicalParser.parse()
    var dom = vdomToDom(syntacticalParser.getOutPut())
    var fragment = document.createDocumentFragment()
    dom.forEach(item => {
        fragment.appendChild(item)
    })
    element.appendChild(fragment)
}
複製程式碼

三個不同情況的測試用例簡單測試下

html(document.getElementById('app'), '<p class="a" data="js">測試並列元素的</p><p class="a" data="js">測試並列元素的</p>')
html(document.getElementById('app'), '測試<div>你好呀,我測試一下沒有深層元素的</div>')
html(document.getElementById('app'), '<div class="div"><p class="p">測試一下巢狀很深的<span class="span">p的子元素</span></p><span>p同級別</span></div>')
複製程式碼

宣告:簡單測試下都沒啥問題,本次實踐的目的是對 DOM 這一塊通過詞法分析和語法分析生成 DOM Tree 有一個基本的認識,所以細節問題肯定還是存在很多的。

總結

其實在瞭解了原理之後,這一塊程式碼寫下來,並沒有太大的難度,但卻讓我很興奮,有兩個成果吧

  • 瞭解並初步實踐了一下狀態機
  • 資料結構的魅力

程式碼已經基本都列出來了,想跑一下的童鞋也可以 clone 這個 repo:domtree

相關文章