[Vue源碼]一起來學Vue模板編譯原理(一)-Template生成AST

本文我們一起通過學習Vue模板編譯原理(一)-Template生成AST來分析Vue源碼。預計接下來會圍繞Vue源碼來整理一些文章,如下。

這些文章統一放在我的git倉庫:https://github.com/yzsunlei/javascript-series-code-analyzing。覺得有用記得star收藏。

編譯過程

模板編譯是Vue中比較核心的一部分。關於Vue編譯原理這塊的整體邏輯主要分三個部分,也可以說是分三步,前後關係如下:

第一步:將模板字符串轉換成element ASTs(解析器)

第二步:對 AST 進行靜態節點標記,主要用來做虛擬DOM的渲染優化(優化器)

第三步:使用element ASTs生成render函數代碼字符串(代碼生成器)

對應的Vue源碼如下,源碼位置在src/compiler/index.js

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 1.parse,模板字符串 轉換成 抽象語法樹(AST)
  const ast = parse(template.trim(), options)
  // 2.optimize,對 AST 進行靜態節點標記
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  // 3.generate,抽象語法樹(AST) 生成 render函數代碼字符串
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

這篇文檔主要講第一步將模板字符串轉換成對象語法樹(element ASTs),對應的源碼實現我們通常稱之爲解析器。

解析器運行過程

在分析解析器的原理前,我們先舉例看下解析器的具體作用。

來一個最簡單的實例:

<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並不是什麼很神奇的東西,不要被它的名字嚇倒。它只是用JS中的對象來描述一個節點,一個對象代表一個節點,對象中的屬性用來保存節點所需的各種數據。

事實上,解析器內部也分了好幾個子解析器,比如HTML解析器、文本解析器以及過濾器解析器,其中最主要的是HTML解析器。顧名思義,HTML解析器的作用是解析HTML,它在解析HTML的過程中會不斷觸發各種鉤子函數。這些鉤子函數包括開始標籤鉤子函數、結束標籤鉤子函數、文本鉤子函數以及註釋鉤子函數。

我們先看下解析器整體的代碼結構,源碼位置src/compiler/parser/index.js

parseHTML(template, {
  warn,
  expectHTML: options.expectHTML,
  isUnaryTag: options.isUnaryTag,
  canBeLeftOpenTag: options.canBeLeftOpenTag,
  shouldDecodeNewlines: options.shouldDecodeNewlines,
  shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
  shouldKeepComment: options.comments,
  outputSourceRange: options.outputSourceRange,
  // 每當解析到標籤的開始位置時,觸發該函數
  start (tag, attrs, unary, start, end) {
    //...
  },
  // 每當解析到標籤的結束位置時,觸發該函數
  end (tag, start, end) {
    //...
  },
  // 每當解析到文本時,觸發該函數
  chars (text: string, start: number, end: number) {
    //...
  },
  // 每當解析到註釋時,觸發該函數
  comment (text: string, start, end) {
    //...
  }
})

實際上,模板解析的過程就是不斷調用鉤子函數的處理過程。整個過程,讀取template字符串,使用不同的正則表達式,匹配到不同的內容,然後觸發對應不同的鉤子函數處理匹配到的截取片段,比如開始標籤正則匹配到開始標籤,觸發start鉤子函數,鉤子函數處理匹配到的開始標籤片段,生成一個標籤節點添加到抽象語法樹上。

還舉上面那個例子來說:

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

整個解析運行過程就是:解析到

時,會觸發一個標籤開始的鉤子函數start,處理匹配片段,生成一個標籤節點添加到AST上;然後解析到

時,又觸發一次鉤子函數start,處理匹配片段,又生成一個標籤節點並作爲上一個節點的子節點添加到AST上;接着解析到{{name}}這行文本,此時觸發了文本鉤子函數chars,處理匹配片段,生成一個帶變量文本(變量文本下面會講到)標籤節點並作爲上一個節點的子節點添加到AST上;然後解析到

,觸發了標籤結束的鉤子函數end;接着繼續解析到
,此時又觸發一次標籤結束的鉤子函數end,解析結束。

正則匹配

模板解析過程會涉及到許許多多的正則匹配,知道每個正則有什麼用途,會更加方便之後的分析。

那我們先來看看這些正則表達式,源碼位置在src/compiler/parser/index.js

export const onRE = /^@|^v-on:/
export const dirRE = process.env.VBIND_PROP_SHORTHAND
  ? /^v-|^@|^:|^\.|^#/
  : /^v-|^@|^:|^#/
export const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
export const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
const stripParensRE = /^\(|\)$/g
const dynamicArgRE = /^\[.*\]$/

const argRE = /:(.*)$/
export const bindRE = /^:|^\.|^v-bind:/
const propBindRE = /^\./
const modifierRE = /\.[^.\]]+(?=[^\]]*$)/g

const slotRE = /^v-slot(:|$)|^#/

const lineBreakRE = /[\r\n]/
const whitespaceRE = /\s+/g

const invalidAttributeRE = /[\s"'<>\/=]/

上面這些正則相對來說比較簡單,基本上都是用來匹配Vue中自定義的一些語法格式,如onRE匹配 @ 或 v-on 開頭的屬性,forAliasRE匹配v-for中的屬性值,比如item in items、(item, index) of items。

下面這些就是專門針對html的一些正則匹配,源碼位置在src/compiler/parser/html-parser.js

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
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 = /^<!\[/

這些正則表達式相對來說就複雜一些,如attribute用來匹配標籤的屬性,startTagOpen、startTagClose用於匹配標籤的開始、結束部分等。這些正則表達式的寫法就不多說了,有興趣的朋友可以針對這些正則一個一個的去測試一下。

HTML解析器

這裏我們來看看HTMl解析器。

事實上,解析HTML模板的過程就是循環的過程,簡單來說就是用HTML模板字符串來循環,每輪循環都從HTML模板中截取一小段字符串,然後重複以上過程,直到HTML模板被截成一個空字符串時結束循環,解析完畢。

我們通過源碼,就可以看到整個函數邏輯就是被一個while循環包裹着。源碼位置在:src/compiler/parser/html-parser.js

export function parseHTML (html, options) {
  const stack = []
  const expectHTML = options.expectHTML
  const isUnaryTag = options.isUnaryTag || no
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  let index = 0
  let last, lastTag
  while (html) {
    //...
  }
  
  parseEndTag()
  
  //...
}

下面我用一個簡單的模板,模擬一下HTML解析的過程,以便於更好的理解。

<div>
  <p>{{text}}</p>
</div>

最初的HTML模板:

<div>
  <p>{{text}}</p>
</div>

第一輪循環時,截取出一段字符串

,解析出是div開始標籤並且觸發鉤子函數start,截取後的結果爲:


  <p>{{text}}</p>
</div>

第二輪循環時,截取出一段換行空字符串,會觸發鉤子函數chars,截取後的結果爲:

  <p>{{text}}</p>
</div>

第三輪循環時,截取出一段字符串

,解析出是p開始標籤並且觸發鉤子函數start,截取後的結果爲:

  {{text}}</p>
</div>

第四輪循環時,截取出一段字符串{{name}},解析出是變量字符串並且觸發鉤子函數chars,截取後的結果爲:

  </p>
</div>

第五輪循環時,截取出一段字符串

,解析出是p閉合標籤並且觸發鉤子函數end,截取後的結果爲:

</div>

第六輪循環時,截取出一段換行空字符串,會觸發鉤子函數chars,截取後的結果爲:

</div>

第七輪循環時,截取出一段字符串,解析出是div閉合標籤並且觸發鉤子函數end,截取後的結果爲:

第八輪循環時,發現只有一個空字符串,解析完畢,循環結束。

現在,是不是就對HTML解析過程很清楚了。其實循環過程對每次匹配到的片段進行分析記錄還是很複雜的,因爲被截取的片段分很多種類型,比如:

開始標籤,例如<div>

結束標籤,例如</div>

HTML註釋,例如<!-- 註釋 -->

DOCTYPE,例如<!DOCTYPE html>

條件註釋,例如<!--[if !IE]>-->註釋<!--<![endif]-->

文本,例如’字符串’

對每個片段的具體處理這裏就不說了,有興趣的直接看源碼去。

文本解析器

文本解析器是對HTML解析器解析出來的文本進行二次加工。文本其實分兩種類型,一種是純文本,另一種是帶變量的文本。如下:

這種就是純文本:

這裏有段文本

這種就是帶變量的文本:

文本內容:{{text}}

上面HTML解析器在解析文本時,並不會區分文本是否是帶變量的文本。如果是純文本,不需要進行任何處理;但如果是帶變量的文本,那麼需要使用文本解析器進一步解析。因爲帶變量的文本在使用虛擬DOM進行渲染時,需要將變量替換成變量中的值。

我們知道,HTML解析器在碰到文本時,會觸發chars鉤子函數,我們先來看看鉤子函數裏面是怎麼區分普通文本和變量文本的。

源碼位置在:src/compiler/parser/html-parser.js

chars (text: string, start: number, end: number) {
  //...
  let child: ?ASTNode
  if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
    child = {
      type: 2,
      expression: res.expression,
      tokens: res.tokens,
      text
    }
  } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
    child = {
      type: 3,
      text
    }
  }
  //...
  children.push(child)
}

我們重點看res = parseText(text,delimiters)這一行,通過條件判斷設置不同的類型。事實上type=2表示表達式類型,type=3表示普通文本類型。

我們再來看看parseText函數具體做了什麼

export function parseText (
  text: string,
  delimiters?: [string, string]
): TextParseResult | void {
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
  // 匹配不到帶變量時直接返回了
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  const rawTokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  // 對匹配到的變量循環處理成表達式
  while ((match = tagRE.exec(text))) {
    index = match.index
    // push text token
    // 先把 { { 前邊的文本添加到tokens中
    if (index > lastIndex) {
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // tag token
    const exp = parseFilters(match[1].trim())
    // 使用_s對變量進行包裝
    // 把變量改成`_s(x)`這樣的形式也添加到數組中
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    // 設置lastIndex來保證下一輪循環時,正則表達式不再重複匹配已經解析過的文本
    lastIndex = index + match[0].length
  }
  // 當所有變量都處理完畢後,如果最後一個變量右邊還有文本,就將文本添加到數組中
  if (lastIndex < text.length) {
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

實際上這個函數就是處理帶變量的文本,首先如果是純文本,直接return。如果是帶變量的文本,使用正則表達式匹配出文本中的變量,先把變量左邊的文本添加到數組中,然後把變量改成_s(x)這樣的形式也添加到數組中。如果變量後面還有變量,則重複以上動作,直到所有變量都添加到數組中。如果最後一個變量的後面有文本,就將它添加到數組中。

那麼對於上面示例處理結果如下:

parseText('這裏有段文本')
// undefined

parseText('文本內容:{{text}}')
// '"文本內容:" + _s(text)'

好了,對於文本解析器就這麼多內容。

總結一下

模板解析是Vue模板編譯的第一步,即通過模板得到AST(抽象語法樹)。

生成AST的過程核心就是藉助HTML解析器,當HTML解析器通過正則匹配到不同的片段時會觸發對應不同的鉤子函數,通過鉤子函數對匹配片段進行解析我們可以構建出不同的節點。

文本解析器是對HTML解析器解析出來的文本進行二次加工,主要是爲了處理帶變量的文本。

相關

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章