《Vue不看原始碼懂原理》系列——Vue模板編譯

龍小胖發表於2020-03-31

我們在Vue中會使用一些變數,表示式,指令來填充模板,但是這些語法在HTML中是不存在的,那麼Vue是如何對這樣的模板進行編譯的呢?

模板編譯

模板編譯的主要作用是將Vue模板編譯為渲染函式,首先將模板解析成AST(抽象語法樹),然後使用AST生成渲染函式。

首先我們要知道Vue每次渲染,都會生成一份新的vNode與舊的vNode進行對比,在生成渲染函式之前還會遍歷一遍AST,為所有的靜態節點做一個編輯,在重新渲染時,不會生成新得節點,而是直接克隆已存在的之前的靜態節點。

所以總體過程是:將模板解析成AST=>遍歷AST標記靜態節點=>使用AST生成渲染函式

在這裡插入圖片描述

模板解析成AST

在這一步驟中,需要經過解析器將模板解析AST,然後還需要經過優化器,遍歷AST找出靜態節點並標記。

解析器

在解析器內部還分成了文字解析器,HTML解析器和過濾器解析器。

其中核心部分是HTML解析器,作用是用來解析字串模板。變數解析器用於解析帶有模板的文字變數,而不帶用變數的文字節點就是剛才所說的靜態節點,不需要解析。過濾器解析器用來解析過濾器。解析結果AST是一種以節點為結構的樹形結構的物件,一個物件表示一個節點,物件的屬性用來儲存節點所需要的資料。

解析模板例如:

<div>
  <p>{{name}}</p>
</div>
複製程式碼

解析成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)"
      }]
    }
  ]
}
複製程式碼

解析器在解析HTML的過程中會不斷觸發各種鉤子函式。這些鉤子函式包括開始標籤鉤子函式、結束標籤鉤子函式、文字鉤子函式以及註釋鉤子函式。

例如:

parseHTML(template, {
    start (tag, attrs, unary) {
        // 每當解析到標籤的開始位置時,觸發該函式
    },
    end () {
        // 每當解析到標籤的結束位置時,觸發該函式
    },
    chars (text) {
        // 每當解析到文字時,觸發該函式
    },
    comment (text) {
        // 每當解析到註釋時,觸發該函式
    }
})
複製程式碼

我們簡單舉一個例子來說明上述方法是如何構建AST節點的:

<div><p>我是一個節點</p></div>
複製程式碼

首先,解析器會將html模板作為一段字串模板從前向後進行解析,解析到<div>時,會觸發一個標籤開始的鉤子函式start();然後解析到<p>時,又觸發一次鉤子函式start();接著解析到我是一個節點這行文字,此時觸發了文字鉤子函式chars();然後解析到</p>,觸發了標籤結束的鉤子函式end();接著繼續解析到</div>,此時又觸發一次標籤結束的鉤子函式end(),解析結束。

start()函式你可以看作為HTML解析函式,他的三個引數分別是分別是tag、attrs和unary,分別代表標籤名、標籤的屬性以及是否是自閉合標籤。

而文字節點的解析函式chars和註釋節點的解析函式comment都只有一個引數text。這是因為構建元素節點需要知道標籤名、屬性和是否是自閉合元素,而構建註釋節點和文字節點時只需要知道文字內容即可。 我們將上面的parseHTML()擴充一下:

//我們模擬一個建立AST元素型別節點的函式
function createASTElement (tag, attrs, parent) {
    // 返回的是一個節點物件
    return {
        type: 1, // 指定節點型別 1.元素節點
        tag, // 指定節點
        attrsList: attrs, // 指定節點屬性
        parent, // 指定是否是自閉合標籤
        children: []
    }
}
parseHTML(template, {
    start (tag, attrs, unary) {
        // 每當解析到標籤的開始位置時,觸發該函式
        // 將標籤名、標籤的屬性以及是否是自閉合標籤傳入
        let element = createASTElement(tag, attrs, currentParent)
    },
    end () {
        // 每當解析到標籤的結束位置時,觸發該函式
    },
    chars (text) {
        // 每當解析到文字時,觸發該函式 
        // 返回的是一個文字節點物件  
        // 文字分兩種型別 2.帶變數的動態文字節點 3.不帶變數的純文字節點
        let element = {type: 3, text}
    },
    comment (text) {
        // 每當解析到註釋時,觸發該函式
        // 返回的是一個註釋節點物件,註釋文字和文字的區別是打上了isComment標記
        let element = {type: 3, text, isComment: true}
    }
})
複製程式碼

但是使用上述方式建立的節點雖然帶有節點物件資訊,但是是扁平的,沒有層級關係,而Vue使用了出入棧的方式來構建一個AST結構物件,為之前的扁平資料實現層級關係。

每次解析HTML,都會使用一個棧來儲存維護,當觸發start()函式時,將當前構建的節點推入棧中;每當觸發鉤子函式end()時,就從棧中彈出上一個節點。舉個例子:

<div>
    <h1>我是h1</h1>
    <p>我是文字</p>
</div>
複製程式碼
  1. 模板的開始位置是div的開始標籤,此時發現棧是空的,這說明div節點是根節點,因為它沒有父節點。最後,將div節點推入棧中,並將模板字串中的div開始標籤從模板中擷取掉
    在這裡插入圖片描述
  2. 鉤子函式裡會忽略空格,同時會在模板中將這些空格擷取掉。接下來發現是h1的開始標籤,於是會觸發鉤子函式start,會先構建一個h1節點。此時發現棧裡存的最近一個節點是div節點,這說明h1節點的父節點是div,於是將h1新增到div的子節點中(也就是children中),並且將h1節點推入棧中,同時從模板中將h1的開始標籤擷取掉。
    \[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-PphkP0lG-1585107481902)(https://s1.ax1x.com/2020/03/25/8XUvYn.jpg)\]
  3. 這時模板的開始位置是一段文字,於是會觸發鉤子函式chars。先構建一個文字節點,此時發現棧中的最後一個節點是h1,這說明文字節點的父節點是h1,於是將文字節點新增到h1節點的子節點中。由於文字節點沒有子節點,所以文字節點不會被推入棧中。最後,將文字從模板中擷取掉。
    在這裡插入圖片描述
  4. 這時模板的開始位置是h1結束標籤,於是會觸發鉤子函式end。end觸發後,會把棧中最後一個節點(也就是h1)彈出來。
    在這裡插入圖片描述
  5. 第2個標籤是p標籤和h1標籤同理,會先構建一個p節點,由於第4步已經從棧中彈出了一個節點h1,所以此時棧中的最近一個節點是div,於是將p推入div的子節點中,最後將p推入到棧中,從模板中擷取掉。然後會一樣構建文字節點,擷取,最後根據p結束標籤觸發鉤子函式end,把p節點彈出來。
    在這裡插入圖片描述
  6. 最後開始位置是div的結束標籤,於是會觸發鉤子函式end。其邏輯與之前一樣,把棧中的最後一個節點div彈出來,並將div的結束標籤從模板中擷取掉。HTML解析器已經執行完畢,這時我們會發現棧已經空了,而我們得到了一個完整的帶層級關係的AST語法樹
    \[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-qOVcHowI-1585107971471)(https://s1.ax1x.com/2020/03/25/8Xw1JA.jpg)\]

其中對開始標籤,結束標籤,還有標籤屬性的解析基本是使用了大量正規表示式:去解析<div </div> : class=這樣的字串,去判定這是一個什麼標籤該去觸發什麼函式,不做過多描述。

這個過程如何解析HTML中的註釋,條件註釋,DOCTYPE,文字?

HTML中的註釋,判斷<!--,通過indexOf找到註釋結束位置-->的下標,然後將結束位置前的字元都擷取掉。條件註釋註釋用提前的表示式判斷<,條件註釋會被直接擷取掉。DOCTYPE直接匹配這段字元,根據它的length屬性來決定要擷取多長的字串。文字我們只需要找到>與下一個<在什麼位置,這之前的所有字元都屬於文字。

節點不完整?

<div><p></div>
複製程式碼

在上面的程式碼中,p標籤沒有結束標籤,那麼當HTML解析器解析到div的結束標籤時,發現棧內元素卻是p標籤。就會從棧頂向棧底遍歷尋找到div標籤,在找到div標籤之前遇到的所有其他標籤都會標記為忘記閉合的標籤,在非生產環境下在控制檯列印警告提示。

文字解析器

為什麼文字解析器要單獨說,因為文字其實分兩種型別,一種是純文字,另一種是帶變數的文字。

	Hello name
	Hello {{name}}
複製程式碼

如果是純文字,不需要進行任何處理;但如果是帶變數的文字,那麼需要使用文字解析器進一步解析。因為帶變數的文字在使用虛擬DOM進行渲染時,需要將變數替換成變數中的值。

  1. 第一步要做的事情就是使用正規表示式來判斷文字是否是帶變數的文字,也就是檢查文字中是否包含{{xxx}}這樣的語法。
  2. 我們建立一個陣列,把變數左邊的文字新增到陣列中,然後把變數改成_s(變數名)這樣的函式形式也新增到陣列中。如果變數後面還有變數,則重複以上動作。
  3. 陣列元素的順序和文字的順序是一致的,此時將這些陣列元素用+連起來變成字串(_s(變數名)是Vue中對應的解析變數函式,會返回該變數的值)

優化器

靜態節點:

<p>我就是一個純文字的靜態節點</p>
複製程式碼

優化器則是將解析完的AST進行遍歷,找出靜態節點並標記,在下次更新對比虛擬DOM的vNode時,如果發現這兩個節點是靜態節點,則直接跳過更新節點的流程。達到進一步避免一些無用的DOM操作來提升效能,因為靜態節點在首次渲染後一定不會改變。

AST生成渲染函式

程式碼生成器

程式碼生成器是將解析完的AST轉化為渲染函式需要的內容,這個內容叫程式碼字串,例如:

<div>
  <p>{{name}}</p>
</div>
複製程式碼
// 解析為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生成程式碼字串
`with(this) {return _c('div', [_c('p', [_v(_s(name))]), _v(" "), _m(0)])}`
複製程式碼

之後將這串程式碼字串傳到Vue的渲染函式中,渲染函式根據引數結構,呼叫相關的建立vNode的方法(生成後的程式碼字串中看到了有幾個函式呼叫 _c,_v,_s,這是Vue內部的一些渲染函式,_c可以建立元素型別的vNode,_v可以建立文字型別的vNode,_e可以建立註釋型別的vNode)最後組成一份虛擬DOM結構。

我們拿_c來解釋一下這個字串的結構:

在這裡插入圖片描述
將其分解來看,拿建立元素型別的函式_c()來說,圖中1和3是第一個引數:HTML標籤名,圖中2和4是第三個引數:children,這個函式存在第二個可選項引數:元素上使用的屬性所對應的資料物件,例如:

<p title="biaoti">name</p>
複製程式碼
with(this){
  return _c(
    'p', // 標籤名
    {
      attrs:{"title":"biaoti"},
    }, // 屬性
    [_v("name")] // 子節點
  )
}
複製程式碼

程式碼生成器的總體邏輯其實就是遞迴ATS,然後根據ATS結構拼出這樣的_c('div',[_c('p',[_v(_s(name))])]) 字串,再將其傳入渲染函式執行。

至於具體的AST轉換過程就不做深入解釋,會令文章顯得枯燥。

總結

我們以上簡單講述了Vue對模板編譯的整體流程:解析器(模板字串轉換成AST),優化器(標記靜態節點)和程式碼生成器(將AST裝換成帶結構的程式碼字串)。

解析器通過使用一個棧來維護節點,每從模板字串中擷取一個節點字串,就將其推入棧中,同時構建一個AST節點,一直到結束節點在將其推出棧,如此迴圈最後構建出一套帶有結構的AST物件。

優化器是通過遍歷AST節點,對其中的靜態節點做標記,同時最後標記處根靜態節點,節省部分不必要的效能消耗。

程式碼生成器也是通過遍歷去拼出一個渲染函式執行的程式碼字串,遍歷的過程根據不同的節點型別type呼叫不同的生成字串方法,最後拼出一個完整的 render 函式需要的程式碼字串。

後續還有兩篇:

《Vue不看原始碼懂原理》系列——Vue的diff演算法不難懂(直接傳送)

《Vue不看原始碼懂原理》系列——Vue的例項函式和指令解密(下週)

之有一篇用心總結的《Javascript垃圾回收原理》沒太有響應,我覺得大家可以看一看,耐心一下的話比較好理解。

點個贊,我加油

點關注,不迷路,哈哈哈

相關文章