成人国产在线小视频_日韩寡妇人妻调教在线播放_色成人www永久在线观看_2018国产精品久久_亚洲欧美高清在线30p_亚洲少妇综合一区_黄色在线播放国产_亚洲另类技巧小说校园_国产主播xx日韩_a级毛片在线免费

資訊專欄INFORMATION COLUMN

Vue.js 模板解析器原理 - 來自《深入淺出Vue.js》第九章

pinecone / 3583人閱讀

摘要:模板解析器原理本文來自深入淺出模板編譯原理篇的第九章,主要講述了如何將模板解析成,這一章的內(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í),會(huì)觸發(fā)一個(gè)標(biāo)簽開始的鉤子函數(shù)start;然后解析到

時(shí),又觸發(fā)一次鉤子函數(shù)start;接著解析到我是Berwin這行文本,此時(shí)觸發(fā)了文本鉤子函數(shù)chars;然后解析到

,觸發(fā)了標(biāo)簽結(jié)束的鉤子函數(shù)end;接著繼續(xù)解析到
,此時(shí)又觸發(fā)一次標(biāo)簽結(jié)束的鉤子函數(shù)end,解析結(jié)束。

因此,我們可以在鉤子函數(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、attrsunary,它們分別代表標(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ù)startstart觸發(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í),截取出一段字符串

,并且觸發(fā)鉤子函數(shù)start,截取后的結(jié)果為:

`
  

{{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、attrsunary等數(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(`^]*>`)

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 截取DOCTYPE

DOCTYPE與條件注釋相同,都是不需要觸發(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、startTagOpencommentconditionalComment都是正則表達(dá)式,分別匹配結(jié)束標(biāo)簽、開始標(biāo)簽、注釋和條件注釋。

在Vue.js源碼中,截取文本的邏輯和其他的實(shí)現(xiàn)思路一致。

9.3.8 純文本內(nèi)容元素的處理

什么是純文本內(nèi)容元素呢?script、styletextarea這三種元素叫作純文本內(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]*?)(]*>)", "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ù)charsend。

鉤子函數(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、styletextarea這種純文本內(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

相關(guān)文章

發(fā)表評(píng)論

0條評(píng)論

最新活動(dòng)
閱讀需要支付1元查看
<