都知道瀏覽器和服務端是經過 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
}
複製代碼
簡單解釋下上面的每一個屬性
咱們能夠很容易的知道,字符串要麼以普通文本開頭,要麼以<
開頭,所以 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 一個新 elementendTag
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