用原生 JS 實現(xiàn) innerHTML 功能實例詳解
都知道瀏覽器和服務(wù)端是通過 HTTP 協(xié)議進(jìn)行數(shù)據(jù)傳輸?shù)模?HTTP 協(xié)議又是純文本協(xié)議,那么瀏覽器在得到服務(wù)端傳輸過來的 HTML 字符串,是如何解析成真實的 DOM 元素的呢,也就是我們常說的生成 DOM Tree,最近了解到狀態(tài)機(jī)這樣一個概念,于是就萌生一個想法,實現(xiàn)一個 innerHTML 功能的函數(shù),也算是小小的實踐一下。
函數(shù)原型
我們實現(xiàn)一個如下的函數(shù),參數(shù)是 DOM 元素和 HTML 字符串,將 HTML 字符串轉(zhuǎn)換成真實的 DOM 元素且 append 在參數(shù)一傳入的 DOM 元素中。
function html(element, htmlString) { // 1. 詞法分析 // 2. 語法分析 // 3. 解釋執(zhí)行 }
在上面的注釋我已經(jīng)注明,這個步驟我們分成三個部分,分別是詞法分析、語法分析和解釋執(zhí)行。
詞法分析
詞法分析是特別重要且核心的一部分,具體任務(wù)就是:把字符流變成 token 流。
詞法分析通常有兩種方案,一種是狀態(tài)機(jī),一種是正則表達(dá)式,它們是等效的,選擇你喜歡的就好。我們這里選擇狀態(tài)機(jī)。
首先我們需要確定 token 種類,我們這里不考慮太復(fù)雜的情況,因為我們只對原理進(jìn)行學(xué)習(xí),不可能像瀏覽器那樣有強大的容錯能力。除了不考慮容錯之外,對于自閉合節(jié)點、注釋、CDATA 節(jié)點暫時均不做考慮。
接下來步入主題,假設(shè)我們有如下節(jié)點信息,我們會分出哪些 token 來呢。
<p class="a" data="js">測試元素</p>
對于上述節(jié)點信息,我們可以拆分出如下 token
- 開始標(biāo)簽:<p
- 屬性標(biāo)簽:class="a"
- 文本節(jié)點:測試元素
- 結(jié)束標(biāo)簽:</p>
狀態(tài)機(jī)的原理,將整個 HTML 字符串進(jìn)行遍歷,每次讀取一個字符,都要進(jìn)行一次決策(下一個字符處于哪個狀態(tài)),而且這個決策是和當(dāng)前狀態(tài)有關(guān)的,這樣一來,讀取的過程就會得到一個又一個完整的 token,記錄到我們最終需要的 tokens 中。
萬事開頭難,我們首先要確定起初可能處于哪種狀態(tài),也就是確定一個 start 函數(shù),在這之前,對詞法分析類進(jìn)行簡單的封裝,具體如下
function HTMLLexicalParser(htmlString, tokenHandler) { this.token = []; this.tokens = []; this.htmlString = htmlString this.tokenHandler = tokenHandler }
簡單解釋下上面的每個屬性
- token:token 的每個字符
- tokens:存儲一個個已經(jīng)得到的 token
- htmlString:待處理字符串
- tokenHandler:token 處理函數(shù),我們每得到一個 token 時,就已經(jīng)可以進(jìn)行流式解析
我們可以很容易的知道,字符串要么以普通文本開頭,要么以 < 開頭,因此 start 代碼如下
HTMLLexicalParser.prototype.start = function(c) { if(c === '<') { this.token.push(c) return this.tagState } else { return this.textState(c) } }
start 處理的比較簡單,如果是 < 字符,表示開始標(biāo)簽或結(jié)束標(biāo)簽,因此我們需要下一個字符信息才能確定到底是哪一類 token,所以返回 tagState 函數(shù)去進(jìn)行再判斷,否則我們就認(rèn)為是文本節(jié)點,返回文本狀態(tài)函數(shù)。
接下來分別展開 tagState 和 textState 函數(shù)。 tagState 根據(jù)下一個字符,判斷進(jìn)入開始標(biāo)簽狀態(tài)還是結(jié)束標(biāo)簽狀態(tài),如果是 / 表示是結(jié)束標(biāo)簽,否則是開始標(biāo)簽, textState 用來處理每一個文本節(jié)點字符,遇到 < 表示得到一個完整的文本節(jié)點 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 } }
這里初次見面的函數(shù)是 emitToken 、 startTagState 和 endTagState 。
emitToken 用來將產(chǎn)生的完整 token 存儲在 tokens 中,參數(shù)是 token 類型和值。
startTagState 用來處理開始標(biāo)簽,這里有三種情形
- 如果接下來的字符是字母,則認(rèn)定依舊處于開始標(biāo)簽態(tài)
- 遇到空格,則認(rèn)定開始標(biāo)簽態(tài)結(jié)束,接下來是處理屬性了
- 遇到>,同樣認(rèn)定為開始標(biāo)簽態(tài)結(jié)束,但接下來是處理新的節(jié)點信息
- endTagState用來處理結(jié)束標(biāo)簽,結(jié)束標(biāo)簽不存在屬性,因此只有兩種情形
- 如果接下來的字符是字母,則認(rèn)定依舊處于結(jié)束標(biāo)簽態(tài)
- 遇到>,同樣認(rèn)定為結(jié)束標(biāo)簽態(tài)結(jié)束,但接下來是處理新的節(jié)點信息
邏輯上面說的比較清楚了,代碼也比較簡單,看看就好啦
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 } }
最后只有屬性標(biāo)簽需要處理了,也就是上面看到的 attrState 函數(shù),也處理三種情形
- 如果是字母、單引號、雙引號、等號,則認(rèn)定為依舊處于屬性標(biāo)簽態(tài)
- 如果遇到空格,則表示屬性標(biāo)簽態(tài)結(jié)束,接下來進(jìn)入新的屬性標(biāo)簽態(tài)
- 如果遇到>,則認(rèn)定為屬性標(biāo)簽態(tài)結(jié)束,接下來開始新的節(jié)點信息
代碼如下
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 解析函數(shù),和可能用到的 getOutPut 函數(shù)來獲取結(jié)果即可,就不啰嗦了,上代碼
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 字符串,輸出結(jié)果為
看上去結(jié)果很 nice,接下來進(jìn)入語法分析步驟
語法分析
首先們需要考慮到的情況有兩種,一種是有多個根元素的,一種是只有一個根元素的。
我們的節(jié)點有兩種類型,文本節(jié)點和正常節(jié)點,因此聲明兩個數(shù)據(jù)結(jié)構(gòu)。
function Element(tagName) { this.tagName = tagName this.attr = {} this.childNodes = [] } function Text(value) { this.value = value || '' }
目標(biāo):將元素建立起父子關(guān)系,因為真實的 DOM 結(jié)構(gòu)就是父子關(guān)系,這里我一開始實踐的時候,將 childNodes 屬性的處理放在了 startTag token 中,還給 Element 增加了 isEnd 屬性,實屬愚蠢,不但復(fù)雜化了,而且還很難實現(xiàn)。
仔細(xì)思考 DOM 結(jié)構(gòu),token 也是有順序的,合理利用棧數(shù)據(jù)結(jié)構(gòu),這個問題就變的簡單了,將 childNodes 處理放在 endTag 中處理。具體邏輯如下
- 如果是 startTag token,直接 push 一個新 element
- 如果是 endTag token,則表示當(dāng)前節(jié)點處理完成,此時出棧一個節(jié)點,同時將該節(jié)點歸入棧頂元素節(jié)點的 childNodes 屬性,這里需要做個判斷,如果出棧之后??樟?,表示整個節(jié)點處理完成,考慮到可能有平行元素,將元素 push 到 stacks。
- 如果是 attr token,直接寫入棧頂元素的 attr 屬性
- 如果是 text token,由于文本節(jié)點的特殊性,不存在有子節(jié)點、屬性等,就認(rèn)定為處理完成。這里需要做個判斷,因為文本節(jié)點可能是根級別的,判斷是否存在棧頂元素,如果存在直接壓入棧頂元素的 childNodes 屬性,不存在 push 到 stacks。
代碼如下
function HTMLSyntacticalParser() { this.stack = [] this.stacks = [] } HTMLSyntacticalParser.prototype.getOutPut = function() { return this.stacks } // 一開始搞復(fù)雜了,合理利用基本數(shù)據(jù)結(jié)構(gòu)真是一件很酷炫的事 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) } } }
簡單測試如下:
沒啥大問題哈
解釋執(zhí)行
對于上述語法分析的結(jié)果,可以理解成 vdom 結(jié)構(gòu)了,接下來就是映射成真實的 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) } }
實現(xiàn)函數(shù)
上面三步驟完成后,來到了最后一步,實現(xiàn)最開始提出的函數(shù)
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 有一個基本的認(rèn)識,所以細(xì)節(jié)問題肯定還是存在很多的。
總結(jié)
其實在了解了原理之后,這一塊代碼寫下來,并沒有太大的難度,但卻讓我很興奮,有兩個成果吧
- 了解并初步實踐了一下狀態(tài)機(jī)
- 數(shù)據(jù)結(jié)構(gòu)的魅力
代碼已經(jīng)基本都列出來了,想跑一下的童鞋也可以 clone 這個 repo: domtree
總結(jié)
以上所述是小編給大家介紹的用原生 JS 實現(xiàn) innerHTML 功能實例詳解,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復(fù)大家的。在此也非常感謝大家對腳本之家網(wǎng)站的支持!
- JavaScript 輸出顯示內(nèi)容(document.write、alert、innerHTML、console.log)
- JavaScript中innerHTML,innerText,outerHTML的用法及區(qū)別
- Javascript中innerHTML用法實例分析
- js的.innerHTML = ""IE9下顯示有錯誤的解決方法
- js innerHTML 改變div內(nèi)容的方法
- Javascript在IE下設(shè)置innerHTML時出現(xiàn)未知的運行時錯誤的解決方法
- javascript innerText和innerHtml應(yīng)用
- javascript 異步的innerHTML使用分析
- JS 動態(tài)獲取節(jié)點代碼innerHTML分析 [IE,FF]
相關(guān)文章
在JavaScript中構(gòu)建ArrayList示例代碼
這篇文章主要介紹了在JavaScript中構(gòu)建ArrayList,很實用,需要的朋友可以參考下2014-09-09深入理解JavaScript創(chuàng)建對象的多種方式以及優(yōu)缺點
本篇文章主要介紹了JavaScript創(chuàng)建對象的多種方式以及優(yōu)缺點,主要介紹了5種方式,有興趣的可以了解下2017-06-06ES6 Generator函數(shù)的應(yīng)用實例分析
這篇文章主要介紹了ES6 Generator函數(shù)的應(yīng)用,結(jié)合實例形式分析了ES6 Generator函數(shù)異步操作與異常捕獲相關(guān)使用技巧,需要的朋友可以參考下2019-06-06js中find、findIndex、indexOf的用法和區(qū)別
本文主要介紹了js中find、findIndex、indexOf的用法和區(qū)別,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-07-07