用原生 JS 實現 innerHTML 功能

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

函數原型

咱們實現一個以下的函數,參數是 DOM 元素和 HTML 字符串,將 HTML 字符串轉換成真實的 DOM 元素且 append 在參數一傳入的 DOM 元素中。git

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

    // 2. 語法分析

    // 3. 解釋執行
}
複製代碼

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

詞法分析

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

詞法分析一般有兩種方案,一種是狀態機,一種是正則表達式,它們是等效的,選擇你喜歡的就好。咱們這裏選擇狀態機。瀏覽器

首先咱們須要肯定 token 種類,咱們這裏不考慮太複雜的狀況,由於咱們只對原理進行學習,不可能像瀏覽器那樣有強大的容錯能力。除了不考慮容錯以外,對於自閉合節點、註釋、CDATA 節點暫時均不作考慮。數據結構

接下來步入主題,假設咱們有以下節點信息,咱們會分出哪些 token 來呢。app

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

對於上述節點信息,咱們能夠拆分出以下 tokendom

  • 開始標籤:<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

相關文章
相關標籤/搜索