Vue3 源碼解析(二):AST解析器

上一篇文章中,咱們從 packges/vue/src/index.ts 的入口開始,瞭解了一個 Vue 對象的編譯流程,在文中咱們提到 baseCompile 函數在執行過程當中會生成 AST 抽象語法樹,毫無疑問這是很關鍵的一步,由於只有拿到生成的 AST 咱們才能遍歷 AST 的節點進行 transform 轉換操做,好比解析 v-ifv-for 等各類指令,或者對節點進行分析將知足條件的節點靜態提高,這些都依賴以前生成的 AST 抽象語法樹。那麼今天咱們就一塊兒來看一下 AST 的解析,看看 Vue 是如何解析模板的。vue

生成 AST 抽象語法樹

首先咱們來重溫一下 baseCompile 函數中有關 ast 的邏輯及後續的使用:node

export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {

  /* 忽略以前邏輯 */

  const ast = isString(template) ? baseParse(template, options) : template

  transform(
    ast,
    {/* 忽略參數 */}
  )

  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

由於我已經將我們不須要關注的邏輯註釋處理,因此如今看函數體內的邏輯會很是清晰:數組

  • 生成 ast 對象
  • 將 ast 對象做爲參數傳入 transform 函數,對 ast 節點進行轉換操做
  • 將 ast 對象做爲參數傳入 generate 函數,返回編譯結果

這裏咱們主要關注 ast 的生成。能夠看到 ast 的生成有一個三目運算符的判斷,若是傳進來的 template 模板參數是一個字符串,那麼則調用 baseParse 解析模板字符串,不然直接將 template 做爲 ast 對象。baseParse 裏作了什麼事情才能生成 ast 呢?一塊兒來看一下源碼,數據結構

export function baseParse(
  content: string,
  options: ParserOptions = {}
): RootNode {
  const context = createParserContext(content, options) // 建立解析的上下文對象
  const start = getCursor(context) // 生成記錄解析過程的遊標信息
  return createRoot( // 生成並返回 root 根節點
    parseChildren(context, TextModes.DATA, []), // 解析子節點,做爲 root 根節點的 children 屬性
    getSelection(context, start)
  )
}

在 baseParse 的函數中我添加了註釋,方便你們理解各個函數的做用,首先會建立解析的上下文,以後根據上下文獲取遊標信息,因爲還未進行解析,因此遊標中的 column、line、offset 屬性對應的都是 template 的起始位置。以後就是建立根節點並返回根節點,至此ast 樹生成,解析完成。函數

建立 AST 的根節點

export function createRoot(
  children: TemplateChildNode[],
  loc = locStub
): RootNode {
  return {
    type: NodeTypes.ROOT,
    children,
    helpers: [],
    components: [],
    directives: [],
    hoists: [],
    imports: [],
    cached: 0,
    temps: 0,
    codegenNode: undefined,
    loc
  }
}

看 createRoot 函數的代碼,咱們能發現該函數就是返回了一個 RootNode 類型的根節點對象,其中咱們傳入的 children 參數會被做爲根節點的 children 參數。這裏很是好理解,按樹型數據結構來想象就能夠。因此生成 ast 的關鍵點就會聚焦到 parseChildren 這個函數上來。parseChildren 函數若是不去看它的源碼,見文之意也能夠大體瞭解這是一個解析子節點的函數。接下來咱們就來一塊兒來看一下 AST 解析中最關鍵的 parseChildren 函數,仍是老規矩,爲了幫助你們理解,我會精簡函數體內的邏輯。spa

解析子節點

function parseChildren(
  context: ParserContext,
  mode: TextModes,
  ancestors: ElementNode[]
): TemplateChildNode[] {
  const parent = last(ancestors) // 獲取當前節點的父節點
  const ns = parent ? parent.ns : Namespaces.HTML
  const nodes: TemplateChildNode[] = [] // 存儲解析後的節點

  // 當標籤未閉合時,解析對應節點
  while (!isEnd(context, mode, ancestors)) {/* 忽略邏輯 */}

  // 處理空白字符,提升輸出效率
  let removedWhitespace = false
  if (mode !== TextModes.RAWTEXT && mode !== TextModes.RCDATA) {/* 忽略邏輯 */}

  // 移除空白字符,返回解析後的節點數組
  return removedWhitespace ? nodes.filter(Boolean) : nodes
}

從上文代碼中,能夠知道 parseChildren 函數接收三個參數,context:解析器上下文,mode:文本數據類型,ancestors:祖先節點數組。而函數的執行中會首先從祖先節點中獲取當前節點的父節點,肯定命名空間,以及建立一個空數組,用來儲存解析後的節點。以後會有一個 while 循環,判斷是否到達了標籤的關閉位置,若是不是須要關閉的標籤,則在循環體內對源模板字符串進行分類解析。以後會有一段處理空白字符的邏輯,處理完成後返回解析好的 nodes 數組。在你們對於 parseChildren 的執行流程有一個初步理解以後,咱們一塊兒來看一下函數的核心,while 循環內的邏輯。設計

在 while 中解析器會判斷文本數據的類型,只有當 TextModes 爲 DATA 或 RCDATA 時會繼續往下解析。code

第一種狀況就是判斷是否須要解析 Vue 模板語法中的 「Mustache」語法 (雙大括號) ,若是當前上下文中沒有 v-pre 指令來跳過表達式,而且源模板字符串是以咱們指定的分隔符開頭的(此時 context.options.delimiters 中是雙大括號),就會進行雙大括號的解析。這裏就能夠發現,若是當你有特殊需求,不但願使用雙大括號做爲表達式插值,那麼你只須要在編譯前改變選項中的 delimiters 屬性便可。component

接下來會判斷,若是第一個字符是 「<」 而且第二個字符是 '!'的話,會嘗試解析註釋標籤,<!DOCTYPE<!CDATA 這三種狀況,對於 DOCTYPE 會進行忽略,解析成註釋。orm

以後會判斷當第二個字符是 「/」 的狀況,「</」 已經知足了一個閉合標籤的條件了,因此會嘗試去匹配閉合標籤。當第三個字符是 「>」,缺乏了標籤名字,會報錯,並讓解析器的進度前進三個字符,跳過 「</>」。

若是「</」開頭,而且第三個字符是小寫英文字符,解析器會解析結束標籤。

若是源模板字符串的第一個字符是 「<」,第二個字符是小寫英文字符開頭,會調用 parseElement 函數來解析對應的標籤。

當這個判斷字符串字符的分支條件結束,而且沒有解析出任何 node 節點,那麼會將 node 做爲文本類型,調用 parseText 進行解析。

最後將生成的節點添加進 nodes 數組,在函數結束時返回。

這就是 while 循環體內的邏輯,且是 parseChildren 中最重要的部分。在這個判斷過程當中,咱們看到了雙大括號語法的解析,看到了註釋節點的怎樣被解析的,也看到了開始標籤和閉合標籤的解析,以及文本內容的解析。精簡後的代碼在下方框中,你們能夠對照上述的講解,來理解一下源碼。固然,源碼中的註釋也是很是詳細了喲。

while (!isEnd(context, mode, ancestors)) {
  const s = context.source
  let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined

  if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
    if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
      /* 若是標籤沒有 v-pre 指令,源模板字符串以雙大括號 `{{` 開頭,按雙大括號語法解析 */
      node = parseInterpolation(context, mode)
    } else if (mode === TextModes.DATA && s[0] === '<') {
      // 若是源模板字符串的第以個字符位置是 `!`
      if (s[1] === '!') {
                // 若是以 '<!--' 開頭,按註釋解析
        if (startsWith(s, '<!--')) {
          node = parseComment(context)
        } else if (startsWith(s, '<!DOCTYPE')) {
                    // 若是以 '<!DOCTYPE' 開頭,忽略 DOCTYPE,當作僞註釋解析
          node = parseBogusComment(context)
        } else if (startsWith(s, '<![CDATA[')) {
          // 若是以 '<![CDATA[' 開頭,又在 HTML 環境中,解析 CDATA
          if (ns !== Namespaces.HTML) {
            node = parseCDATA(context, ancestors)
          }
        }
      // 若是源模板字符串的第二個字符位置是 '/'
      } else if (s[1] === '/') {
        // 若是源模板字符串的第三個字符位置是 '>',那麼就是自閉合標籤,前進三個字符的掃描位置
        if (s[2] === '>') {
          emitError(context, ErrorCodes.MISSING_END_TAG_NAME, 2)
          advanceBy(context, 3)
          continue
        // 若是第三個字符位置是英文字符,解析結束標籤
        } else if (/[a-z]/i.test(s[2])) {
          parseTag(context, TagType.End, parent)
          continue
        } else {
          // 若是不是上述狀況,則當作僞註釋解析
          node = parseBogusComment(context)
        }
      // 若是標籤的第二個字符是小寫英文字符,則當作元素標籤解析
      } else if (/[a-z]/i.test(s[1])) {
        node = parseElement(context, ancestors)
        
      // 若是第二個字符是 '?',當作僞註釋解析
      } else if (s[1] === '?') {
        node = parseBogusComment(context)
      } else {
        // 都不是這些狀況,則報出第一個字符不是合法標籤字符的錯誤。
        emitError(context, ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME, 1)
      }
    }
  }
  
  // 若是上述的狀況解析完畢後,沒有建立對應的節點,則當作文原本解析
  if (!node) {
    node = parseText(context, mode)
  }
  
  // 若是節點是數組,則遍歷添加進 nodes 數組中,不然直接添加
  if (isArray(node)) {
    for (let i = 0; i < node.length; i++) {
      pushNode(nodes, node[i])
    }
  } else {
    pushNode(nodes, node)
  }
}

解析模板元素 Element

在 while 的循環內,各個分支判斷分支內,咱們能看到 node 會接收各類節點類型的解析函數的返回值。而這裏我會詳細的說一下 parseElement 這個解析元素的函數,由於這是咱們在模板中用的最頻繁的場景。

我先把 parseElement 的源碼精簡一下貼上來,而後來嘮一嘮裏面的邏輯。

function parseElement(
  context: ParserContext,
  ancestors: ElementNode[]
): ElementNode | undefined {
  // 解析起始標籤
  const parent = last(ancestors)
  const element = parseTag(context, TagType.Start, parent)
  
  // 若是是自閉合的標籤或者是空標籤,則直接返回。voidTag例如: `<img>`, `<br>`, `<hr>`
  if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
    return element
  }

  // 遞歸的解析子節點
  ancestors.push(element)
  const mode = context.options.getTextMode(element, parent)
  const children = parseChildren(context, mode, ancestors)
  ancestors.pop()

  element.children = children

  // 解析結束標籤
  if (startsWithEndTagOpen(context.source, element.tag)) {
    parseTag(context, TagType.End, parent)
  } else {
    emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
    if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
      const first = children[0]
      if (first && startsWith(first.loc.source, '<!--')) {
        emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
      }
    }
  }
  // 獲取標籤位置對象
  element.loc = getSelection(context, element.loc.start)

  return element
}

首先咱們會獲取當前節點的父節點,而後調用 parseTag 函數解析。

parseTag 函數會按的執行大致是如下流程:

  • 首先匹配標籤名。
  • 解析元素中的 attribute 屬性,存儲至 props 屬性
  • 檢測是否存在 v-pre 指令,如果存在的話,則修改 context 上下文中的 inVPre 屬性爲 true
  • 檢測自閉合標籤,若是是自閉合,則將 isSelfClosing 屬性置爲 true
  • 判斷 tagType,是 ELEMENT 元素仍是 COMPONENT 組件,或者 SLOT 插槽
  • 返回生成的 element 對象

因爲篇幅緣由,我這裏就不貼 parseTag 的源碼了,感興趣的同窗能夠自行查看。

在獲取到 element 對象後,會判斷 element 是不是自閉合標籤,或者是空標籤,例如 <img><br><hr> ,若是是這種狀況,則直接返回 element 對象。

而後咱們會嘗試解析 element 的子節點,將 element 壓入棧中中,而後遞歸的調用 parseChildren 來解析子節點。

const parent = last(ancestors)

再回頭看看 parseChildren 以及 parseElement 中的這行代碼,就能夠發如今將 element 入棧後,咱們拿到的父節點就是當前節點。在解析完畢後,調用 ancestors.pop() ,使當前解析完子節點的 element 對象出棧,將解析後的 children 對象賦值給 element 的 children 屬性,完成 element 的子節點解析,這裏是個很巧妙的設計。

最後匹配結束標籤,設置 element 的 loc 位置信息,返回解析完畢的 element 對象。

示例:模板元素解析

請看下方咱們要解析的模板,圖片中是解析過程當中,保存解析後節點的棧的存儲狀況,

<div>
  <p>Hello World</p>
</div>

parseElement

圖中的黃色矩形是一個棧,當開始解析時,parseChildren 首先會遇到 div 標籤,開始調用的 parseElement 函數。經過 parseTag 函數解析出了 div 元素,並將它壓入棧中,遞歸解析子節點。第二次調用 parseChildren 函數,碰見 p 元素,調用 parseElement 函數,將 p 標籤壓入棧中,此時棧中有 div 和 p 兩個標籤。再次解析 p 中的子節點,第三次調用 parseChildren 標籤,此次不會匹配到任何標籤,不會生成對應的 node,因此會經過 parseText 函數去生成文本,解析出 node 爲 HelloWorld,並返回 node。

將這個文本類型的 node 添加進 p 標籤的 children 屬性後,此時 p 標籤的子節點解析完畢,彈出祖先棧,完成結束標籤的解析後,返回 p 標籤對應的 element 對象。

p 標籤對應的 node 節點生成,並在 parseChildren 函數中返回對應 node。

div 標籤在接收到 p 標籤的 node 後,添加進自身的 children 屬性中,出棧。此時祖先棧中就空空如也了。而 div 的標籤完成閉合解析的邏輯後,返回 element 元素。

最終 parseChildren 的第一次調用返回結果,生成了 div 對應的 node 對象,也返回告終果,將這個結果做爲 createRoot 函數的 children 參數傳入,生成根節點對象,完成 ast 解析。

後記

這篇文章咱們從 ast 生成時調用的 baseParse 函數分析,再到 baseParse 返回 createRoot 的調用結果,一直到細化的講解了 parseChildren 解析子節點函數中的其中某一個具體解析器的執行過程。最後經過一個簡單模板舉例,看 Vue 的解析器是如何解析以及分析祖先棧中的狀況,比較全面的講解了解析器的工做流程。

若是這篇文章能輔助你來了解 Vue3 中解析器的工做流程,但願能給文章點贊哦。❤️

相關文章
相關標籤/搜索