首頁 > 軟體

vue中template模板編譯的過程全面剖析

2022-04-15 16:00:21

簡述過程

vue template模板編譯的過程經過parse()生成ast(抽象語法樹),optimize對靜態節點優化,generate()生成render字串

之後呼叫new Watcher()函數,用來監聽資料的變化,render 函數就是資料監聽的回撥所呼叫的,其結果便是重新生成 vnode。

當這個 render 函數位符串在第一次 mount、或者繫結的資料更新的時候,都會被呼叫,生成 Vnode。

如果是資料的更新,那麼 Vnode 會與資料改變之前的 Vnode 做 diff,對內容做改動之後,就會更新到 我們真正的 DOM

vue的渲染過程

parse

在瞭解 parse 的過程之前,我們需要了解 AST,AST 的全稱是 Abstract Syntax Tree,也就是所謂抽象語法樹,用來表示程式碼的資料結構。

在Vue中我把它理解為巢狀的、攜帶標籤名、屬性和父子關係的 JS 物件,以樹來表現 DOM 結構。

vue中的ast型別有以下3種

ASTElement = {  // AST標籤元素
  type: 1;
  tag: string;
  attrsList: Array<{ name: string; value: any }>;
  attrsMap: { [key: string]: any };
  parent: ASTElement | void;
  children: Array<ASTNode>
  
  ...
}
ASTExpression = { // AST表示式 {{ }}
  type: 2;
  expression: string;
  text: string;
  tokens: Array<string | Object>;
  static?: boolean;
};
ASTText = {  // AST文字
  type: 3;
  text: string;
  static?: boolean;
  isComment?: boolean;
};

通過children欄位來形成一種層層巢狀的樹狀結構。vue中定義了許多正則(判斷標籤開始、結束、屬性、vue指令、文字),通過對html內容進行遞迴正則匹配,對滿足條件的字串進行擷取。把字串型別的html轉換位AST結構

parse函數的作用就是把字串型的template轉化為AST結構

如,假設我們有一個元素

texttext,在 parse 完之後會變成如下的結構並返回:

  ele1 = {
    type: 1,
    tag: "div",
    attrsList: [{name: "id", value: "test"}],
    attrsMap: {id: "test"},
    parent: undefined,
    children: [{
        type: 3,
        text: 'texttext'
      }
    ],
    plain: true,
    attrs: [{name: "id", value: "'test'"}]
  }

那麼它具體是怎麼解析、擷取的呢?

舉個例子

<div>
    <p>我是{{name}}</p>
</div>

他的擷取過程,主要如下

// 初始
<div>
    <p>我是{{name}}</p>
</div>
// 第一次擷取剩餘(包括空格)
    <p>我是{{name}}</p>
</div>
// 第二次擷取
<p>我是{{name}}</p>
</div>
// 第三次擷取
我是{{name}}</p>
</div>
// 第四次擷取
</p>
</div>
//
            
</div>
//
</div>

那麼,他的擷取規則是什麼呢?

vue中擷取規則主要是通過判斷模板中html.indexof(’<’)的值,來確定我們是要擷取標籤還是文字.

  • 等於 0:這就代表這是註釋、條件註釋、doctype、開始標籤、結束標籤中的某一種
  • 大於等於 0:這就說明是文字、表示式
  • 小於 0:表示 html 標籤解析完了,可能會剩下一些文字、表示式

若等於0

若等於0,則進行正則匹配看是否為開始標籤、結束標籤、註釋、條件註釋、doctype中的一種。若是開始標籤,則擷取對應的開始標籤,並定義ast的基本結構,並且解析標籤上帶的屬性(attrs, tagName)、指令等等。
當然,這裡的attrs也是通過正則匹配出來的,具體做法就是通過匹配標籤上對應的屬性,然後把他push到attrs裡。

匹配時候的正規表示式如下。

const attribute = /^s*([^s"'<>/=]+)(?:s*(=)s*(?:"([^"]*)"+|'([^']*)'+|([^s"'=<>`]+)))?/
const ncname = '[a-zA-Z_][\w\-\.]*'
const qnameCapture = `((?:${ncname}\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^s*(/?)>/
const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!--/
const conditionalComment = /^<![/
  • 同時,需要注意的一點是,vue中還需要維護一個stack(可以理解為一個陣列),用來標記DOM的深度

關於stack

stack裡的最後一項,永遠是當前正在解析的元素的parentNode。

通過stack解析器會把當前解析的元素和stack裡的最後一個元素建立父子關係。即把當前節點push到stack的最後一個節點的children裡,同時將它自身的parent設為stack的最後一個節點。

當然,因為我們的標籤中存在一種自閉和的標籤(如input),這種型別的標籤沒有子元素,所以不會push到stack中。

  • 若是結束標籤,則需要通過這個結束標籤的tagName從後到前匹配stack中每一項的tagName,將匹配到的那一項之後的所有項全部刪除,表示這一段已經解析完成。
  • 若不是以上5種中的一種,則表示他是文字

等於0或大於0

若等於0且不滿足以上五種條件或大於0,則表示它是文字或表示式。

  • 此時,它會判斷它的剩餘部分是否符合標籤的格式,
  • 如果不符合,則繼續再剩餘部分判斷’<'的位置,並繼續1的判斷,直到剩餘部分有符合標籤的格式出現。
let textEnd = html.indexOf('<')
let text, rest, next
if (textEnd >= 0) {
  rest = html.slice(textEnd)
  // 剩餘部分的 HTML 不符合標籤的格式那肯定就是文字
  // 並且還是以 < 開頭的文字
  while (
    !endTag.test(rest) &&
    !startTagOpen.test(rest) &&
    !comment.test(rest) &&
    !conditionalComment.test(rest)
  ) {
    // < in plain text, be forgiving and treat it as text
    next = rest.indexOf('<', 1)
    if (next < 0) break
    textEnd += next
    rest = html.slice(textEnd)
  }
  text = html.substring(0, textEnd)
  html = html.substring(0, textEnd)
}

關於文字的擷取

文字一般分為2種

  • 實打實</div>
  • 我是{{name}}</div>

如果文字中含有表示式,則需要對文字中的變數進行解析

const expression = parseText(text, delimiters) // 對變數解析 {{name}} => _s(name)
children.push({
  type: 2,
  expression,
  text
})
// 上例中解析過後形成如下的結構
{
  expression: "_s(name)",
  text: "我是{{name}}",
  type: 2
}

現在我們再來看最開始的例子

<div>
    <p>我是{{name}}</p>
</div>

1.首先第一次判斷<的位置,等於0,且可以匹配上開始標籤,則擷取這個標籤。

// 第一次擷取後剩餘
    <p>我是{{name}}</p>
</div>

2.繼續判斷<的位置,大於0(因為有空格),判斷為文字,擷取這個文字

// 第二次擷取後剩餘
<p>我是{{name}}</p>
</div>

3.繼續判斷<位置,等於0,且為開始標籤,擷取這一部分,並且維護stack,把當前的解析的元素的parnet置為stack中的最後一項,並且在stack的最後一項的children裡push當前解析的元素

// 這裡有個判斷,因為非自閉和標籤才會有children,所以非自閉標籤才往stack裡push
if (!unary) {
  currentParent = element
  stack.push(element)
}
// 設立父子關係
currentParent.children.push(element)
element.parent = currentParent
// 此時stack
[divAst,pAst]
//  第三次擷取後剩餘
我是{{name}}</p>
</div>

4.繼續判斷<的位置,大於0,判斷剩餘部分是否屬於標籤的一種,這裡剩餘部分可以匹配結束標籤,則表明為文字

// 第四次擷取後剩餘
</p>
</div>

5.繼續判斷<的位置,等於0,且匹配為結束標籤,此時會再stack裡尋找滿足tagName和當前標籤名相同的最後一項,把它之後項的全部刪除。

// 此時stack
[divAst]
// 第五次擷取剩餘
</div>

6.繼續通過以上方式擷取,直到全部擷取完畢。

parse過程總結

簡單來說,template的parse過程,其實就是不斷的擷取字串並解析它們的過程。

在此過程中,如果擷取到非閉合標籤就push到stack中,如果擷取道結束標籤就把這個標籤pop出來。

optimize優化

optimize的作用主要是對生成的AST進行靜態內容的優化,標記靜態節點。所謂靜態內容,指的是和資料沒有關係,不需要每次都更新的內容。

標記靜態節點的作用的作用是為了之後dom diff時,是否需要patch,diff演演算法會直接跳過靜態節點,從而減少了比較的過程,優化了patch的效能。

  • 1.如果是表示式AST節點,直接返回 false
  • 2.如果是文字AST節點,直接返回 true
  • 3.如果元素是元素節點,階段有 v-pre 指令 ||

1.沒有任何指令、資料繫結、事件繫結等 &&

2.沒有 v-if 和 v-for &&

3.不是 slot 和 component &&

4.是 HTML 保留標籤 &&

5.不是 template 標籤的直接子元素並且沒有包含在 for 迴圈中則返回 true

簡單來說,沒有使用vue獨有的語法的節點就可以稱為靜態節點

判斷一個父級元素是靜態節點,則需要判斷它的所有子節點都是靜態節點,否則就不是靜態節點

標記靜態節點的過程是一個不斷遞迴的過程

for (let i = 0, l = node.children.length; i < l; i++) {
  const child = node.children[i]
  markStatic(child)
  if (!child.static) {
    node.static = false
  }
}

markStatic方法是用來標記靜態節點的方法,它會不斷的迴圈children,如果children還有children,則走相同的邏輯。這樣所有的節點都會被打上標記。

在迴圈中會判斷,子節點是否為靜態節點,如果不是則其父節點不是靜態節點。

generate生成render函數

generate是將AST轉化成render funtion字串的過程,他遞迴了AST,得到結果是render的字串。

render函數的就是返回一個_c(‘tagName’,data,children)的方法

1.第一個引數是標籤名

2.第二個引數是他的一些資料,包括屬性/指令/方法/表示式等等。

3.第三個引數是當前標籤的子標籤,同樣的,每一個子標籤的格式也是_c(‘tagName’,data,children)。

generate就是通過不斷遞迴形成了這麼一種樹形結構。


  • genElement:用來生成基本的render結構或者叫createElement結構
  • genData: 處理ast結構上的一些屬性,用來生成data
  • genChildren:處理ast的children,並在內部呼叫genElement,形成子元素的_c()方法

render字串內部有幾種方法

幾種內部方法

  • _c:對應的是 createElement 方法,顧名思義,它的含義是建立一個元素(Vnode)
  • _v:建立一個文字結點。
  • _s:把一個值轉換為字串。(eg: {{data}})
  • _m:渲染靜態內容
<template>
  <div id="app">
    {{val}}
    <img src="http://xx.jpg">
  </div>
</template>
{
  render: with(this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_v("n" + _s(val) + "n"),
        _c('img', {
              attrs: {
                "src": ""
              }
            })
        ]
    )
  }
}

那麼問題來了,_c(‘tagName’,data,children)如何拼接的,data是如何拼接的,children又是如何拼接的?

// genElement方法用來拼接每一項_c('tagName',data,children)
function genElement (el: ASTElement, state: CodegenState) {
  const data = el.plain ? undefined : genData(el, state)
  const children = el.inlineTemplate ? null : genChildren(el, state, true)
    
  let code = `_c('${el.tag}'${
    data ? `,${data}` : '' // data
  }${
    children ? `,${children}` : '' // children
  })`
  
  return code
}

線來看data的拼接邏輯

//
function genData (el: ASTElement, state: CodegenState): string {
  let data = '{'
  // key
  if (el.key) {
    data += `key:${el.key},`
  }
  // ref
  if (el.ref) {
    data += `ref:${el.ref},`
  }
  if (el.refInFor) {
    data += `refInFor:true,`
  }
  // ... 類似的還有很多種情況
  data = data.replace(/,$/, '') + '}'
  return data
}

從上面可以看出來,data的拼接過程就是不斷的判讀ast上一些屬性是否存在,然後拼在data上,最後把這個data返回。

那麼children怎麼拼出來呢?

function genChildren (
  el: ASTElement,
  state: CodegenState
): string | void {
  const children = el.children
  if (children.length) {
    return `[${children.map(c => genNode(c, state)).join(',')}]`
  }
}
function genNode (node: ASTNode, state: CodegenState): string {
  if (node.type === 1) {
    return genElement(node, state)
  } if (node.type === 3 && node.isComment) {
    return genComment(node)
  } else {
    return genText(node)
  }
}

最後執行render函數就會形成虛擬DOM.

以上為個人經驗,希望能給大家一個參考,也希望大家多多支援it145.com。


IT145.com E-mail:sddin#qq.com