本文來自《深刻淺出Vue.js》模板編譯原理篇的第九章,主要講述瞭如何將模板解析成AST,這一章的內容是全書最複雜且燒腦的章節。本文排版較爲緊湊和圖片是未經加工的原稿,真實紙質書的排版和圖片會更加精緻。javascript
經過第8章的學習,咱們知道解析器在整個模板編譯中的位置。咱們只有將模板解析成AST後,才能基於AST作優化或者生成代碼字符串,那麼解析器是如何將模板解析成AST的呢?css
本章中,咱們將詳細介紹解析器內部的運行原理。html
解析器要實現的功能是將模板解析成AST。java
例如:正則表達式
<div>
<p>{{name}}</p>
</div>
複製代碼
上面的代碼是一個比較簡單的模板,它轉換成AST後的樣子以下:express
{
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。數組
事實上,解析器內部也分了好幾個子解析器,好比HTML解析器、文本解析器以及過濾器解析器,其中最主要的是HTML解析器。顧名思義,HTML解析器的做用是解析HTML,它在解析HTML的過程當中會不斷觸發各類鉤子函數。這些鉤子函數包括開始標籤鉤子函數、結束標籤鉤子函數、文本鉤子函數以及註釋鉤子函數。數據結構
僞代碼以下:函數
parseHTML(template, {
start (tag, attrs, unary) {
// 每當解析到標籤的開始位置時,觸發該函數
},
end () {
// 每當解析到標籤的結束位置時,觸發該函數
},
chars (text) {
// 每當解析到文本時,觸發該函數
},
comment (text) {
// 每當解析到註釋時,觸發該函數
}
})
複製代碼
你可能不能很清晰地理解,下面咱們舉個簡單的例子:學習
<div><p>我是Berwin</p></div>
複製代碼
當上面這個模板被HTML解析器解析時,所觸發的鉤子函數依次是:start
、start
、chars
、end
、end
。
也就是說,解析器實際上是從前向後解析的。解析到<div>
時,會觸發一個標籤開始的鉤子函數start
;而後解析到<p>
時,又觸發一次鉤子函數start
;接着解析到我是Berwin
這行文本,此時觸發了文本鉤子函數chars
;而後解析到</p>
,觸發了標籤結束的鉤子函數end
;接着繼續解析到</div>
,此時又觸發一次標籤結束的鉤子函數end
,解析結束。
所以,咱們能夠在鉤子函數中構建AST節點。在start
鉤子函數中構建元素類型的節點,在chars
鉤子函數中構建文本類型的節點,在comment
鉤子函數中構建註釋類型的節點。
當HTML解析器再也不觸發鉤子函數時,就表明全部模板都解析完畢,全部類型的節點都在鉤子函數中構建完成,即AST構建完成。
咱們發現,鉤子函數start
有三個參數,分別是tag
、attrs
和unary
,它們分別表明標籤名、標籤的屬性以及是不是自閉合標籤。
而文本節點的鉤子函數chars
和註釋節點的鉤子函數comment
都只有一個參數,只有text
。這是由於構建元素節點時須要知道標籤名、屬性和自閉合標識,而構建註釋節點和文本節點時只須要知道文本便可。
什麼是自閉合標籤?舉個簡單的例子,input
標籤就屬於自閉合標籤:<input type="text" />
,而div
標籤就不屬於自閉合標籤:<div></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所示。
下面咱們用一個具體的例子來描述如何從0到1構建一個帶層級關係的AST。
假設有這樣一個模板:
<div>
<h1>我是Berwin</h1>
<p>我今年23歲</p>
</div>
複製代碼
上面這個模板被解析成AST的過程如圖9-2所示。
圖9-2構建AST的過程(下面的(1)~(12)須要改爲圖中那樣黑底白字的)
圖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中清晰寫明瞭每一個節點的父節點、子節點及其節點類型。
經過前面的介紹,咱們發現構建AST很是依賴HTML解析器所執行的鉤子函數以及鉤子函數中所提供的參數,你必定會很是好奇HTML解析器是如何解析模板的,接下來咱們會詳細介紹HTML解析器的運行原理。
事實上,解析HTML模板的過程就是循環的過程,簡單來講就是用HTML模板字符串來循環,每輪循環都從HTML模板中截取一小段字符串,而後重複以上過程,直到HTML模板被截成一個空字符串時結束循環,解析完畢,如圖9-2所示。
在截取一小段字符串時,有可能截取到開始標籤,也有可能截取到結束標籤,又或者是文本或者註釋,咱們能夠根據截取的字符串的類型來觸發不一樣的鉤子函數。
循環HTML模板的僞代碼以下:
function parseHTML(html, options) {
while (html) {
// 截取模板字符串並觸發鉤子函數
}
}
複製代碼
爲了方便理解,咱們手動模擬HTML解析器的解析過程。例如,下面這樣一個簡單的HTML模板:
<div>
<p>{{name}}</p>
</div>
複製代碼
它在被HTML解析器解析的過程以下。
最初的HTML模板:
`<div> <p>{{name}}</p> </div>`
複製代碼
第一輪循環時,截取出一段字符串<div>
,而且觸發鉤子函數start
,截取後的結果爲:
` <p>{{name}}</p> </div>`
複製代碼
第二輪循環時,截取出一段字符串:
` `
複製代碼
而且觸發鉤子函數chars
,截取後的結果爲:
`<p>{{name}}</p> </div>`
複製代碼
第三輪循環時,截取出一段字符串<p>
,而且觸發鉤子函數start
,截取後的結果爲:
`{{name}}</p> </div>`
複製代碼
第四輪循環時,截取出一段字符串{{name}}
,而且觸發鉤子函數chars
,截取後的結果爲:
`</p> </div>`
複製代碼
第五輪循環時,截取出一段字符串</p>
,而且觸發鉤子函數end
,截取後的結果爲:
` </div>`
複製代碼
第六輪循環時,截取出一段字符串:
` `
複製代碼
而且觸發鉤子函數chars
,截取後的結果爲:
`</div>`
複製代碼
第七輪循環時,截取出一段字符串</div>
,而且觸發鉤子函數end
,截取後的結果爲:
``
複製代碼
解析完畢。
HTML解析器的所有邏輯都是在循環中執行,循環結束就表明解析結束。接下來,咱們要討論的重點是HTML解析器在循環中都幹了些什麼事。
你會發現HTML解析器能夠很聰明地知道它在每一輪循環中應該截取哪些字符串,那麼它是如何作到這一點的呢?
經過前面的例子,咱們發現一個頗有趣的事,那就是每一輪截取字符串時,都是在整個模板的開始位置截取。咱們根據模板開始位置的片斷類型,進行不一樣的截取操做。
例如,上面例子中的第一輪循環:若是是以開始標籤開頭的模板,就把開始標籤截取掉。
再例如,上面例子中的第四輪循環:若是是以文本開始的模板,就把文本截取掉。
這些被截取的片斷分不少種類型,示例以下。
<div>
。</div>
。<!-- 我是註釋 -->
。<!DOCTYPE html>
。<!--[if !IE]>-->我是註釋<!--<![endif]-->
。我是Berwin
。一般,最多見的是開始標籤、結束標籤、文本以及註釋。
上一節中咱們說過,每一輪循環都是從模板的最前面截取,因此只有模板以開始標籤開頭,才須要進行開始標籤的截取操做。
那麼,如何肯定模板是否是以開始標籤開頭?
在HTML解析器中,想分辨出模板是否以開始標籤開頭並不難,咱們須要先判斷HTML模板是否是以<
開頭。
若是HTML模板的第一個字符不是<
,那麼它必定不是以開始標籤開頭的模板,因此不須要進行開始標籤的截取操做。
若是HTML模板以<
開頭,那麼說明它至少是一個以標籤開頭的模板,但這個標籤究竟是什麼類型的標籤,還須要進一步確認。
若是模板以<
開頭,那麼它有多是以開始標籤開頭的模板,同時它也有多是以結束標籤開頭的模板,還有多是註釋等其餘標籤,由於這些類型的片斷都以<
開頭。那麼,要進一步肯定模板是否是以開始標籤開頭,還須要藉助正則表達式來分辨模板的開始位置是否符合開始標籤的特徵。
那麼,如何使用正則表達式來匹配模板以開始標籤開頭?咱們看下面的代碼:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 以開始標籤開始的模板
'<div></div>'.match(startTagOpen) // ["<div", "div", index: 0, input: "<div></div>"]
// 以結束標籤開始的模板
'</div><div>我是Berwin</div>'.match(startTagOpen) // null
// 以文本開始的模板
'我是Berwin</p>'.match(startTagOpen) // null
複製代碼
經過上面的例子能夠看到,只有'<div></div>'
能夠成功匹配,而以</div>
開頭的或者以文本開頭的模板都沒法成功匹配。
在9.2節中,咱們介紹了當HTML解析器解析到標籤開始時,會觸發鉤子函數start
,同時會給出三個參數,分別是標籤名(tagName
)、屬性(attrs
)以及自閉合標識(unary
)。
所以,在分辨出模板以開始標籤開始以後,須要將標籤名、屬性以及自閉合標識解析出來。
在分辨模板是否以開始標籤開始時,就能夠獲得標籤名,而屬性和自閉合標識則須要進一步解析。
當完成上面的解析後,咱們能夠獲得這樣一個數據結構:
const start = '<div></div>'.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}`)
'<div></div>'.match(startTagOpen)
// ["<div", "div", index: 0, input: "<div></div>"]
'<p></p>'.match(startTagOpen)
// ["<p", "p", index: 0, input: "<p></p>"]
'<div class="box"></div>'.match(startTagOpen)
// ["<div", "div", index: 0, input: "<div class="box"></div>"]
複製代碼
能夠看出,上面這個正則表達式雖然能夠分辨出模板是否以開始標籤開頭,可是它的匹配規則並非匹配整個開始標籤,而是開始標籤的一小部分。
事實上,開始標籤被拆分紅三個小部分,分別是標籤名、屬性和結尾,如圖9-3所示。
圖9-3 開始標籤被拆分紅三個小部分( 代碼用代碼體)經過「標籤名」這一段字符,就能夠分辨出模板是否以開始標籤開頭,此後要想獲得屬性和自閉合標識,則須要進一步解析。
在分辨模板是否以開始標籤開頭時,會將開始標籤中的標籤名這一小部分截取掉,所以在解析標籤屬性時,咱們獲得的模板是下面僞代碼中的樣子:
' class="box"></div>'
複製代碼
一般,標籤屬性是可選的,一個標籤的屬性有可能存在,也有可能不存在,因此須要判斷標籤是否存在屬性,若是存在,對它進行截取。
下面的僞代碼展現瞭如何解析開始標籤中的屬性,可是它只能解析一個屬性:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box"></div>'
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"></div>']
複製代碼
若是標籤上有不少屬性,那麼上面的處理方式就不足以支撐解析任務的正常運行。例以下面的代碼:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box" id="el"></div>'
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"></div>']
複製代碼
能夠看到,這裏只解析出了class
屬性,而id
屬性沒有解析出來。
此時剩餘的HTML模板是這樣的:
' id="el"></div>'
複製代碼
因此屬性也能夠分紅多個小部分,一小部分一小部分去解析與截取。
解決這個問題時,咱們只須要每解析一個屬性就截取一個屬性。若是截取完後,剩下的HTML模板依然符合標籤屬性的正則表達式,那麼說明還有剩餘的屬性須要處理,此時就重複執行前面的流程,直到剩餘的模板不存在屬性,也就是剩餘的模板不存在符合正則表達式所預設的規則。
例如:
const startTagClose = /^\s*(\/?)>/
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box" id="el"></div>'
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
中。
此時剩餘模板是下面的樣子:
"></div>"
複製代碼
咱們將屬性解析後的模板與解析以前的模板進行對比:
// 解析前的模板
' class="box" id="el"></div>'
// 解析後的模板
'></div>'
// 解析前的數據
{
tagName: 'div',
attrs: []
}
// 解析後的數據
{
tagName: 'div',
attrs: [
[' class="box"', 'class', '=', 'box', null, null],
[' id="el"', 'id','=', 'el', null, null]
]
}
複製代碼
能夠看到,標籤上的全部屬性都已經被成功解析出來,並保存在attrs
屬性中。
若是咱們接着上面的例子繼續解析的話,目前剩餘的模板是下面這樣的:
'></div>'
複製代碼
開始標籤中結尾部分解析的主要目的是解析出當前這個標籤是不是自閉合標籤。
舉個例子:
<div></div>
複製代碼
這樣的div
標籤就不是自閉合標籤,而下面這樣的input
標籤就屬於自閉合標籤:
<input type="text" />
複製代碼
自閉合標籤是沒有子節點的,因此前文中咱們提到構建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('></div>')) // {unarySlash: ""}
console.log(parseStartTagEnd('/><div></div>')) // {unarySlash: "/"}
複製代碼
這段代碼能夠正確解析出開始標籤是不是自閉合標籤。
從代碼中打印出來的結果能夠看到,自閉合標籤解析後的unarySlash
屬性爲/
,而非自閉合標籤爲空字符串。
前面解析開始標籤時,咱們將其拆解成了三個部分,分別是標籤名、屬性和結尾。我相信你已經對開始標籤的解析有了一個清晰的認識,接下來看一下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
等數據取出來,而後調用鉤子函數將這些數據放到參數中。
結束標籤的截取要比開始標籤簡單得多,由於它不須要解析什麼,只須要分辨出當前是否已經截取到結束標籤,若是是,那麼觸發鉤子函數就能夠了。
那麼,如何分辨模板已經截取到結束標籤了呢?其道理其實和開始標籤的截取相同。
若是HTML模板的第一個字符不是<
,那麼必定不是結束標籤。只有HTML模板的第一個字符是<
時,咱們才須要進一步確認它究竟是不是結束標籤。
進一步確認時,咱們只須要判斷剩餘HTML模板的開始位置是否符合正則表達式中定義的規則便可:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = '</div>'.match(endTag)
const endTagMatch2 = '<div>'.match(endTag)
console.log(endTagMatch) // ["</div>", "div", index: 0, input: "</div>"]
console.log(endTagMatch2) // null
複製代碼
上面代碼能夠分辨出剩餘模板是不是結束標籤。當分辨出結束標籤後,須要作兩件事,一件事是截取模板,另外一件事是觸發鉤子函數。而Vue.js中相關源碼被精簡後以下:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
html = html.substring(endTagMatch[0].length)
options.end(endTagMatch[1])
continue
}
複製代碼
能夠看出,先對模板進行截取,而後觸發鉤子函數。
分辨模板是否已經截取到註釋的原理與開始標籤和結束標籤相同,先判斷剩餘HTML模板的第一個字符是否是<
,若是是,再用正則表達式來進一步匹配:
const comment = /^<!--/
if (comment.test(html)) {
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
if (options.shouldKeepComment) {
options.comment(html.substring(4, commentEnd))
}
html = html.substring(commentEnd + 3)
continue
}
}
複製代碼
在上面的代碼中,咱們使用正則表達式來判斷剩餘的模板是否符合註釋的規則,若是符合,就將這段註釋文本截取出來。
這裏有一個有意思的地方,那就是註釋的鉤子函數能夠經過選項來配置,只有options.shouldKeepComment
爲真時,纔會觸發鉤子函數,不然只截取模板,不觸發鉤子函數。
條件註釋不須要觸發鉤子函數,咱們只須要把它截取掉就好了。
截取條件註釋的原理與截取註釋很是類似,若是模板的第一個字符是<
,而且符合咱們事先用正則表達式定義好的規則,就說明須要進行條件註釋的截取操做。
在下面的代碼中,咱們經過indexOf
找到條件註釋結束位置的下標,而後將結束位置前的字符都截取掉:
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
html = html.substring(conditionalEnd + 2)
continue
}
}
複製代碼
咱們來舉個例子:
const conditionalComment = /^<!\[/
let html = '<![if !IE]><link href="non-ie.css" rel="stylesheet"><![endif]>'
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
html = html.substring(conditionalEnd + 2)
}
}
console.log(html) // '<link href="non-ie.css" rel="stylesheet"><![endif]>'
複製代碼
從打印結果中能夠看到,HTML中的條件註釋部分截取掉了。
經過這個邏輯能夠發現,在Vue.js中條件註釋其實沒有用,寫了也會被截取掉,通俗一點說就是寫了也白寫。
DOCTYPE
DOCTYPE
與條件註釋相同,都是不須要觸發鉤子函數的,只須要將匹配到的這一段字符截取掉便可。下面的代碼將DOCTYPE
這段字符匹配出來後,根據它的length
屬性來決定要截取多長的字符串:
const doctype = /^<!DOCTYPE [^>]+>/i
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
html = html.substring(doctypeMatch[0].length)
continue
}
複製代碼
示例以下:
const doctype = /^<!DOCTYPE [^>]+>/i
let html = '<!DOCTYPE html><html lang="en"><head></head><body></body></html>'
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
html = html.substring(doctypeMatch[0].length)
}
console.log(html) // '<html lang="en"><head></head><body></body></html>'
複製代碼
從打印結果能夠看到,HTML中的DOCTYPE
被成功截取掉了。
若想分辨在本輪循環中HTML模板是否已經截取到文本,其實很簡單,咱們甚至不須要使用正則表達式。
在前面的其餘標籤類型中,咱們都會判斷剩餘HTML模板的第一個字符是不是<
,若是是,再進一步確認究竟是哪一種類型。這是由於以<
開頭的標籤類型太多了,如開始標籤、結束標籤和註釋等。然而文本只有一種,若是HTML模板的第一個字符不是<
,那麼它必定是文本了。
例如:
我是文本</div>
複製代碼
上面這段HTML模板並非以<
開頭的,因此能夠判定它是以文本開頭的。
那麼,如何從模板中將文本解析出來呢?咱們只須要找到下一個<
在什麼位置,這以前的全部字符都屬於文本,如圖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</div>
複製代碼
在上面這樣的模板中,若是隻截取第一個<
前面的字符,最後被截取出來的將只有1,而不能把全部文本都截取出來。
那麼,該如何解決這個問題呢?
有一個思路是,若是將<
前面的字符截取完以後,剩餘的模板不符合任何須要被解析的片斷的類型,就說明這個<
是文本的一部分。
什麼是須要被解析的片斷的類型?在9.3.1節中,咱們說過HTML解析器是一段一段截取模板的,而被截取的每一段都符合某種類型,這些類型包括開始標籤、結束標籤和註釋等。
說的再具體一點,那就是上面這段代碼中的1被截取完以後,剩餘模板是下面的樣子:
<2</div>
複製代碼
<2
符合開始標籤的特徵麼?不符合。
<2
符合結束標籤的特徵麼?不符合。
<2
符合註釋的特徵麼?不符合。
當剩餘的模板什麼都不符合時,就說明<
屬於文本的一部分。
當判斷出<
是屬於文本的一部分後,咱們須要作的事情是找到下一個<
並將其前面的文本截取出來加到前面截取了一半的文本後面。
這裏還用上面的例子,第二個<
以前的字符是<2
,那麼把<2
截取出來後,追加到上一次截取出來的1
的後面,此時的結果是:
1<2 複製代碼
截取後剩餘的模板是:
</div>
複製代碼
若是剩餘的模板依然不符合任何被解析的類型,那麼重複此過程。直到全部文本都解析完。
說完了思路,咱們看一下具體的實現,僞代碼以下:
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源碼中,截取文本的邏輯和其餘的實現思路一致。
什麼是純文本內容元素呢?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('([\\s\\S]*?)(</' + 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
並將標籤名放到參數中傳出去,表明本輪循環中的全部邏輯都已處理完畢。
假如咱們如今有這樣一個模板:
<div id="el">
<script>console.log(1)</script>
</div>
複製代碼
當解析到script
中的內容時,模板是下面的樣子:
console.log(1)</script>
</div>
複製代碼
此時父元素爲script
,因此會進入到else
中的邏輯進行處理。在其處理過程當中,會觸發鉤子函數chars
和end
。
鉤子函數chars
的參數爲script
中的全部內容,本例中大概是下面的樣子:
chars('console.log(1)')
複製代碼
鉤子函數end
的參數爲標籤名,本例中是script
。
處理後的剩餘模板以下:
</div>
複製代碼
經過前面幾節的介紹,特別是9.3.8節中的介紹,你必定會感到很奇怪,如何知道父元素是誰?
在前面幾節中,咱們並無介紹HTML解析器內部其實也有一個棧來維護DOM層級關係,其邏輯與9.2.1節相同:就是每解析到開始標籤,就向棧中推動去一個;每解析到標籤結束,就彈出來一個。所以,想取到父元素並不難,只須要拿到棧中的最後一項便可。
同時,HTML解析器中的棧還有另外一個做用,它能夠檢測出HTML標籤是否正確閉合。例如:
<div><p></div>
複製代碼
在上面的代碼中,p
標籤忘記寫結束標籤,那麼當HTML解析器解析到div
的結束標籤時,棧頂的元素倒是p
標籤。這個時候從棧頂向棧底循環找到div
標籤,在找到div
標籤以前遇到的全部其餘標籤都是忘記了閉合的標籤,而Vue.js會在非生產環境下在控制檯打印警告提示。
關於使用棧來維護DOM層級關係的具體實現思路,9.2.1節已經詳細介紹過,這裏再也不重複介紹。
前面咱們把開始標籤、結束標籤、註釋、文本、純文本內容元素等的截取方式拆分開,單獨進行了詳細介紹。本節中,咱們就來介紹如何將這些解析方式組裝起來完成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的處理邏輯
}
}
}
複製代碼
關於不一樣類型的具體處理方式,前面已經詳細介紹過,這裏再也不重複。
文本解析器的做用是解析文本。你可能會以爲很奇怪,文本不是在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 = /\{\{((?:.|\n)+?)\}\}/g
if (!tagRE(text)) {
return
}
}
複製代碼
在上面的代碼中,若是是純文本,則直接返回。若是是帶變量的文本,該如何處理呢?
一個解決思路是使用正則表達式匹配出文本中的變量,先把變量左邊的文本添加到數組中,而後把變量改爲_s(x)
這樣的形式也添加到數組中。若是變量後面還有變量,則重複以上動做,直到全部變量都添加到數組中。若是最後一個變量的後面有文本,就將它添加到數組中。
這時咱們其實已經有一個數組,數組元素的順序和文本的順序是一致的,此時將這些數組元素用+
連起來變成字符串,就能夠獲得最終想要的效果,如圖9-5所示。
在圖9-5中,最上面的字符串表明即將解析的文本,中間兩個方塊表明數組中的兩個元素。最後,使用數組方法join
將這兩個元素合併成一個字符串。
具體實現代碼以下:
function parseText (text) {
const tagRE = /\{\{((?:.|\n)+?)\}\}/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)+"歲啦"'
複製代碼
從上面代碼的打印結果能夠看到,文本已經被正確解析了。
解析器的做用是經過模板獲得AST(抽象語法樹)。
生成AST的過程須要藉助HTML解析器,當HTML解析器觸發不一樣的鉤子函數時,咱們能夠構建出不一樣的節點。
隨後,咱們能夠經過棧來獲得當前正在構建的節點的父節點,而後將構建出的節點添加到父節點的下面。
最終,當HTML解析器運行完畢後,咱們就能夠獲得一個完整的帶DOM層級關係的AST。
HTML解析器的內部原理是一小段一小段地截取模板字符串,每截取一小段字符串,就會根據截取出來的字符串類型觸發不一樣的鉤子函數,直到模板字符串截空中止運行。
文本分兩種類型,不帶變量的純文本和帶變量的文本,後者須要使用文本解析器進行二次加工。
更多精彩內容能夠觀看《深刻淺出Vue.js》
本書使用最最容易理解的文筆來描述Vue.js的內部原理,對於想學習Vue.js原理的小夥伴是很是值得入手的一本書。
掃碼京東購買