用原生 JS 實(shí)現(xiàn) innerHTML 功能實(shí)例詳解
都知道瀏覽器和服務(wù)端是通過 HTTP 協(xié)議進(jìn)行數(shù)據(jù)傳輸?shù)?,?HTTP 協(xié)議又是純文本協(xié)議,那么瀏覽器在得到服務(wù)端傳輸過來的 HTML 字符串,是如何解析成真實(shí)的 DOM 元素的呢,也就是我們常說的生成 DOM Tree,最近了解到狀態(tài)機(jī)這樣一個(gè)概念,于是就萌生一個(gè)想法,實(shí)現(xiàn)一個(gè) innerHTML 功能的函數(shù),也算是小小的實(shí)踐一下。
函數(shù)原型
我們實(shí)現(xiàn)一個(gè)如下的函數(shù),參數(shù)是 DOM 元素和 HTML 字符串,將 HTML 字符串轉(zhuǎn)換成真實(shí)的 DOM 元素且 append 在參數(shù)一傳入的 DOM 元素中。
function html(element, htmlString) {
// 1. 詞法分析
// 2. 語法分析
// 3. 解釋執(zhí)行
}
在上面的注釋我已經(jīng)注明,這個(gè)步驟我們分成三個(gè)部分,分別是詞法分析、語法分析和解釋執(zhí)行。
詞法分析
詞法分析是特別重要且核心的一部分,具體任務(wù)就是:把字符流變成 token 流。
詞法分析通常有兩種方案,一種是狀態(tài)機(jī),一種是正則表達(dá)式,它們是等效的,選擇你喜歡的就好。我們這里選擇狀態(tài)機(jī)。
首先我們需要確定 token 種類,我們這里不考慮太復(fù)雜的情況,因?yàn)槲覀冎粚?duì)原理進(jìn)行學(xué)習(xí),不可能像瀏覽器那樣有強(qiáng)大的容錯(cuò)能力。除了不考慮容錯(cuò)之外,對(duì)于自閉合節(jié)點(diǎn)、注釋、CDATA 節(jié)點(diǎn)暫時(shí)均不做考慮。
接下來步入主題,假設(shè)我們有如下節(jié)點(diǎn)信息,我們會(huì)分出哪些 token 來呢。
<p class="a" data="js">測(cè)試元素</p>
對(duì)于上述節(jié)點(diǎn)信息,我們可以拆分出如下 token
- 開始標(biāo)簽:<p
- 屬性標(biāo)簽:class="a"
- 文本節(jié)點(diǎn):測(cè)試元素
- 結(jié)束標(biāo)簽:</p>
狀態(tài)機(jī)的原理,將整個(gè) HTML 字符串進(jìn)行遍歷,每次讀取一個(gè)字符,都要進(jìn)行一次決策(下一個(gè)字符處于哪個(gè)狀態(tài)),而且這個(gè)決策是和當(dāng)前狀態(tài)有關(guān)的,這樣一來,讀取的過程就會(huì)得到一個(gè)又一個(gè)完整的 token,記錄到我們最終需要的 tokens 中。
萬事開頭難,我們首先要確定起初可能處于哪種狀態(tài),也就是確定一個(gè) start 函數(shù),在這之前,對(duì)詞法分析類進(jìn)行簡(jiǎn)單的封裝,具體如下
function HTMLLexicalParser(htmlString, tokenHandler) {
this.token = [];
this.tokens = [];
this.htmlString = htmlString
this.tokenHandler = tokenHandler
}
簡(jiǎn)單解釋下上面的每個(gè)屬性
- token:token 的每個(gè)字符
- tokens:存儲(chǔ)一個(gè)個(gè)已經(jīng)得到的 token
- htmlString:待處理字符串
- tokenHandler:token 處理函數(shù),我們每得到一個(gè) token 時(shí),就已經(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 處理的比較簡(jiǎn)單,如果是 < 字符,表示開始標(biāo)簽或結(jié)束標(biāo)簽,因此我們需要下一個(gè)字符信息才能確定到底是哪一類 token,所以返回 tagState 函數(shù)去進(jìn)行再判斷,否則我們就認(rèn)為是文本節(jié)點(diǎn),返回文本狀態(tài)函數(shù)。
接下來分別展開 tagState 和 textState 函數(shù)。 tagState 根據(jù)下一個(gè)字符,判斷進(jìn)入開始標(biāo)簽狀態(tài)還是結(jié)束標(biāo)簽狀態(tài),如果是 / 表示是結(jié)束標(biāo)簽,否則是開始標(biāo)簽, textState 用來處理每一個(gè)文本節(jié)點(diǎn)字符,遇到 < 表示得到一個(gè)完整的文本節(jié)點(diǎn) 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 存儲(chǔ)在 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é)點(diǎn)信息
- endTagState用來處理結(jié)束標(biāo)簽,結(jié)束標(biāo)簽不存在屬性,因此只有兩種情形
- 如果接下來的字符是字母,則認(rèn)定依舊處于結(jié)束標(biāo)簽態(tài)
- 遇到>,同樣認(rèn)定為結(jié)束標(biāo)簽態(tài)結(jié)束,但接下來是處理新的節(jié)點(diǎn)信息
邏輯上面說的比較清楚了,代碼也比較簡(jiǎn)單,看看就好啦
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ù),也處理三種情形
- 如果是字母、單引號(hào)、雙引號(hào)、等號(hào),則認(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é)點(diǎn)信息
代碼如下
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
}
}
最后我們提供一個(gè) 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
}
接下來簡(jiǎn)單測(cè)試一下,對(duì)于 <p class="a" data="js">測(cè)試并列元素的</p><p class="a" data="js">測(cè)試并列元素的</p> HTML 字符串,輸出結(jié)果為

看上去結(jié)果很 nice,接下來進(jìn)入語法分析步驟
語法分析
首先們需要考慮到的情況有兩種,一種是有多個(gè)根元素的,一種是只有一個(gè)根元素的。
我們的節(jié)點(diǎn)有兩種類型,文本節(jié)點(diǎn)和正常節(jié)點(diǎn),因此聲明兩個(gè)數(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)系,因?yàn)檎鎸?shí)的 DOM 結(jié)構(gòu)就是父子關(guān)系,這里我一開始實(shí)踐的時(shí)候,將 childNodes 屬性的處理放在了 startTag token 中,還給 Element 增加了 isEnd 屬性,實(shí)屬愚蠢,不但復(fù)雜化了,而且還很難實(shí)現(xiàn)。
仔細(xì)思考 DOM 結(jié)構(gòu),token 也是有順序的,合理利用棧數(shù)據(jù)結(jié)構(gòu),這個(gè)問題就變的簡(jiǎn)單了,將 childNodes 處理放在 endTag 中處理。具體邏輯如下
- 如果是 startTag token,直接 push 一個(gè)新 element
- 如果是 endTag token,則表示當(dāng)前節(jié)點(diǎn)處理完成,此時(shí)出棧一個(gè)節(jié)點(diǎn),同時(shí)將該節(jié)點(diǎn)歸入棧頂元素節(jié)點(diǎn)的 childNodes 屬性,這里需要做個(gè)判斷,如果出棧之后棧空了,表示整個(gè)節(jié)點(diǎn)處理完成,考慮到可能有平行元素,將元素 push 到 stacks。
- 如果是 attr token,直接寫入棧頂元素的 attr 屬性
- 如果是 text token,由于文本節(jié)點(diǎn)的特殊性,不存在有子節(jié)點(diǎn)、屬性等,就認(rèn)定為處理完成。這里需要做個(gè)判斷,因?yàn)槲谋竟?jié)點(diǎn)可能是根級(jí)別的,判斷是否存在棧頂元素,如果存在直接壓入棧頂元素的 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)
}
}
}
簡(jiǎn)單測(cè)試如下:

沒啥大問題哈
解釋執(zhí)行
對(duì)于上述語法分析的結(jié)果,可以理解成 vdom 結(jié)構(gòu)了,接下來就是映射成真實(shí)的 DOM,這里其實(shí)比較簡(jiǎn)單,用下遞歸即可,直接上代碼吧
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)
}
}
實(shí)現(xiàn)函數(shù)
上面三步驟完成后,來到了最后一步,實(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)
}
三個(gè)不同情況的測(cè)試用例簡(jiǎn)單測(cè)試下
html(document.getElementById('app'), '<p class="a" data="js">測(cè)試并列元素的</p><p class="a" data="js">測(cè)試并列元素的</p>')
html(document.getElementById('app'), '測(cè)試<div>你好呀,我測(cè)試一下沒有深層元素的</div>')
html(document.getElementById('app'), '<div class="div"><p class="p">測(cè)試一下嵌套很深的<span class="span">p的子元素</span></p><span>p同級(jí)別</span></div>')
聲明:簡(jiǎn)單測(cè)試下都沒啥問題,本次實(shí)踐的目的是對(duì) DOM 這一塊通過詞法分析和語法分析生成 DOM Tree 有一個(gè)基本的認(rèn)識(shí),所以細(xì)節(jié)問題肯定還是存在很多的。
總結(jié)
其實(shí)在了解了原理之后,這一塊代碼寫下來,并沒有太大的難度,但卻讓我很興奮,有兩個(gè)成果吧
- 了解并初步實(shí)踐了一下狀態(tài)機(jī)
- 數(shù)據(jù)結(jié)構(gòu)的魅力
代碼已經(jīng)基本都列出來了,想跑一下的童鞋也可以 clone 這個(gè) repo: domtree
總結(jié)
以上所述是小編給大家介紹的用原生 JS 實(shí)現(xiàn) innerHTML 功能實(shí)例詳解,希望對(duì)大家有所幫助,如果大家有任何疑問請(qǐng)給我留言,小編會(huì)及時(shí)回復(fù)大家的。在此也非常感謝大家對(duì)腳本之家網(wǎng)站的支持!
- JavaScript 輸出顯示內(nèi)容(document.write、alert、innerHTML、console.log)
- JavaScript中innerHTML,innerText,outerHTML的用法及區(qū)別
- Javascript中innerHTML用法實(shí)例分析
- js的.innerHTML = ""IE9下顯示有錯(cuò)誤的解決方法
- js innerHTML 改變div內(nèi)容的方法
- Javascript在IE下設(shè)置innerHTML時(shí)出現(xiàn)未知的運(yùn)行時(shí)錯(cuò)誤的解決方法
- javascript innerText和innerHtml應(yīng)用
- javascript 異步的innerHTML使用分析
- JS 動(dòng)態(tài)獲取節(jié)點(diǎn)代碼innerHTML分析 [IE,FF]
相關(guān)文章
uniapp中使用計(jì)時(shí)器setInterval的場(chǎng)景與方法
計(jì)時(shí)器在日常開發(fā)中經(jīng)常會(huì)遇到,下面這篇文章主要給大家介紹了關(guān)于uniapp中使用計(jì)時(shí)器setInterval的場(chǎng)景與方法,文中通過實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-08-08
js實(shí)現(xiàn)簡(jiǎn)單的前端分頁效果
這篇文章主要為大家詳細(xì)介紹了js實(shí)現(xiàn)簡(jiǎn)單的前端分頁效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-08-08
JavaScript正則替換HTML標(biāo)簽功能示例
這篇文章主要介紹了JavaScript正則替換HTML標(biāo)簽功能,結(jié)合完整實(shí)例形式詳細(xì)分析了javascript正則替換字符串操作相關(guān)實(shí)現(xiàn)技巧,需要的朋友可以參考下2017-03-03
JavaScript自動(dòng)點(diǎn)擊鏈接 防止繞過瀏覽器訪問的方法
下面小編就為大家?guī)硪黄狫avaScript自動(dòng)點(diǎn)擊鏈接 防止繞過瀏覽器訪問的方法。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-01-01
javascript在當(dāng)前窗口關(guān)閉前檢測(cè)窗口是否關(guān)閉
檢測(cè)窗口是否關(guān)閉,在當(dāng)前窗口關(guān)閉前使用js做到這一點(diǎn),下面是具體的實(shí)現(xiàn),感興趣的朋友可以參考下2014-09-09
使用weixin-java-tools完成微信授權(quán)登錄、微信支付的示例
這篇文章主要介紹了使用weixin-java-tools完成微信授權(quán)登錄、微信支付的示例,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-09-09
JavaScript數(shù)組reduce()方法使用實(shí)例詳解
reduce是數(shù)組原型對(duì)象上的一個(gè)方法,可以幫助我們操作數(shù)組。本文將和大家分享4個(gè)關(guān)于JavaScript中數(shù)組reduce的用法,希望對(duì)大家有所幫助2022-07-07

