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

小磊哥er發表於2019-12-23

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

這些文章統一放在我的git倉庫:github.com/yzsunlei/ja…。覺得有用記得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解析器解析出來的文字進行二次加工,主要是為了處理帶變數的文字。

相關

相關文章