摘要:模板解析器原理本文來自深入淺出模板編譯原理篇的第九章,主要講述了如何將模板解析成,這一章的內容是全書最復雜且燒腦的章節。循環模板的偽代碼如下截取模板字符串并觸發鉤子函數為了方便理解,我們手動模擬解析器的解析過程。
Vue.js 模板解析器原理
本文來自《深入淺出Vue.js》模板編譯原理篇的第九章,主要講述了如何將模板解析成AST,這一章的內容是全書最復雜且燒腦的章節。本文未經排版,真實紙質書的排版會更加精致。
通過第8章的學習,我們知道解析器在整個模板編譯中的位置。我們只有將模板解析成AST后,才能基于AST做優化或者生成代碼字符串,那么解析器是如何將模板解析成AST的呢?
本章中,我們將詳細介紹解析器內部的運行原理。
9.1 解析器的作用解析器要實現的功能是將模板解析成AST。
例如:
{{name}}
上面的代碼是一個比較簡單的模板,它轉換成AST后的樣子如下:
{ tag: "div" type: 1, staticRoot: false, static: false, plain: true, parent: undefined, attrsList: [], attrsMap: {}, children: [ { tag: "p" type: 1, staticRoot: false, static: false, plain: true, parent: {tag: "div", ...}, attrsList: [], attrsMap: {}, children: [{ type: 2, text: "{{name}}", static: false, expression: "_s(name)" }] } ] }
其實AST并不是什么很神奇的東西,不要被它的名字嚇倒。它只是用JS中的對象來描述一個節點,一個對象代表一個節點,對象中的屬性用來保存節點所需的各種數據。比如,parent屬性保存了父節點的描述對象,children屬性是一個數組,里面保存了一些子節點的描述對象。再比如,type屬性代表一個節點的類型等。當很多個獨立的節點通過parent屬性和children屬性連在一起時,就變成了一個樹,而這樣一個用對象描述的節點樹其實就是AST。
9.2 解析器內部運行原理事實上,解析器內部也分了好幾個子解析器,比如HTML解析器、文本解析器以及過濾器解析器,其中最主要的是HTML解析器。顧名思義,HTML解析器的作用是解析HTML,它在解析HTML的過程中會不斷觸發各種鉤子函數。這些鉤子函數包括開始標簽鉤子函數、結束標簽鉤子函數、文本鉤子函數以及注釋鉤子函數。
偽代碼如下:
parseHTML(template, { start (tag, attrs, unary) { // 每當解析到標簽的開始位置時,觸發該函數 }, end () { // 每當解析到標簽的結束位置時,觸發該函數 }, chars (text) { // 每當解析到文本時,觸發該函數 }, comment (text) { // 每當解析到注釋時,觸發該函數 } })
你可能不能很清晰地理解,下面我們舉個簡單的例子:
我是Berwin
當上面這個模板被HTML解析器解析時,所觸發的鉤子函數依次是:start、start、chars、end、end。
也就是說,解析器其實是從前向后解析的。解析到 時,又觸發一次鉤子函數start;接著解析到我是Berwin這行文本,此時觸發了文本鉤子函數chars;然后解析到
因此,我們可以在鉤子函數中構建AST節點。在start鉤子函數中構建元素類型的節點,在chars鉤子函數中構建文本類型的節點,在comment鉤子函數中構建注釋類型的節點。
當HTML解析器不再觸發鉤子函數時,就代表所有模板都解析完畢,所有類型的節點都在鉤子函數中構建完成,即AST構建完成。
我們發現,鉤子函數start有三個參數,分別是tag、attrs和unary,它們分別代表標簽名、標簽的屬性以及是否是自閉合標簽。
而文本節點的鉤子函數chars和注釋節點的鉤子函數comment都只有一個參數,只有text。這是因為構建元素節點時需要知道標簽名、屬性和自閉合標識,而構建注釋節點和文本節點時只需要知道文本即可。
什么是自閉合標簽?舉個簡單的例子,input標簽就屬于自閉合標簽:,而div標簽就不屬于自閉合標簽:。
在start鉤子函數中,我們可以使用這三個參數來構建一個元素類型的AST節點,例如:
function createASTElement (tag, attrs, parent) { return { type: 1, tag, attrsList: attrs, parent, children: [] } } parseHTML(template, { start (tag, attrs, unary) { let element = createASTElement(tag, attrs, currentParent) } })
在上面的代碼中,我們在鉤子函數start中構建了一個元素類型的AST節點。
如果是觸發了文本的鉤子函數,就使用參數中的文本構建一個文本類型的AST節點,例如:
parseHTML(template, { chars (text) { let element = {type: 3, text} } })
如果是注釋,就構建一個注釋類型的AST節點,例如:
parseHTML(template, { comment (text) { let element = {type: 3, text, isComment: true} } })
你會發現,9.1節中看到的AST是有層級關系的,一個AST節點具有父節點和子節點,但是9.2節中介紹的創建節點的方式,節點是被拉平的,沒有層級關系。因此,我們需要一套邏輯來實現層級關系,讓每一個AST節點都能找到它的父級。下面我們介紹一下如何構建AST層級關系。
構建AST層級關系其實非常簡單,我們只需要維護一個棧(stack)即可,用棧來記錄層級關系,這個層級關系也可以理解為DOM的深度。
HTML解析器在解析HTML時,是從前向后解析。每當遇到開始標簽,就觸發鉤子函數start。每當遇到結束標簽,就會觸發鉤子函數end。
基于HTML解析器的邏輯,我們可以在每次觸發鉤子函數start時,把當前構建的節點推入棧中;每當觸發鉤子函數end時,就從棧中彈出一個節點。
這樣就可以保證每當觸發鉤子函數start時,棧的最后一個節點就是當前正在構建的節點的父節點,如圖9-1所示。
圖9-1 使用棧記錄DOM層級關系(英文為代碼體)
下面我們用一個具體的例子來描述如何從0到1構建一個帶層級關系的AST。
假設有這樣一個模板:
我是Berwin
我今年23歲
上面這個模板被解析成AST的過程如圖9-2所示。
圖9-2給出了構建AST的過程,圖中的黑底白數字代表解析的步驟,具體如下。
(1) 模板的開始位置是div的開始標簽,于是會觸發鉤子函數start。start觸發后,會先構建一個div節點。此時發現棧是空的,這說明div節點是根節點,因為它沒有父節點。最后,將div節點推入棧中,并將模板字符串中的div開始標簽從模板中截取掉。
(2) 這時模板的開始位置是一些空格,這些空格會觸發文本節點的鉤子函數,在鉤子函數里會忽略這些空格。同時會在模板中將這些空格截取掉。
(3) 這時模板的開始位置是h1的開始標簽,于是會觸發鉤子函數start。與前面流程一樣,start觸發后,會先構建一個h1節點。此時發現棧的最后一個節點是div節點,這說明h1節點的父節點是div,于是將h1添加到div的子節點中,并且將h1節點推入棧中,同時從模板中將h1的開始標簽截取掉。
(4) 這時模板的開始位置是一段文本,于是會觸發鉤子函數chars。chars觸發后,會先構建一個文本節點,此時發現棧中的最后一個節點是h1,這說明文本節點的父節點是h1,于是將文本節點添加到h1節點的子節點中。由于文本節點沒有子節點,所以文本節點不會被推入棧中。最后,將文本從模板中截取掉。
(5) 這時模板的開始位置是h1結束標簽,于是會觸發鉤子函數end。end觸發后,會把棧中最后一個節點彈出來。
(6) 與第(2)步一樣,這時模板的開始位置是一些空格,這些空格會觸發文本節點的鉤子函數,在鉤子函數里會忽略這些空格。同時會在模板中將這些空格截取掉。
(7) 這時模板的開始位置是p開始標簽,于是會觸發鉤子函數start。start觸發后,會先構建一個p節點。由于第(5)步已經從棧中彈出了一個節點,所以此時棧中的最后一個節點是div,這說明p節點的父節點是div。于是將p推入div的子節點中,最后將p推入到棧中,并將p的開始標簽從模板中截取掉。
(8) 這時模板的開始位置又是一段文本,于是會觸發鉤子函數chars。當chars觸發后,會先構建一個文本節點,此時發現棧中的最后一個節點是p節點,這說明文本節點的父節點是p節點。于是將文本節點推入p節點的子節點中,并將文本從模板中截取掉。
(9) 這時模板的開始位置是p的結束標簽,于是會觸發鉤子函數end。當end觸發后,會從棧中彈出一個節點出來,也就是把p標簽從棧中彈出來,并將p的結束標簽從模板中截取掉。
(10) 與第(2)步和第(6)步一樣,這時模板的開始位置是一些空格,這些空格會觸發文本節點的鉤子函數并且在鉤子函數里會忽略這些空格。同時會在模板中將這些空格截取掉。
(11) 這時模板的開始位置是div的結束標簽,于是會觸發鉤子函數end。其邏輯與之前一樣,把棧中的最后一個節點彈出來,也就是把div彈了出來,并將div的結束標簽從模板中截取掉。
(12)這時模板已經被截取空了,也就代表著HTML解析器已經運行完畢。這時我們會發現棧已經空了,但是我們得到了一個完整的帶層級關系的AST語法樹。這個AST中清晰寫明了每個節點的父節點、子節點及其節點類型。
9.3 HTML解析器通過前面的介紹,我們發現構建AST非常依賴HTML解析器所執行的鉤子函數以及鉤子函數中所提供的參數,你一定會非常好奇HTML解析器是如何解析模板的,接下來我們會詳細介紹HTML解析器的運行原理。
9.3.1 運行原理事實上,解析HTML模板的過程就是循環的過程,簡單來說就是用HTML模板字符串來循環,每輪循環都從HTML模板中截取一小段字符串,然后重復以上過程,直到HTML模板被截成一個空字符串時結束循環,解析完畢,如圖9-2所示。
在截取一小段字符串時,有可能截取到開始標簽,也有可能截取到結束標簽,又或者是文本或者注釋,我們可以根據截取的字符串的類型來觸發不同的鉤子函數。
循環HTML模板的偽代碼如下:
function parseHTML(html, options) { while (html) { // 截取模板字符串并觸發鉤子函數 } }
為了方便理解,我們手動模擬HTML解析器的解析過程。例如,下面這樣一個簡單的HTML模板:
{{name}}
它在被HTML解析器解析的過程如下。
最初的HTML模板:
``{{name}}
第一輪循環時,截取出一段字符串 {{name}}`
第二輪循環時,截取出一段字符串:
` `
并且觸發鉤子函數chars,截取后的結果為:
`{{name}}
第三輪循環時,截取出一段字符串
,并且觸發鉤子函數start,截取后的結果為:
`{{name}}
第四輪循環時,截取出一段字符串{{name}},并且觸發鉤子函數chars,截取后的結果為:
`
第五輪循環時,截取出一段字符串
,并且觸發鉤子函數end,截取后的結果為:`
第六輪循環時,截取出一段字符串:
` `
并且觸發鉤子函數chars,截取后的結果為:
`
第七輪循環時,截取出一段字符串,并且觸發鉤子函數end,截取后的結果為:
``
解析完畢。
HTML解析器的全部邏輯都是在循環中執行,循環結束就代表解析結束。接下來,我們要討論的重點是HTML解析器在循環中都干了些什么事。
你會發現HTML解析器可以很聰明地知道它在每一輪循環中應該截取哪些字符串,那么它是如何做到這一點的呢?
通過前面的例子,我們發現一個很有趣的事,那就是每一輪截取字符串時,都是在整個模板的開始位置截取。我們根據模板開始位置的片段類型,進行不同的截取操作。
例如,上面例子中的第一輪循環:如果是以開始標簽開頭的模板,就把開始標簽截取掉。再例如,上面例子中的第四輪循環:如果是以文本開始的模板,就把文本截取掉。
這些被截取的片段分很多種類型,示例如下。
開始標簽,例如 結束標簽,例如
HTML注釋,例如。
DOCTYPE,例如。
條件注釋,例如我是注釋。
文本,例如我是Berwin。
通常,最常見的是開始標簽、結束標簽、文本以及注釋。
9.3.2 截取開始標簽上一節中我們說過,每一輪循環都是從模板的最前面截取,所以只有模板以開始標簽開頭,才需要進行開始標簽的截取操作。
那么,如何確定模板是不是以開始標簽開頭?
在HTML解析器中,想分辨出模板是否以開始標簽開頭并不難,我們需要先判斷HTML模板是不是以<開頭。
如果HTML模板的第一個字符不是<,那么它一定不是以開始標簽開頭的模板,所以不需要進行開始標簽的截取操作。
如果HTML模板以<開頭,那么說明它至少是一個以標簽開頭的模板,但這個標簽到底是什么類型的標簽,還需要進一步確認。
如果模板以<開頭,那么它有可能是以開始標簽開頭的模板,同時它也有可能是以結束標簽開頭的模板,還有可能是注釋等其他標簽,因為這些類型的片段都以<開頭。那么,要進一步確定模板是不是以開始標簽開頭,還需要借助正則表達式來分辨模板的開始位置是否符合開始標簽的特征。
那么,如何使用正則表達式來匹配模板以開始標簽開頭?我們看下面的代碼:
const ncname = "[a-zA-Z_][w-.]*" const qnameCapture = `((?:${ncname}:)?${ncname})` const startTagOpen = new RegExp(`^<${qnameCapture}`) // 以開始標簽開始的模板 "".match(startTagOpen) // [""] // 以結束標簽開始的模板 "我是Berwin".match(startTagOpen) // null // 以文本開始的模板 "我是Berwin".match(startTagOpen) // null
通過上面的例子可以看到,只有""可以成功匹配,而以開頭的或者以文本開頭的模板都無法成功匹配。
在9.2節中,我們介紹了當HTML解析器解析到標簽開始時,會觸發鉤子函數start,同時會給出三個參數,分別是標簽名(tagName)、屬性(attrs)以及自閉合標識(unary)。
因此,在分辨出模板以開始標簽開始之后,需要將標簽名、屬性以及自閉合標識解析出來。
在分辨模板是否以開始標簽開始時,就可以得到標簽名,而屬性和自閉合標識則需要進一步解析。
當完成上面的解析后,我們可以得到這樣一個數據結構:
const start = "".match(startTagOpen) if (start) { const match = { tagName: start[1], attrs: [] } }
這里有一個細節很重要:在前面的例子中,我們匹配到的開始標簽并不全。例如:
const ncname = "[a-zA-Z_][w-.]*" const qnameCapture = `((?:${ncname}:)?${ncname})` const startTagOpen = new RegExp(`^<${qnameCapture}`) "".match(startTagOpen) // [""] "".match(startTagOpen) // [""] "".match(startTagOpen) // [""]
可以看出,上面這個正則表達式雖然可以分辨出模板是否以開始標簽開頭,但是它的匹配規則并不是匹配整個開始標簽,而是開始標簽的一小部分。
事實上,開始標簽被拆分成三個小部分,分別是標簽名、屬性和結尾,如圖9-3所示。
圖9-3 開始標簽被拆分成三個小部分(代碼用代碼體)
通過“標簽名”這一段字符,就可以分辨出模板是否以開始標簽開頭,此后要想得到屬性和自閉合標識,則需要進一步解析。
1. 解析標簽屬性在分辨模板是否以開始標簽開頭時,會將開始標簽中的標簽名這一小部分截取掉,因此在解析標簽屬性時,我們得到的模板是下面偽代碼中的樣子:
" class="box">"
通常,標簽屬性是可選的,一個標簽的屬性有可能存在,也有可能不存在,所以需要判斷標簽是否存在屬性,如果存在,對它進行截取。
下面的偽代碼展示了如何解析開始標簽中的屬性,但是它只能解析一個屬性:
const attribute = /^s*([^s""<>/=]+)(?:s*(=)s*(?:"([^"]*)"+|"([^"]*)"+|([^s""=<>`]+)))?/ let html = " class="box">" let attr = html.match(attribute) html = html.substring(attr[0].length) console.log(attr) // [" class="box"", "class", "=", "box", undefined, undefined, index: 0, input: " class="box">"]
如果標簽上有很多屬性,那么上面的處理方式就不足以支撐解析任務的正常運行。例如下面的代碼:
const attribute = /^s*([^s""<>/=]+)(?:s*(=)s*(?:"([^"]*)"+|"([^"]*)"+|([^s""=<>`]+)))?/ let html = " class="box" id="el">" let attr = html.match(attribute) html = html.substring(attr[0].length) console.log(attr) // [" class="box"", "class", "=", "box", undefined, undefined, index: 0, input: " class="box" id="el">"]
可以看到,這里只解析出了class屬性,而id屬性沒有解析出來。
此時剩余的HTML模板是這樣的:
" id="el">"
所以屬性也可以分成多個小部分,一小部分一小部分去解析與截取。
解決這個問題時,我們只需要每解析一個屬性就截取一個屬性。如果截取完后,剩下的HTML模板依然符合標簽屬性的正則表達式,那么說明還有剩余的屬性需要處理,此時就重復執行前面的流程,直到剩余的模板不存在屬性,也就是剩余的模板不存在符合正則表達式所預設的規則。
例如:
const startTagClose = /^s*(/?)>/ const attribute = /^s*([^s""<>/=]+)(?:s*(=)s*(?:"([^"]*)"+|"([^"]*)"+|([^s""=<>`]+)))?/ let html = " class="box" id="el">" let end, attr const match = {tagName: "div", attrs: []} while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) { html = html.substring(attr[0].length) match.attrs.push(attr) }
上面這段代碼的意思是,如果剩余HTML模板不符合開始標簽結尾部分的特征,并且符合標簽屬性的特征,那么進入到循環中進行解析與截取操作。
通過match方法解析出的結果為:
{ tagName: "div", attrs: [ [" class="box"", "class", "=", "box", null, null], [" id="el"", "id","=", "el", null, null] ] }
可以看到,標簽中的兩個屬性都已經解析好并且保存在了attrs中。
此時剩余模板是下面的樣子:
">"
我們將屬性解析后的模板與解析之前的模板進行對比:
// 解析前的模板 " class="box" id="el">" // 解析后的模板 ">" // 解析前的數據 { tagName: "div", attrs: [] } // 解析后的數據 { tagName: "div", attrs: [ [" class="box"", "class", "=", "box", null, null], [" id="el"", "id","=", "el", null, null] ] }
可以看到,標簽上的所有屬性都已經被成功解析出來,并保存在attrs屬性中。
2. 解析自閉合標識如果我們接著上面的例子繼續解析的話,目前剩余的模板是下面這樣的:
">"
開始標簽中結尾部分解析的主要目的是解析出當前這個標簽是否是自閉合標簽。
舉個例子:
這樣的div標簽就不是自閉合標簽,而下面這樣的input標簽就屬于自閉合標簽:
自閉合標簽是沒有子節點的,所以前文中我們提到構建AST層級時,需要維護一個棧,而一個節點是否需要推入到棧中,可以使用這個自閉合標識來判斷。
那么,如何解析開始標簽中的結尾部分呢?看下面這段代碼:
function parseStartTagEnd (html) { const startTagClose = /^s*(/?)>/ const end = html.match(startTagClose) const match = {} if (end) { match.unarySlash = end[1] html = html.substring(end[0].length) return match } } console.log(parseStartTagEnd(">")) // {unarySlash: ""} console.log(parseStartTagEnd("/>")) // {unarySlash: "/"}
這段代碼可以正確解析出開始標簽是否是自閉合標簽。
從代碼中打印出來的結果可以看到,自閉合標簽解析后的unarySlash屬性為/,而非自閉合標簽為空字符串。
3. 實現源碼前面解析開始標簽時,我們將其拆解成了三個部分,分別是標簽名、屬性和結尾。我相信你已經對開始標簽的解析有了一個清晰的認識,接下來看一下Vue.js中真實的代碼是什么樣的:
const ncname = "[a-zA-Z_][w-.]*" const qnameCapture = `((?:${ncname}:)?${ncname})` const startTagOpen = new RegExp(`^<${qnameCapture}`) const startTagClose = /^s*(/?)>/ function advance (n) { html = html.substring(n) } function parseStartTag () { // 解析標簽名,判斷模板是否符合開始標簽的特征 const start = html.match(startTagOpen) if (start) { const match = { tagName: start[1], attrs: [] } advance(start[0].length) // 解析標簽屬性 let end, attr while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) { advance(attr[0].length) match.attrs.push(attr) } // 判斷是否是自閉合標簽 if (end) { match.unarySlash = end[1] advance(end[0].length) return match } } }
上面的代碼是Vue.js中解析開始標簽的源碼,這段代碼中的html變量是HTML模板。
調用parseStartTag就可以將剩余模板開始部分的開始標簽解析出來。如果剩余HTML模板的開始部分不符合開始標簽的正則表達式規則,那么調用parseStartTag就會返回undefined。因此,判斷剩余模板是否符合開始標簽的規則,只需要調用parseStartTag即可。如果調用它后得到了解析結果,那么說明剩余模板的開始部分符合開始標簽的規則,此時將解析出來的結果取出來并調用鉤子函數start即可:
// 開始標簽 const startTagMatch = parseStartTag() if (startTagMatch) { handleStartTag(startTagMatch) continue }
前面我們說過,所有解析操作都運行在循環中,所以continue的意思是這一輪的解析工作已經完成,可以進行下一輪解析工作。
從代碼中可以看出,如果調用parseStartTag之后有返回值,那么會進行開始標簽的處理,其處理邏輯主要在handleStartTag中。這個函數的主要目的就是將tagName、attrs和unary等數據取出來,然后調用鉤子函數將這些數據放到參數中。
9.3.3 截取結束標簽結束標簽的截取要比開始標簽簡單得多,因為它不需要解析什么,只需要分辨出當前是否已經截取到結束標簽,如果是,那么觸發鉤子函數就可以了。
那么,如何分辨模板已經截取到結束標簽了呢?其道理其實和開始標簽的截取相同。
如果HTML模板的第一個字符不是<,那么一定不是結束標簽。只有HTML模板的第一個字符是<時,我們才需要進一步確認它到底是不是結束標簽。
進一步確認時,我們只需要判斷剩余HTML模板的開始位置是否符合正則表達式中定義的規則即可:
const ncname = "[a-zA-Z_][w-.]*" const qnameCapture = `((?:${ncname}:)?${ncname})` const endTag = new RegExp(`^${qnameCapture}[^>]*>`) const endTagMatch = "".match(endTag) const endTagMatch2 = "".match(endTag) console.log(endTagMatch) // ["", "div", index: 0, input: ""] console.log(endTagMatch2) // null
上面代碼可以分辨出剩余模板是否是結束標簽。當分辨出結束標簽后,需要做兩件事,一件事是截取模板,另一件事是觸發鉤子函數。而Vue.js中相關源碼被精簡后如下:
const endTagMatch = html.match(endTag) if (endTagMatch) { html = html.substring(endTagMatch[0].length) options.end(endTagMatch[1]) continue }
可以看出,先對模板進行截取,然后觸發鉤子函數。
9.3.4 截取注釋分辨模板是否已經截取到注釋的原理與開始標簽和結束標簽相同,先判斷剩余HTML模板的第一個字符是不是<,如果是,再用正則表達式來進一步匹配:
const comment = /^") if (commentEnd >= 0) { if (options.shouldKeepComment) { options.comment(html.substring(4, commentEnd)) } html = html.substring(commentEnd + 3) continue } }
在上面的代碼中,我們使用正則表達式來判斷剩余的模板是否符合注釋的規則,如果符合,就將這段注釋文本截取出來。
這里有一個有意思的地方,那就是注釋的鉤子函數可以通過選項來配置,只有options.shouldKeepComment為真時,才會觸發鉤子函數,否則只截取模板,不觸發鉤子函數。
9.3.5 截取條件注釋條件注釋不需要觸發鉤子函數,我們只需要把它截取掉就行了。
截取條件注釋的原理與截取注釋非常相似,如果模板的第一個字符是<,并且符合我們事先用正則表達式定義好的規則,就說明需要進行條件注釋的截取操作。
在下面的代碼中,我們通過indexOf找到條件注釋結束位置的下標,然后將結束位置前的字符都截取掉:
const conditionalComment = /^") if (conditionalEnd >= 0) { html = html.substring(conditionalEnd + 2) continue } }
我們來舉個例子:
const conditionalComment = /^" if (conditionalComment.test(html)) { const conditionalEnd = html.indexOf("]>") if (conditionalEnd >= 0) { html = html.substring(conditionalEnd + 2) } } console.log(html) // ""
從打印結果中可以看到,HTML中的條件注釋部分截取掉了。
通過這個邏輯可以發現,在Vue.js中條件注釋其實沒有用,寫了也會被截取掉,通俗一點說就是寫了也白寫。
9.3.6 截取DOCTYPEDOCTYPE與條件注釋相同,都是不需要觸發鉤子函數的,只需要將匹配到的這一段字符截取掉即可。下面的代碼將DOCTYPE這段字符匹配出來后,根據它的length屬性來決定要截取多長的字符串:
const doctype = /^]+>/i const doctypeMatch = html.match(doctype) if (doctypeMatch) { html = html.substring(doctypeMatch[0].length) continue }
示例如下:
const doctype = /^]+>/i let html = "" const doctypeMatch = html.match(doctype) if (doctypeMatch) { html = html.substring(doctypeMatch[0].length) } console.log(html) // ""
從打印結果可以看到,HTML中的DOCTYPE被成功截取掉了。
9.3.7 截取文本若想分辨在本輪循環中HTML模板是否已經截取到文本,其實很簡單,我們甚至不需要使用正則表達式。
在前面的其他標簽類型中,我們都會判斷剩余HTML模板的第一個字符是否是<,如果是,再進一步確認到底是哪種類型。這是因為以<開頭的標簽類型太多了,如開始標簽、結束標簽和注釋等。然而文本只有一種,如果HTML模板的第一個字符不是<,那么它一定是文本了。
例如:
我是文本
上面這段HTML模板并不是以<開頭的,所以可以斷定它是以文本開頭的。
那么,如何從模板中將文本解析出來呢?我們只需要找到下一個<在什么位置,這之前的所有字符都屬于文本,如圖9-4所示。
圖9-4 尖括號前面的字符都屬于文本
在代碼中可以這樣實現:
while (html) { let text let textEnd = html.indexOf("<") // 截取文本 if (textEnd >= 0) { text = html.substring(0, textEnd) html = html.substring(textEnd) } // 如果模板中找不到<,就說明整個模板都是文本 if (textEnd < 0) { text = html html = "" } // 觸發鉤子函數 if (options.chars && text) { options.chars(text) } }
上面的代碼共有三部分邏輯。
第一部分是截取文本,這在前面介紹過了。<之前的所有字符都是文本,直接使用html.substring從模板的最開始位置截取到<之前的位置,就可以將文本截取出來。
第二部分是一個條件:如果在整個模板中都找不到<,那么說明整個模板全是文本。
第三部分是觸發鉤子函數并將截取出來的文本放到參數中。
關于文本,還有一個特殊情況需要處理:如果<是文本的一部分,該如何處理?
舉個例子:
1<2
在上面這樣的模板中,如果只截取第一個<前面的字符,最后被截取出來的將只有1,而不能把所有文本都截取出來。
那么,該如何解決這個問題呢?
有一個思路是,如果將<前面的字符截取完之后,剩余的模板不符合任何需要被解析的片段的類型,就說明這個<是文本的一部分。
什么是需要被解析的片段的類型?在9.3.1節中,我們說過HTML解析器是一段一段截取模板的,而被截取的每一段都符合某種類型,這些類型包括開始標簽、結束標簽和注釋等。
說的再具體一點,那就是上面這段代碼中的1被截取完之后,剩余模板是下面的樣子:
<2
<2符合開始標簽的特征么?不符合。
<2符合結束標簽的特征么?不符合。
<2符合注釋的特征么?不符合。
當剩余的模板什么都不符合時,就說明<屬于文本的一部分。
當判斷出<是屬于文本的一部分后,我們需要做的事情是找到下一個<并將其前面的文本截取出來加到前面截取了一半的文本后面。
這里還用上面的例子,第二個<之前的字符是<2,那么把<2截取出來后,追加到上一次截取出來的1的后面,此時的結果是:
1<2
截取后剩余的模板是:
如果剩余的模板依然不符合任何被解析的類型,那么重復此過程。直到所有文本都解析完。
說完了思路,我們看一下具體的實現,偽代碼如下:
while (html) { let text, rest, next let textEnd = html.indexOf("<") // 截取文本 if (textEnd >= 0) { rest = html.slice(textEnd) while ( !endTag.test(rest) && !startTagOpen.test(rest) && !comment.test(rest) && !conditionalComment.test(rest) ) { // 如果"<"在純文本中,將它視為純文本對待 next = rest.indexOf("<", 1) if (next < 0) break textEnd += next rest = html.slice(textEnd) } text = html.substring(0, textEnd) html = html.substring(textEnd) } // 如果模板中找不到<,那么說明整個模板都是文本 if (textEnd < 0) { text = html html = "" } // 觸發鉤子函數 if (options.chars && text) { options.chars(text) } }
在代碼中,我們通過while來解決這個問題(注意是里面的while)。如果剩余的模板不符合任何被解析的類型,那么重復解析文本,直到剩余模板符合被解析的類型為止。
在上面的代碼中,endTag、startTagOpen、comment和conditionalComment都是正則表達式,分別匹配結束標簽、開始標簽、注釋和條件注釋。
在Vue.js源碼中,截取文本的邏輯和其他的實現思路一致。
9.3.8 純文本內容元素的處理什么是純文本內容元素呢?script、style和textarea這三種元素叫作純文本內容元素。解析它們的時候,會把這三種標簽內包含的所有內容都當作文本處理。那么,具體該如何處理呢?
前面介紹開始標簽、結束標簽、文本、注釋的截取時,其實都是默認當前需要截取的元素的父級元素不是純文本內容元素。事實上,如果要截取元素的父級元素是純文本內容元素的話,處理邏輯將完全不一樣。
事實上,在while循環中,最外層的判斷條件就是父級元素是不是純文本內容元素。例如下面的偽代碼:
while (html) { if (!lastTag || !isPlainTextElement(lastTag)) { // 父元素為正常元素的處理邏輯 } else { // 父元素為script、style、textarea的處理邏輯 } }
在上面的代碼中,lastTag代表父元素。可以看到,在while中,首先進行判斷,如果父元素不存在或者不是純文本內容元素,那么進行正常的處理邏輯,也就是前面介紹的邏輯。
而當父元素是script這種純文本內容元素時,會進入到else這個語句里面。由于純文本內容元素都被視作文本處理,所以我們的處理邏輯就變得很簡單,只需要把這些文本截取出來并觸發鉤子函數chars,然后再將結束標簽截取出來并觸發鉤子函數end。
也就是說,如果父標簽是純文本內容元素,那么本輪循環會一次性將這個父標簽給處理完畢。
偽代碼如下:
while (html) { if (!lastTag || !isPlainTextElement(lastTag)) { // 父元素為正常元素的處理邏輯 } else { // 父元素為script、style、textarea的處理邏輯 const stackedTag = lastTag.toLowerCase() const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp("([sS]*?)(" + stackedTag + "[^>]*>)", "i")) const rest = html.replace(reStackedTag, function (all, text) { if (options.chars) { options.chars(text) } return "" }) html = rest options.end(stackedTag) } }
上面代碼中的正則表達式可以匹配結束標簽前包括結束標簽自身在內的所有文本。
我們可以給replace方法的第二個參數傳遞一個函數。在這個函數中,我們得到了參數text(代表結束標簽前的所有內容),觸發了鉤子函數chars并把text放到鉤子函數的參數中傳出去。最后,返回了一個空字符串,代表將匹配到的內容都截掉了。注意,這里的截掉會將內容和結束標簽一起截取掉。
最后,調用鉤子函數end并將標簽名放到參數中傳出去,代表本輪循環中的所有邏輯都已處理完畢。
假如我們現在有這樣一個模板:
當解析到script中的內容時,模板是下面的樣子:
console.log(1)
此時父元素為script,所以會進入到else中的邏輯進行處理。在其處理過程中,會觸發鉤子函數chars和end。
鉤子函數chars的參數為script中的所有內容,本例中大概是下面的樣子:
chars("console.log(1)")
鉤子函數end的參數為標簽名,本例中是script。
處理后的剩余模板如下:
9.3.9 使用棧維護DOM層級
通過前面幾節的介紹,特別是9.3.8節中的介紹,你一定會感到很奇怪,如何知道父元素是誰?
在前面幾節中,我們并沒有介紹HTML解析器內部其實也有一個棧來維護DOM層級關系,其邏輯與9.2.1節相同:就是每解析到開始標簽,就向棧中推進去一個;每解析到標簽結束,就彈出來一個。因此,想取到父元素并不難,只需要拿到棧中的最后一項即可。
同時,HTML解析器中的棧還有另一個作用,它可以檢測出HTML標簽是否正確閉合。例如:
在上面的代碼中,p標簽忘記寫結束標簽,那么當HTML解析器解析到div的結束標簽時,棧頂的元素卻是p標簽。這個時候從棧頂向棧底循環找到div標簽,在找到div標簽之前遇到的所有其他標簽都是忘記了閉合的標簽,而Vue.js會在非生產環境下在控制臺打印警告提示。
關于使用棧來維護DOM層級關系的具體實現思路,9.2.1節已經詳細介紹過,這里不再重復介紹。
9.3.10 整體邏輯前面我們把開始標簽、結束標簽、注釋、文本、純文本內容元素等的截取方式拆分開,多帶帶進行了詳細介紹。本節中,我們就來介紹如何將這些解析方式組裝起來完成HTML解析器的功能。
首先,HTML解析器是一個函數。就像9.2節介紹的那樣,HTML解析器最終的目的是實現這樣的功能:
parseHTML(template, { start (tag, attrs, unary) { // 每當解析到標簽的開始位置時,觸發該函數 }, end () { // 每當解析到標簽的結束位置時,觸發該函數 }, chars (text) { // 每當解析到文本時,觸發該函數 }, comment (text) { // 每當解析到注釋時,觸發該函數 } })
所以HTML解析器在實現上肯定是一個函數,它有兩個參數——模板和選項:
export function parseHTML (html, options) { // 做點什么 }
我們的模板是一小段一小段去截取與解析的,所以需要一個循環來不斷截取,直到全部截取完畢:
export function parseHTML (html, options) { while (html) { // 做點什么 } }
在循環中,首先要判斷父元素是不是純文本內容元素,因為不同類型父節點的解析方式將完全不同:
export function parseHTML (html, options) { while (html) { if (!lastTag || !isPlainTextElement(lastTag)) { // 父元素為正常元素的處理邏輯 } else { // 父元素為script、style、textarea的處理邏輯 } } }
在上面的代碼中,我們發現這里已經把整體邏輯分成了兩部分,一部分是父標簽是正常標簽的邏輯,另一部分是父標簽是script、style、textarea這種純文本內容元素的邏輯。
如果父標簽為正常的元素,那么有幾種情況需要分別處理,比如需要分辨出當前要解析的一小段模板到底是什么類型。是開始標簽?還是結束標簽?又或者是文本?
我們把所有需要處理的情況都列出來,有下面幾種情況:
文本
注釋
條件注釋
DOCTYPE
結束標簽
開始標簽
我們會發現,在這些需要處理的類型中,除了文本之外,其他都是以標簽形式存在的,而標簽是以<開頭的。
所以邏輯就很清晰了,我們先根據<來判斷需要解析的字符是文本還是其他的:
export function parseHTML (html, options) { while (html) { if (!lastTag || !isPlainTextElement(lastTag)) { let textEnd = html.indexOf("<") if (textEnd === 0) { // 做點什么 } let text, rest, next if (textEnd >= 0) { // 解析文本 } if (textEnd < 0) { text = html html = "" } if (options.chars && text) { options.chars(text) } } else { // 父元素為script、style、textarea的處理邏輯 } } }
在上面的代碼中,我們可以通過<來分辨是否需要進行文本解析。關于文本解析的內容,詳見9.3.7節。
如果通過<分辨出即將解析的這一小部分字符不是文本而是標簽類,那么標簽類有那么多類型,我們需要進一步分辨具體是哪種類型:
export function parseHTML (html, options) { while (html) { if (!lastTag || !isPlainTextElement(lastTag)) { let textEnd = html.indexOf("<") if (textEnd === 0) { // 注釋 if (comment.test(html)) { // 注釋的處理邏輯 continue } // 條件注釋 if (conditionalComment.test(html)) { // 條件注釋的處理邏輯 continue } // DOCTYPE const doctypeMatch = html.match(doctype) if (doctypeMatch) { // DOCTYPE的處理邏輯 continue } // 結束標簽 const endTagMatch = html.match(endTag) if (endTagMatch) { // 結束標簽的處理邏輯 continue } // 開始標簽 const startTagMatch = parseStartTag() if (startTagMatch) { // 開始標簽的處理邏輯 continue } } let text, rest, next if (textEnd >= 0) { // 解析文本 } if (textEnd < 0) { text = html html = "" } if (options.chars && text) { options.chars(text) } } else { // 父元素為script、style、textarea的處理邏輯 } } }
關于不同類型的具體處理方式,前面已經詳細介紹過,這里不再重復。
9.4 文本解析器文本解析器的作用是解析文本。你可能會覺得很奇怪,文本不是在HTML解析器中被解析出來了么?準確地說,文本解析器是對HTML解析器解析出來的文本進行二次加工。為什么要進行二次加工?
文本其實分兩種類型,一種是純文本,另一種是帶變量的文本。例如下面這樣的文本是純文本:
Hello Berwin
而下面這樣的是帶變量的文本:
Hello {{name}}
在Vue.js模板中,我們可以使用變量來填充模板。而HTML解析器在解析文本時,并不會區分文本是否是帶變量的文本。如果是純文本,不需要進行任何處理;但如果是帶變量的文本,那么需要使用文本解析器進一步解析。因為帶變量的文本在使用虛擬DOM進行渲染時,需要將變量替換成變量中的值。
我們在9.2節中介紹過,每當HTML解析器解析到文本時,都會觸發chars函數,并且從參數中得到解析出的文本。在chars函數中,我們需要構建文本類型的AST,并將它添加到父節點的children屬性中。
而在構建文本類型的AST時,純文本和帶變量的文本是不同的處理方式。如果是帶變量的文本,我們需要借助文本解析器對它進行二次加工,其代碼如下:
parseHTML(template, { start (tag, attrs, unary) { // 每當解析到標簽的開始位置時,觸發該函數 }, end () { // 每當解析到標簽的結束位置時,觸發該函數 }, chars (text) { text = text.trim() if (text) { const children = currentParent.children let expression if (expression = parseText(text)) { children.push({ type: 2, expression, text }) } else { children.push({ type: 3, text }) } } }, comment (text) { // 每當解析到注釋時,觸發該函數 } })
在chars函數中,如果執行parseText后有返回結果,則說明文本是帶變量的文本,并且已經通過文本解析器(parseText)二次加工,此時構建一個帶變量的文本類型的AST并將其添加到父節點的children屬性中。否則,就直接構建一個普通的文本節點并將其添加到父節點的children屬性中。而代碼中的currentParent是當前節點的父節點,也就是前面介紹的棧中的最后一個節點。
假設chars函數被觸發后,我們得到的text是一個帶變量的文本:
"Hello {{name}}"
這個帶變量的文本被文本解析器解析之后,得到的expression變量是這樣的:
"Hello "+_s(name)
上面代碼中的_s其實是下面這個toString函數的別名:
function toString (val) { return val == null ? "" : typeof val === "object" ? JSON.stringify(val, null, 2) : String(val) }
假設當前上下文中有一個變量name,其值為Berwin,那么expression中的內容被執行時,它的內容是不是就是Hello Berwin了?
我們舉個例子:
var obj = {name: "Berwin"} with(obj) { function toString (val) { return val == null ? "" : typeof val === "object" ? JSON.stringify(val, null, 2) : String(val) } console.log("Hello "+toString(name)) // "Hello Berwin" }
在上面的代碼中,我們打印出來的結果是"Hello Berwin"。
事實上,最終AST會轉換成代碼字符串放在with中執行,這部分內容會在第11章中詳細介紹。
接著,我們詳細介紹如何加工文本,也就是文本解析器的內部實現原理。
在文本解析器中,第一步要做的事情就是使用正則表達式來判斷文本是否是帶變量的文本,也就是檢查文本中是否包含{{xxx}}這樣的語法。如果是純文本,則直接返回undefined;如果是帶變量的文本,再進行二次加工。所以我們的代碼是這樣的:
function parseText (text) { const tagRE = /{{((?:.| )+?)}}/g if (!tagRE(text)) { return } }
在上面的代碼中,如果是純文本,則直接返回。如果是帶變量的文本,該如何處理呢?
一個解決思路是使用正則表達式匹配出文本中的變量,先把變量左邊的文本添加到數組中,然后把變量改成_s(x)這樣的形式也添加到數組中。如果變量后面還有變量,則重復以上動作,直到所有變量都添加到數組中。如果最后一個變量的后面有文本,就將它添加到數組中。
這時我們其實已經有一個數組,數組元素的順序和文本的順序是一致的,此時將這些數組元素用+連起來變成字符串,就可以得到最終想要的效果,如圖9-5所示。
圖9-5 文本解析過程
在圖9-5中,最上面的字符串代表即將解析的文本,中間兩個方塊代表數組中的兩個元素。最后,使用數組方法join將這兩個元素合并成一個字符串。
具體實現代碼如下:
function parseText (text) { const tagRE = /{{((?:.| )+?)}}/g if (!tagRE.test(text)) { return } const tokens = [] let lastIndex = tagRE.lastIndex = 0 let match, index while ((match = tagRE.exec(text))) { index = match.index // 先把 {{ 前邊的文本添加到tokens中 if (index > lastIndex) { tokens.push(JSON.stringify(text.slice(lastIndex, index))) } // 把變量改成`_s(x)`這樣的形式也添加到數組中 tokens.push(`_s(${match[1].trim()})`) // 設置lastIndex來保證下一輪循環時,正則表達式不再重復匹配已經解析過的文本 lastIndex = index + match[0].length } // 當所有變量都處理完畢后,如果最后一個變量右邊還有文本,就將文本添加到數組中 if (lastIndex < text.length) { tokens.push(JSON.stringify(text.slice(lastIndex))) } return tokens.join("+") }
這是文本解析器的全部代碼,代碼并不多,邏輯也不是很復雜。
這段代碼有一個很關鍵的地方在lastIndex:每處理完一個變量后,會重新設置lastIndex的位置,這樣可以保證如果后面還有其他變量,那么在下一輪循環時可以從lastIndex的位置開始向后匹配,而lastIndex之前的文本將不再被匹配。
下面用文本解析器解析不同的文本看看:
parseText("你好{{name}}") // ""你好 "+_s(name)" parseText("你好Berwin") // undefined parseText("你好{{name}}, 你今年已經{{age}}歲啦") // ""你好"+_s(name)+", 你今年已經"+_s(age)+"歲啦""
從上面代碼的打印結果可以看到,文本已經被正確解析了。
9.5 總結解析器的作用是通過模板得到AST(抽象語法樹)。
生成AST的過程需要借助HTML解析器,當HTML解析器觸發不同的鉤子函數時,我們可以構建出不同的節點。
隨后,我們可以通過棧來得到當前正在構建的節點的父節點,然后將構建出的節點添加到父節點的下面。
最終,當HTML解析器運行完畢后,我們就可以得到一個完整的帶DOM層級關系的AST。
HTML解析器的內部原理是一小段一小段地截取模板字符串,每截取一小段字符串,就會根據截取出來的字符串類型觸發不同的鉤子函數,直到模板字符串截空停止運行。
文本分兩種類型,不帶變量的純文本和帶變量的文本,后者需要使用文本解析器進行二次加工。
更多精彩內容可以觀看《深入淺出Vue.js》關于《深入淺出Vue.js》
本書使用最最容易理解的文筆來描述Vue.js的內部原理,對于想學習Vue.js原理的小伙伴是非常值得入手的一本書。
京東:
https://item.jd.com/12573168....
亞馬遜:
https://www.amazon.cn/gp/prod...
當當:
http://product.dangdang.com/2...
掃碼京東購買
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/103375.html
摘要:而路由則是使用了中新增的事件和事件。總結這一章主要是介紹了如何使用在中構建我們的前端路由。 系列目錄地址 一、基礎知識概覽 第一章 - 一些基礎概念(posted at 2018-10-31) 第二章 - 常見的指令的使用(posted at 2018-11-01) 第三章 - 事件修飾符的使用(posted at 2018-11-02) 第四章 - 頁面元素樣式的設定(posted a...
摘要:接下來要看看這個訂閱者的具體實現了實現訂閱者作為和之間通信的橋梁,主要做的事情是在自身實例化時往屬性訂閱器里面添加自己自身必須有一個方法待屬性變動通知時,能調用自身的方法,并觸發中綁定的回調,則功成身退。 本文能幫你做什么?1、了解vue的雙向數據綁定原理以及核心代碼模塊2、緩解好奇心的同時了解如何實現雙向綁定為了便于說明原理與實現,本文相關代碼主要摘自vue源碼, 并進行了簡化改造,...
摘要:假如你通過閱讀源碼,掌握了對的實現原理,對生態系統有了充分的認識,那你會在面試環節游刃有余,達到晉級阿里的技術功底,從而提高個人競爭力,面試加分更容易拿。 前言 一年一度緊張刺激的高考開始了,與此同時,我也沒閑著,奔走在各大公司的前端面試環節,不斷積累著經驗,一路升級打怪。 最近兩年,太原作為一個準二線城市,各大互聯網公司的技術棧也在升級換代,假如你在太原面試前端崗位,而你的技術庫里若...
閱讀 2029·2021-11-08 13:14
閱讀 2939·2021-10-18 13:34
閱讀 2027·2021-09-23 11:21
閱讀 3589·2019-08-30 15:54
閱讀 1758·2019-08-30 15:54
閱讀 2929·2019-08-29 15:33
閱讀 2578·2019-08-29 14:01
閱讀 1945·2019-08-29 13:52