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