都知道瀏覽器和服務端是通過 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
函式去進行再判斷,否則我們就認為是文字節點,返回文字狀態函式。
接下來分別展開tagState
和textState
函式。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
}
}
複製程式碼
這裡初次見面的函式是emitToken
、startTagState
和endTagState
。
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 字串,輸出結果為
看上去結果很 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)
}
}
}
複製程式碼
簡單測試如下:
沒啥大問題哈
解釋執行
對於上述語法分析的結果,可以理解成 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