Vue源碼之:模板編譯

參考文檔:javascript

vue-js.com/learn-vue/html

github.com/answershuto…'vue

前言

在前幾篇文章中,咱們介紹了Vue中的虛擬DOM以及虛擬DOMpatch(DOM-Diff)過程,而虛擬DOM存在的必要條件是得先有VNode,那麼VNode又是從哪兒來的呢?這就是接下來幾篇文章要說的模板編譯。你能夠這麼理解:把用戶寫的模板進行編譯,就會產生VNodejava

瞭解一下 $mount

/*把本來不帶編譯的$mount方法保存下來,在最後會調用。*/
const mount = Vue.prototype.$mount
/*掛載組件,帶模板編譯*/
Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component {
  el = el && query(el)

  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  /*處理模板templete,編譯成render函數,render不存在的時候纔會編譯template,不然優先使用render*/
  if (!options.render) {
    let template = options.template
    /*template存在的時候取template,不存在的時候取el的outerHTML*/
    if (template) {
      /*當template是字符串的時候*/
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        /*當template爲DOM節點的時候*/
        template = template.innerHTML
      } else {
        /*報錯*/
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      /*獲取element的outerHTML*/
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      /*將template編譯成render函數,這裏會有render以及staticRenderFns兩個返回,這是vue的編譯時優化,static靜態不須要在VNode更新時進行patch,優化性能*/
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        delimiters: options.delimiters
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  /*Github:https://github.com/answershuto*/
  /*調用const mount = Vue.prototype.$mount保存下來的不帶編譯的mount*/
  return mount.call(this, el, hydrating)
}
複製代碼

經過mount代碼咱們能夠看到,在mount的過程當中,若是render函數不存在(render函數存在會優先使用render)會將template進行compileToFunctions獲得render以及staticRenderFns。譬如說手寫組件時加入了template的狀況都會在運行時進行編譯。而render function在運行後會返回VNode節點,供頁面的渲染以及在update的時候patch。接下來咱們來看一下template是如何編譯的。node

什麼是模板編譯

咱們把寫在<template></template>標籤中的相似於原生HTML的內容稱之爲模板。這時你可能會問了,爲何說是「相似於原生HTML的內容」而不是「就是HTML的內容」?由於咱們在開發中,在<template></template>標籤中除了寫一些原生HTML的標籤,咱們還會寫一些變量插值,如,或者寫一些Vue指令,如v-onv-if等。而這些東西都是在原生HTML語法中不存在的,不被接受的。可是事實上咱們確實這麼寫了,也被正確識別了,頁面也正常顯示了,這又是爲何呢?git

這就歸功於Vue的模板編譯了,Vue會把用戶在<template></template>標籤中寫的相似於原生HTML的內容進行編譯,把原生HTML的內容找出來,再把非原生HTML找出來,通過一系列的邏輯處理生成渲染函數,也就是render函數,而render函數會將模板內容生成對應的VNode,而VNode再通過前幾篇文章介紹的patch過程從而獲得將要渲染的視圖中的VNode,最後根據VNode建立真實的DOM節點並插入到視圖中, 最終完成視圖的渲染更新。github

而把用戶在template></template>標籤中寫的相似於原生HTML的內容進行編譯,把原生HTML的內容找出來,再把非原生HTML找出來,通過一系列的邏輯處理生成渲染函數,也就是render函數的這一段過程稱之爲模板編譯過程。算法

總體的渲染流程

所謂渲染流程,就是把用戶寫的相似於原生HTML的模板通過一系列處理最終反應到視圖中稱之爲整個渲染流程。這個流程在上文中其實已經說到了,下面咱們以流程圖的形式宏觀的瞭解一下,流程圖以下:express

從圖中咱們也能夠看到,模板編譯過程就是把用戶寫的模板通過一系列處理最終生成render函數的過程。編程

模板編譯內部流程

那麼模板編譯內部是怎麼把用戶寫的模板通過處理最終生成render函數的呢?這內部的過程是怎樣的呢?

抽象語法樹AST

Vue如何從<template></template>標籤中寫的模板字符串中提取出元素的標籤,屬性,變量等,就要藉助一個叫作抽象語法樹的東西

所謂抽象語法樹,在計算機科學中,抽象語法樹AbstractSyntaxTree,AST),或簡稱語法樹(Syntax tree),是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每一個節點都表示源代碼中的一種結構。之因此說語法是「抽象」的,是由於這裏的語法並不會表示出真實語法中出現的每一個細節。好比,嵌套括號被隱含在樹的結構中,並無以節點的形式呈現;而相似於if-condition-then這樣的條件跳轉語句,可使用帶有兩個分支的節點來表示。——來自百度百科

astexplorer.net/

具體流程

將一堆字符串模板解析成抽象語法樹AST後,咱們就能夠對其進行各類操做處理了,處理完後用處理後的AST來生成render函數。其具體流程可大體分爲三個階段

一、模板解析階段:將一堆模板字符串用正則等方式解析成抽象語法樹AST

二、優化階段:遍歷AST,找出其中的靜態節點,並打上標記;

三、代碼生成階段:將AST轉換成渲染函數;

這三個階段在源碼中分別對應三個模塊,下面給出三個模塊的源代碼在源碼中的路徑:

一、模板解析階段——解析器——源碼路徑:src/compiler/parser/index.js`;

二、優化階段——優化器——源碼路徑:src/compiler/optimizer.js;

三、代碼生成階段——代碼生成器——源碼路徑:src/compiler/codegen/index.js; 其對應的源碼以下:

// 源碼位置: /src/complier/index.js

function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  /*parse解析獲得ast樹*/
  const ast = parse(template.trim(), options)
  /*
    將AST樹進行優化
    優化的目標:生成模板AST樹,檢測不須要進行DOM改變的靜態子樹。
    一旦檢測到這些靜態樹,咱們就能作如下這些事情:
    1.把它們變成常數,這樣咱們就不再須要每次從新渲染時建立新的節點了。
    2.在patch的過程當中直接跳過。
 */
  optimize(ast, options)
  /*根據ast樹生成所需的code(內部包含render與staticRenderFns)*/
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

複製代碼

能夠看到 baseCompile的代碼很是的簡短主要核心代碼。

一、const ast =parse(template.trim(), options):parse 會用正則等方式解析 template 模板中的指令、classstyle等數據,造成AST

二、optimize(ast, options): optimize的主要做用是標記靜態節點,這是 Vue 在編譯過程當中的一處優化,擋在進行patch 的過程當中,DOM-Diff 算法會直接跳過靜態節點,從而減小了比較的過程,優化了 patch 的性能。

三、const code =generate(ast, options): 將 AST 轉化成render函數字符串的過程,獲得結果是render函數的字符串以及staticRenderFns 字符串。

最終baseCompile的返回值

{
 	ast: ast,
 	render: code.render,
 	staticRenderFns: code.staticRenderFns
 }
複製代碼

最終返回了抽象語法樹( ast),渲染函數( render ),靜態渲染函數( staticRenderFns ),且render 的值爲code.renderstaticRenderFns 的值爲code.staticRenderFns,也就是說經過 generate處理 ast以後獲得的返回值 code 是一個對象。

下面再給出模板編譯內部具體流程圖,便於理解。流程圖以下:

模板解析階段

在解析整個模板的時候它的流程應該是這樣子的:HTML解析器是主線,先用HTML解析器進行解析整個模板,在解析過程當中若是碰到文本內容,那就調用文本解析器來解析文本,若是碰到文本中包含過濾器那就調用過濾器解析器來解析。以下圖所示:

回到源碼

解析器的源碼位於/src/complier/parser文件夾下,其主線代碼以下:

// 代碼位置:/src/complier/parser/index.js

/**
 * Convert HTML string to AST.
 */
export function parse(template, options) {
   // ...
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    start (tag, attrs, unary) {
        if (inVPre) {
        ...
        } else {
             /*處理屬性*/
            processAttrs(element)
        }
    },
    end () {

    },
    //這個地方處理 parseText
    chars (text: string) {
        if (text) {
        let expression
            if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
              children.push({
                type: 2,
                expression,
                text
              })
            }
        }
    },
    comment (text: string) {

    }
  })
  return root
}

/*處理屬性*/
function processAttrs (el) {
  /*獲取元素屬性列表*/
  const list = el.attrsList
  let i, l, name, rawName, value, modifiers, isProp
  for (i = 0, l = list.length; i < l; i++) {
     .....
      /*若是屬性是v-bind的*/
      if (bindRE.test(name)) { // v-bind
        /*這樣處理之後v-bind:aaa獲得aaa*/
        name = name.replace(bindRE, '')
        .....
        /*解析過濾器*/
        value = parseFilters(value)
        ....
      }
    } else {
      /*處理常規的字符串屬性*/
      // literal attribute
      if (process.env.NODE_ENV !== 'production') {
        const expression = parseText(value, delimiters)
        ....
        }
    }
  }
}
複製代碼

從上面代碼中能夠看到,parse 函數就是解析器的主函數,在parse 函數內調用了parseHTML函數對模板字符串進行解析,在parseHTML

函數解析模板字符串的過程當中,若是遇到文本信息,就會調用文本解析器parseText函數進行文本解析;若是遇到文本中包含過濾器,就會調用過濾器解析器parseFilters函數進行解析。

模板解析階段 parseHTML

HTML解析器內部運行流程

在源碼中,HTML解析器就是parseHTML函數,在模板解析主線函數parse中調用了該函數,並傳入兩個參數,代碼如上: 從代碼中咱們能夠看到,調用parseHTML函數時爲其傳入的兩個參數分別是:

一、template:待轉換的模板字符串;

二、options:轉換時所需的選項;

第一個參數是待轉換的模板字符串,無需多言;重點看第二個參數,第二個參數提供了一些解析HTML模板時的一些參數,同時還定義了4個鉤子函數。這4個鉤子函數有什麼做用呢?咱們說了模板編譯階段主線函數parse會將HTML模板字符串轉化成AST,而parseHTML是用來解析模板字符串的,把模板字符串中不一樣的內容出來以後,那麼誰來把提取出來的內容生成對應的AST呢?答案就是這4個鉤子函數

把這4個鉤子函數做爲參數傳給解析器parseHTML,當解析器解析出不一樣的內容時調用不一樣的鉤子函數從而生成不一樣的AST

paseHTML 源碼以下:

function parseHTML(html, options) {
    const stack = []       // 維護AST節點層級的棧
    const expectHTML = options.expectHTML
    const isUnaryTag = options.isUnaryTag || no
    const canBeLeftOpenTag = options.canBeLeftOpenTag || no   //用來檢測一個標籤是不是能夠省略閉合標籤的非自閉合標籤
    let index = 0   //解析遊標,標識當前從何處開始解析模板字符串
    let last,   // 存儲剩餘還未解析的模板字符串
        lastTag  // 存儲着位於 stack 棧頂的元素

	// 開啓一個 while 循環,循環結束的條件是 html 爲空,即 html 被 parse 完畢
	while (html) {
		last = html;
		// 確保即將 parse 的內容不是在純文本標籤裏 (script,style,textarea)
		if (!lastTag || !isPlainTextElement(lastTag)) {
		   let textEnd = html.indexOf('<')
              /**
               * 若是html字符串是以'<'開頭,則有如下幾種可能
               * 開始標籤:<div>
               * 結束標籤:</div>
               * 註釋:<!-- 我是註釋 -->
               * 條件註釋:<!-- [if !IE] --> <!-- [endif] -->
               * DOCTYPE:<!DOCTYPE html>
               * 須要一一去匹配嘗試
               */
            if (textEnd === 0) {
                // 解析是不是註釋
        		if (comment.test(html)) {

                }
                // 解析是不是條件註釋
                if (conditionalComment.test(html)) {

                }
                // 解析是不是DOCTYPE
                const doctypeMatch = html.match(doctype)
                if (doctypeMatch) {

                }
                // 解析是不是結束標籤
                const endTagMatch = html.match(endTag)
                if (endTagMatch) {

                }
                // 匹配是不是開始標籤
                const startTagMatch = parseStartTag()
                if (startTagMatch) {

                }
            }
            // 若是html字符串不是以'<'開頭,則解析文本類型
            let text, rest, next
            if (textEnd >= 0) {

            }
            // 若是在html字符串中沒有找到'<',表示這一段html字符串都是純文本
            if (textEnd < 0) {
                text = html
                html = ''
            }
            // 把截取出來的text轉化成textAST
            if (options.chars && text) {
                options.chars(text)
            }
		} else {
			// 父元素爲script、style、textarea時,其內部的內容所有當作純文本處理
		}

		//將整個字符串做爲文本對待
		if (html === last) {
			options.chars && options.chars(html);
			if (!stack.length && options.warn) {
				options.warn(("Mal-formatted tag at end of template: \"" + html + "\""));
			}
			break
		}
	}

	// Clean up any remaining tags
	parseEndTag();
	//parse 開始標籤
	function parseStartTag() {

	}
	//處理 parseStartTag 的結果
	function handleStartTag(match) {

	}
	//parse 結束標籤
	function parseEndTag(tagName, start, end) {

	}
}
複製代碼
當解析到開始標籤時調用start函數生成元素類型的AST節點,代碼以下;
// 當解析到標籤的開始位置時,觸發start
start (tag, attrs, unary) {
	const element: ASTElement = {
        type: 1,
        tag,
        attrsList: attrs,
        attrsMap: makeAttrsMap(attrs),
        parent: currentParent,
        children: []
    }
}

複製代碼

從上面代碼中咱們能夠看到,start函數接收三個參數,分別是標籤名tag、標籤屬性attrs、標籤是否自閉合unary。當調用該鉤子函數時,內部會調用createASTElement函數來建立元素類型的AST節點

當解析到結束標籤時調用end函數;
當解析到文本時調用chars函數生成文本類型的AST節點;
// 當解析到標籤的文本時,觸發chars
chars (text) {
  if (text) {
    let expression
    if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
      children.push({
        type: 2,
        expression,
        text
      })
    } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
      children.push({
        type: 3,
        text
      })
    }
  }
}
複製代碼

當解析到標籤的文本時,觸發chars鉤子函數,在該鉤子函數內部,首先會判斷文本是否是一個帶變量的動態文本,如「hello 」。若是是動態文本,則建立動態文本類型的AST節點;若是不是動態文本,則建立純靜態文本類型的AST節點。

當解析到註釋時調用comment函數生成註釋類型的AST節點;
comment (text: string, start, end) {
      // adding anyting as a sibling to the root node is forbidden
      // comments should still be allowed, but ignored
      if (currentParent) {
        const child: ASTText = {
          type: 3,
          text,
          isComment: true
        }
        if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
          child.start = start
          child.end = end
        }
        currentParent.children.push(child)
      }
    }
複製代碼

當解析到標籤的註釋時,觸發comment鉤子函數,該鉤子函數會建立一個註釋類型的AST節點。

一邊解析不一樣的內容一邊調用對應的鉤子函數生成對應的AST節點,最終完成將整個模板字符串轉化成AST,這就是HTML解析器所要作的工做。

如何解析不一樣的內容

要從模板字符串中解析出不一樣的內容,那首先要知道模板字符串中都會包含哪些內容。那麼一般咱們所寫的模板字符串中都會包含哪些內容呢?通過整理,一般模板內會包含以下內容:

  • 文本,例如「難涼熱血」
  • HTML註釋,例如
  • 條件註釋,例如我是註釋
  • DOCTYPE,例如
  • 開始標籤,例如
  • 結束標籤,例如
解析HTML註釋

解析註釋比較簡單,咱們知道HTML註釋是以<!--開頭,以-->結尾,這二者中間的內容就是註釋內容,那麼咱們只需用正則判斷待解析的模板字符串html是否以<!--開頭,如果,那就繼續向後尋找-->,若是找到了,OK,註釋就被解析出來了。代碼以下:

const comment = /^<!\--/
if (comment.test(html)) {
  // 若爲註釋,則繼續查找是否存在'-->'
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    // 若存在 '-->',繼續判斷options中是否保留註釋
    if (options.shouldKeepComment) {
      // 若保留註釋,則把註釋截取出來傳給options.comment,建立註釋類型的AST節點
      options.comment(html.substring(4, commentEnd))
    }
    // 若不保留註釋,則將遊標移動到'-->'以後,繼續向後解析
    advance(commentEnd + 3)
    continue
  }
}

function advance (n) {
  index += n   // index爲解析遊標
  html = html.substring(n)
}
複製代碼

在上面代碼中,若是模板字符串html符合註釋開始的正則,那麼就繼續向後查找是否存在-->,若存在,則把html從第4位("<!--"長度爲4)開始截取,直到-->處,截取獲得的內容就是註釋的真實內容,而後調用4個鉤子函數中的comment函數,將真實的註釋內容傳進去,建立註釋類型的AST節點。

上面代碼中有一處值得注意的地方,那就是咱們日常在模板中能夠在<template></template>標籤上配置comments選項來決定在渲染模板時是否保留註釋,對應到上面代碼中就是options.shouldKeepComment,若是用戶配置了comments選項爲true,則shouldKeepCommenttrue,則建立註釋類型的AST節點,如不保留註釋,則將遊標移動到'-->'以後,繼續向後解析。

解析條件註釋

解析條件註釋也比較簡單,其原理跟解析註釋相同,都是先用正則判斷是不是以條件註釋特有的開頭標識開始,而後尋找其特有的結束標識,若找到,則說明是條件註釋,將其截取出來便可,因爲條件註釋不存在於真正的DOM樹中,因此不須要調用鉤子函數建立AST節點。代碼以下:

// 解析是不是條件註釋
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
  // 若爲條件註釋,則繼續查找是否存在']>'
  const conditionalEnd = html.indexOf(']>')

  if (conditionalEnd >= 0) {
    // 若存在 ']>',則從本來的html字符串中把條件註釋截掉,
    // 把剩下的內容從新賦給html,繼續向後匹配
    advance(conditionalEnd + 2)
    continue
  }
}
複製代碼
解析開始標籤

相較於前三種內容的解析,解析開始標籤會稍微複雜一點,可是萬變不離其宗,它的原理仍是相通的,都是使用正則去匹配提取。

首先使用開始標籤的正則去匹配模板字符串,看模板字符串是否具備開始標籤的特徵,以下:

/**
 * 匹配開始標籤的正則
 */
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)

const start = html.match(startTagOpen)
if (start) {
  const match = {
    tagName: start[1],
    attrs: [],
    start: index
  }
}

// 以開始標籤開始的模板:
'<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
// 以結束標籤開始的模板:
'</div><div></div>'.match(startTagOpen) => null
// 以文本開始的模板:
'我是文本</p>'.match(startTagOpen) => null
複製代碼

在上面代碼中,咱們用不一樣類型的內容去匹配開始標籤的正則,發現只有<div></div>的字符串能夠正確匹配,而且返回一個數組。

在前文中咱們說到,當解析到開始標籤時,會調用4個鉤子函數中的start函數,而start函數須要傳遞3個參數,分別是標籤名tag、標籤屬性attrs、標籤是否自閉合unary。標籤名經過正則匹配的結果就能夠拿到,即上面代碼中的start[1],而標籤屬性attrs以及標籤是否自閉合unary須要進一步解析。

一、解析標籤屬性

咱們知道,標籤屬性通常是寫在開始標籤的標籤名以後的,以下:

<div class="a" id="b"></div>
複製代碼

另外,咱們在上面匹配是不是開始標籤的正則中已經能夠拿到開始標籤的標籤名,即上面代碼中的start[0],那麼咱們能夠將這一部分先從模板字符串中截掉,則剩下的部分以下:

class="a" id="b"></div>
複製代碼

那麼咱們只需用剩下的這部分去匹配標籤屬性的正則,就能夠將標籤屬性提取出來了,以下:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = 'class="a" id="b"></div>'
let attr = html.match(attribute)
console.log(attr)
// ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b"></div>", groups: undefined]
複製代碼

能夠看到,第一個標籤屬性class="a"已經被拿到了。另外,標籤屬性有可能有多個也有可能沒有,若是沒有的話那好辦,匹配標籤屬性的正則就會匹配失敗,標籤屬性就爲空數組;而若是標籤屬性有多個的話,那就須要循環匹配了,匹配出第一個標籤屬性後,就把該屬性截掉,用剩下的字符串繼續匹配,直到再也不知足正則爲止,代碼以下:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const startTagClose = /^\s*(\/?)>/
const match = {
 tagName: start[1],
 attrs: [],
 start: index
}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
 advance(attr[0].length)
 match.attrs.push(attr)
}
複製代碼

在上面代碼的while循環中,若是剩下的字符串不符合開始標籤的結束特徵(startTagClose)而且符合標籤屬性的特徵的話,那就說明還有未提取出的標籤屬性,那就進入循環,繼續提取,直到把全部標籤屬性都提取完畢。

所謂不符合開始標籤的結束特徵是指當前剩下的字符串不是以開始標籤結束符開頭的,咱們知道一個開始標籤的結束符有多是一個>(非自閉合標籤),也有多是/>(自閉合標籤),若是剩下的字符串(如></div>)以開始標籤的結束符開頭,那麼就表示標籤屬性已經被提取完畢了。

二、解析標籤是不是自閉合

在HTML中,有自閉合標籤(如<img src=""/>)也有非自閉合標籤(如<div></div>),這兩種類型的標籤在建立AST節點是處理方式是有區別的,因此咱們須要解析出當前標籤是不是自閉合標籤。

解析的方式很簡單,咱們知道,通過標籤屬性提取以後,那麼剩下的字符串無非就兩種,以下: `

<!--非自閉合標籤-->
></div>
複製代碼
<!--自閉合標籤-->
/>
複製代碼

因此咱們能夠用剩下的字符串去匹配開始標籤結束符正則,以下:

const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
'></div>'.match(startTagClose) // [">", "", index: 0, input: "></div>", groups: undefined]
'/>'.match(startTagClose) // ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
複製代碼

能夠看到,非自閉合標籤匹配結果中的end[1]"",而自閉合標籤匹配結果中的end[1]"/"。因此根據匹配結果的

  • end[1]是不是""咱們便可判斷出當前標籤是否爲自閉合標籤,源碼以下:
const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
if (end) {
 match.unarySlash = end[1]
 advance(end[0].length)
 match.end = index
 return match
}
複製代碼

通過以上兩步,開始標籤就已經解析完畢了,完整源碼以下:

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/


function parseStartTag () {
  const start = html.match(startTagOpen)
  // '<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
      start: index
    }
    advance(start[0].length)
    let end, attr
    /**
     * <div a=1 b=2 c=3></div>
     * 從<div以後到開始標籤的結束符號'>'以前,一直匹配屬性attrs
     * 全部屬性匹配完以後,html字符串還剩下
     * 自閉合標籤剩下:'/>'
     * 非自閉合標籤剩下:'></div>'
     */
    while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
      advance(attr[0].length)
      match.attrs.push(attr)
    }

    /**
     * 這裏判斷了該標籤是否爲自閉合標籤
     * 自閉合標籤如:<input type='text' />
     * 非自閉合標籤如:<div></div>
     * '></div>'.match(startTagClose) => [">", "", index: 0, input: "></div>", groups: undefined]
     * '/><div></div>'.match(startTagClose) => ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
     * 所以,咱們能夠經過end[1]是不是"/"來判斷該標籤是不是自閉合標籤
     */
    if (end) {
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}
複製代碼

經過源碼能夠看到,調用parseStartTag函數,若是模板字符串符合開始標籤的特徵,則解析開始標籤,並將解析結果返回,若是不符合開始標籤的特徵,則返回undefined。

解析完畢後,就能夠用解析獲得的結果去調用start鉤子函數去建立元素型的AST節點了。

在源碼中,Vue並無直接去調start鉤子函數去建立AST節點,而是調用了handleStartTag函數,在該函數內部纔去調的start鉤子函數,爲何要這樣作呢?這是由於雖然通過parseStartTag函數已經把建立AST節點必要信息提取出來了,可是提取出來的標籤屬性數組仍是須要處理一下,下面咱們就來看一下handleStartTag函數都作了些什麼事。handleStartTag函數源碼以下:

function handleStartTag (match) {
    const tagName = match.tagName
    const unarySlash = match.unarySlash

    if (expectHTML) {
      // ...
    }

    const unary = isUnaryTag(tagName) || !!unarySlash

    const l = match.attrs.length
    const attrs = new Array(l)
    for (let i = 0; i < l; i++) {
      const args = match.attrs[i]
      const value = args[3] || args[4] || args[5] || ''
      const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
        ? options.shouldDecodeNewlinesForHref
        : options.shouldDecodeNewlines
      attrs[i] = {
        name: args[1],
        value: decodeAttr(value, shouldDecodeNewlines)
      }
    }

    if (!unary) {
      stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
      lastTag = tagName
    }

    if (options.start) {
      options.start(tagName, attrs, unary, match.start, match.end)
    }
  }

複製代碼

handleStartTag函數用來對parseStartTag函數的解析結果進行進一步處理,它接收parseStartTag函數的返回值做爲參數。

handleStartTag函數的開始定義幾個常量:

const tagName = match.tagName       // 開始標籤的標籤名
const unarySlash = match.unarySlash  // 是否爲自閉合標籤的標誌,自閉合爲"",非自閉合爲"/"
const unary = isUnaryTag(tagName) || !!unarySlash  // 布爾值,標誌是否爲自閉合標籤
const l = match.attrs.length    // match.attrs 數組的長度
const attrs = new Array(l)  // 一個與match.attrs數組長度相等的數組
複製代碼
解析結束標籤

結束標籤的解析要比解析開始標籤容易多了,由於它不須要解析什麼屬性,只須要判斷剩下的模板字符串是否符合結束標籤的特徵,若是是,就將結束標籤名提取出來,再調用4個鉤子函數中的end函數就行了。

首先判斷剩餘的模板字符串是否符合結束標籤的特徵,以下:

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = html.match(endTag)

'</div>'.match(endTag)  // ["</div>", "div", index: 0, input: "</div>", groups: undefined]
'<div>'.match(endTag)  // null
複製代碼

上面代碼中,若是模板字符串符合結束標籤的特徵,則會得到匹配結果數組;若是不合符,則獲得null。

接着再調用end鉤子函數,以下:

if (endTagMatch) {
    const curIndex = index
    advance(endTagMatch[0].length)
    parseEndTag(endTagMatch[1], curIndex, index)
    continue
}
複製代碼
解析文本

解析文本也比較容易,在解析模板字符串以前,咱們先查找一下第一個<出如今什麼位置,若是第一個<在第一個位置,那麼說明模板字符串是以其它5種類型開始的;若是第一個<不在第一個位置而在模板字符串中間某個位置,那麼說明模板字符串是以文本開頭的,那麼從開頭到第一個<出現的位置就都是文本內容了;若是在整個模板字符串裏沒有找到<,那說明整個模板字符串都是文本。這就是解析思路,接下來咱們對照源碼來了解一下實際的解析過程,源碼以下:

et textEnd = html.indexOf('<')
// '<' 在第一個位置,爲其他5種類型
if (textEnd === 0) {
    // ...
}
// '<' 不在第一個位置,文本開頭
if (textEnd >= 0) {
    // 若是html字符串不是以'<'開頭,說明'<'前面的都是純文本,無需處理
    // 那就把'<'之後的內容拿出來賦給rest
    rest = html.slice(textEnd)
    while (
        !endTag.test(rest) &&
        !startTagOpen.test(rest) &&
        !comment.test(rest) &&
        !conditionalComment.test(rest)
    ) {
        // < in plain text, be forgiving and treat it as text
        /**
           * 用'<'之後的內容rest去匹配endTag、startTagOpen、comment、conditionalComment
           * 若是都匹配不上,表示'<'是屬於文本自己的內容
           */
        // 在'<'以後查找是否還有'<'
        next = rest.indexOf('<', 1)
        // 若是沒有了,表示'<'後面也是文本
        if (next < 0) break
        // 若是還有,表示'<'是文本中的一個字符
        textEnd += next
        // 那就把next以後的內容截出來繼續下一輪循環匹配
        rest = html.slice(textEnd)
    }
    // '<'是結束標籤的開始 ,說明從開始到'<'都是文本,截取出來
    text = html.substring(0, textEnd)
    advance(textEnd)
}
// 整個模板字符串裏沒有找到`<`,說明整個模板字符串都是文本
if (textEnd < 0) {
    text = html
    html = ''
}
// 把截取出來的text轉化成textAST
if (options.chars && text) {
    options.chars(text)
}
複製代碼

值得深究的是若是<不在第一個位置而在模板字符串中間某個位置,那麼說明模板字符串是以文本開頭的,那麼從開頭到第一個<出現的位置就都是文本內容了,接着咱們還要從第一個<的位置繼續向後判斷,由於還存在這樣一種狀況,那就是若是文本里面原本就包含一個<,例如1<2。爲了處理這種狀況,咱們把從第一個<的位置直到模板字符串結束都截取出來記做rest,以下:

while (
    !endTag.test(rest) &&
    !startTagOpen.test(rest) &&
    !comment.test(rest) &&
    !conditionalComment.test(rest)
) {
    // < in plain text, be forgiving and treat it as text
    /**
    * 用'<'之後的內容rest去匹配endTag、startTagOpen、comment、conditionalComment
    * 若是都匹配不上,表示'<'是屬於文本自己的內容
    */
    // 在'<'以後查找是否還有'<'
    next = rest.indexOf('<', 1)
    // 若是沒有了,表示'<'後面也是文本
    if (next < 0) break
    // 若是還有,表示'<'是文本中的一個字符
    textEnd += next
    // 那就把next以後的內容截出來繼續下一輪循環匹配
    rest = html.slice(textEnd)
}
複製代碼

如何保證AST節點層級關係

上一章節咱們介紹了HTML解析器是如何解析各類不一樣類型的內容而且調用鉤子函數建立不一樣類型的AST節點。此時你可能會有個疑問,咱們上面建立的AST節點都是單首創建且分散的,而真正的DOM節點都是有層級關係的,那如何來保證AST節點的層級關係與真正的DOM節點相同呢?

關於這個問題,Vue也注意到了。Vue在HTML解析器的開頭定義了一個棧stack,這個棧的做用就是用來維護AST節點層級的,那麼它是怎麼維護的呢?經過前文咱們知道,HTML解析器在從前向後解析模板字符串時,每當遇到開始標籤時就會調用start鉤子函數,那麼在start鉤子函數內部咱們能夠將解析獲得的開始標籤推入棧中,而每當遇到結束標籤時就會調用end鉤子函數,那麼咱們也能夠在end鉤子函數內部將解析獲得的結束標籤所對應的開始標籤從棧中彈出。請看以下例子:

假若有以下模板字符串:

<div><p><span></span></p></div>
複製代碼

當解析到開始標籤<div>時,就把div推入棧中,而後繼續解析,當解析到<p>時,再把p推入棧中,同理,再把span推入棧中,當解析到結束標籤</span>時,此時棧頂的標籤恰好是span的開始標籤,那麼就用span的開始標籤和結束標籤構建AST節點,而且從棧中把span的開始標籤彈出,那麼此時棧中的棧頂標籤p就是構建好的spanAST節點的父節點,以下圖:

模板解析階段 parseText

文本解析器的源碼位於src/compiler/parser/text-parsre.js中,代碼以下:

const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
const buildRegex = cached(delimiters => {
  const open = delimiters[0].replace(regexEscapeRE, '\\$&')
  const close = delimiters[1].replace(regexEscapeRE, '\\$&')
  return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
})
export function parseText (text,delimiters) {
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  const rawTokens = []
  /**
   * let lastIndex = tagRE.lastIndex = 0
   * 上面這行代碼等同於下面這兩行代碼:
   * tagRE.lastIndex = 0
   * let lastIndex = tagRE.lastIndex
   */
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  while ((match = tagRE.exec(text))) {
    index = match.index
    // push text token
    if (index > lastIndex) {
      // 先把'{{'前面的文本放入tokens中
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // tag token
    // 取出'{{ }}'中間的變量exp
    const exp = parseFilters(match[1].trim())
    // 把變量exp改爲_s(exp)形式也放入tokens中
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    // 設置lastIndex 以保證下一輪循環時,只從'}}'後面再開始匹配正則
    lastIndex = index + match[0].length
  }
  // 當剩下的text再也不被正則匹配上時,表示全部變量已經處理完畢
  // 此時若是lastIndex < text.length,表示在最後一個變量後面還有文本
  // 最後將後面的文本再加入到tokens中
  if (lastIndex < text.length) {
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }

  // 最後把數組tokens中的全部元素用'+'拼接起來
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

複製代碼

咱們看到,除開咱們本身加的註釋,代碼其實不復雜

相關文章
相關標籤/搜索