Vue編程三部曲之將template編譯成AST示例詳解
前言
Vue.js 提供了 2 個版本,一個是 Runtime + Compiler
版本,一個是 Runtime only
版本。Runtime + Compiler
版本是包含編譯代碼的,可以把編譯過程放在運行時做,Runtime only
版本不包含編譯代碼的,需要借助 webpack 的 vue-loader 事先把模板編譯成 render 函數(shù)。
如果你需要在客戶端編譯模板 (比如傳入一個字符串給 template 選項,或掛載到一個元素上并以其 DOM 內(nèi)部的 HTML 作為模板),就將需要加上編譯器,即完整版:
// 需要編譯器 new Vue({ template: '<div>{{ hi }}</div>' }) // 不需要編譯器 new Vue({ render (h) { return h('div', this.hi) } })
當(dāng)使用 vue-loader 或 vueify 的時候,*.vue 文件內(nèi)部的模板會在構(gòu)建時預(yù)編譯成 JavaScript。你在最終打好的包里實際上是不需要編譯器的,所以只用運行時版本即可。因為運行時版本相比完整版體積要小大約 30%,所以應(yīng)該盡可能使用這個版本。
在 Vue 的整個編譯過程中,會做三件事:
- 解析模板
parse
,生成 AST - 優(yōu)化 AST
optimize
- 生成代碼
generate
對編譯過程的了解會讓我們對 Vue 的指令、內(nèi)置組件等有更好的理解。不過由于編譯的過程是一個相對復(fù)雜的過程,我們只要求理解整體的流程、輸入和輸出即可,對于細節(jié)我們不必摳太細。由于篇幅較長,這里會用三篇文章來講這三件事。這是第一篇, 模板解析,template -> AST
注:全文源碼來源,Vue(2.6.11),Runtime + Compiler 的 Vue.js
編譯準備
這里先做一個準備工作,編譯之前有一個嵌套的函數(shù)調(diào)用,看似非常的復(fù)雜,但是卻有玄機。有什么玄機?接著往下看。
源碼編譯鏈式調(diào)用
compileToFunctions
在源碼走了一遭,發(fā)現(xiàn)經(jīng)過一系列的調(diào)用,最后 createCompiler
函數(shù)返回的 compileToFunctions
函數(shù) 對應(yīng)的就是 $mount
函數(shù)調(diào)用的 compileToFunctions
方法,它是調(diào)用 createCompileToFunctionFn
方法的返回值。
// 偽代碼 function createCompilerCreator (baseCompile) { return function createCompiler (baseOptions) { function compile ( template, options ) { ... return compiled } return { compile: compile, compileToFunctions: createCompileToFunctionFn(compile) } } } function createCompileToFunctionFn (compile) { var cache = Object.create(null); return function compileToFunctions ( template, options, vm ) { ... } }
方法接受三個參數(shù)。
- 編譯模板 template
- 編譯配置 options
- Vue 的實例
這個方法編譯的核心代碼就一行。
// compile var compiled = compile(template, options);
而 compile 方法的核心代碼也就一行。
const compiled = baseCompile(template, finalOptions)
并且 baseCompile
方法是在執(zhí)行 createCompilerCreator
方法執(zhí)行的時候傳入的。
var createCompiler = createCompilerCreator(function baseCompile ( template, options ) { var ast = parse(template.trim(), options); if (options.optimize !== false) { optimize(ast, options); } var code = generate(ast, options); return { ast: ast, render: code.render, staticRenderFns: code.staticRenderFns } });
baseCompile
會做三件事情。
其實看到這里你就會發(fā)現(xiàn),這編譯的準備工作,做了很多函數(shù)的調(diào)用,但是兜兜轉(zhuǎn)轉(zhuǎn)之后,最后回頭來還是調(diào)用了最開始createCompilerCreator
傳入的函數(shù)。
我理解這樣做的原因是 Vue 本身是支持多平臺的編譯,在不同平臺下的編譯會有所有不同,但是在同一平臺編譯是相同的,所以在使用createCompiler(baseOptions)
時,baseOptions 會有所有不同。
在 Vue 中利用函數(shù)柯里化的思想,將 baseOptions
的配置參數(shù)進行了保存。并且在調(diào)用鏈中,不斷的進行函數(shù)調(diào)用并返回函數(shù)。
這其實也是利用了函數(shù)柯里化的思想把很多基礎(chǔ)的函數(shù)抽離出來, 通過 createCompilerCreator(baseCompile) 的方式把真正編譯的過程和其它邏輯如對編譯配置處理、緩存處理等剝離開,這樣的設(shè)計還是非常巧妙的。
編譯準備已經(jīng)做完,我們接下來看看 Vue 是如何做 parse
的。
parse
parse
要做的事情就是對 template 做解析,生成 AST 抽象語法樹。
抽象語法樹(Abstract Syntax Tree,AST),或簡稱語法樹(Syntax tree),是源代碼語法結(jié)構(gòu)的一種抽象表示。它以樹狀的形式表現(xiàn)編程語言的語法結(jié)構(gòu),樹上的每個節(jié)點都表示源代碼中的一種結(jié)構(gòu)。
例如現(xiàn)在有這樣一段代碼:
<body> <div id="app"></div> <script> new Vue({ el: '#app', template: ` <ul> <li>1</li> <li>1</li> <li>1</li> <li>1</li> <li>1</li> <li>1</li> <li>1</li> <li>1</li> <li>1</li> <li>1</li> </ul> ` }); </script> </body>
經(jīng)過parse
,就變成了一個嵌套的樹狀結(jié)構(gòu)的對象。
在 AST 中,每一個樹節(jié)點都是一個 element,并且維護了上下文關(guān)系(父子關(guān)系)。
解析 template
parse
的過程核心就是 parseHTML
函數(shù),這個函數(shù)的作用就是解析 template 模板。下面將解析過程中一些重要的點進行一個抽象解讀。
function parseHTML (html, options) { var stack = []; ... // 遍歷模板字符串 while (html) { ... } // 清除所有剩余的標簽 parseEndTag(); // 將 html 字符串的指針前移 function advance (n) { ... } // 解析開始標簽 function parseStartTag () { ... } // 處理解析的開始標簽的結(jié)果 function handleStartTag (match) { ... } // 解析結(jié)束標簽 function parseEndTag (tagName, start, end) { ... } }
標簽匹配相關(guān)的正則
下面也會講到關(guān)于一些指令匹配相關(guān)的正則。其實這些正則大家在平時的項目中有涉及也可以用起來,畢竟這些正則是經(jīng)過千萬人測試的。
// 識別合法的xml標簽 var ncname = '[a-zA-Z_][\w\-\.]*'; // 復(fù)用拼接,這在我們項目中完成可以學(xué)起來 var qnameCapture = "((?:" + ncname + "\:)?" + ncname + ")"; // 匹配注釋 var comment =/^<!--/; // 匹配<!DOCTYPE> 聲明標簽 var doctype = /^<!DOCTYPE [^>]+>/i; // 匹配條件注釋 var conditionalComment =/^<![/; // 匹配開始標簽 var startTagOpen = new RegExp(("^<" + qnameCapture)); // 匹配解說標簽 var endTag = new RegExp(("^<\/" + qnameCapture + "[^>]*>")); // 匹配單標簽 var startTagClose = /^\s*(/?)>/; // 匹配屬性,例如 id、class var attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配動態(tài)屬性,例如 v-if、v-else var dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)[[^=]+][^\s"'<>/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
stack
變量 stack
,它定義一個棧,作用是存儲開始標簽。例如我有一個這樣的簡單模板:
<div> <ul> <li>1</li> </ul> </div>
當(dāng)在 while 循環(huán)時,如果遇到一個非單標簽,就會將開始標簽 push 到數(shù)組中,遇到閉合標簽就開始元素出棧,這樣可以檢測我們寫的 template 是否符合嵌套、開閉規(guī)范,這也是檢測 html 字符串中是否缺少閉合標簽的原理。
advance
advance
函數(shù)貫穿這個 template 的解析流程。當(dāng)我們在解析 template 字符串的時候,需要對字符串逐一掃描,直到結(jié)束。advance 函數(shù)的作用就是移動指針。例如匹配 <
字符,指針移動 1,匹配到<!--
字符指針移動 4。在整個解析過程中,貫穿著指針的移動,因為要想解析完成就必須把模板全部編譯完。
function advance (n) { index += n; html = html.substring(n); }
while
template 的 while 循環(huán)是解析中最重要的一環(huán),也是這一小節(jié)的重點。
循環(huán)的終止條件是 html 字符串為空,即 html 字符串全部編譯完畢。
循環(huán)時,第一個判斷是判斷內(nèi)容是否在存純文本標簽中。判斷的作用是: 確保我們沒有像腳本/樣式這樣的純文本內(nèi)容元素。
當(dāng)內(nèi)容不在純文本標簽,判斷 template 字符串的第一個<
字符位置,來進行不同的操作。
var textEnd = html.indexOf('<');
當(dāng)前 template 第一個字符是 <
在這種場景下, template 會出現(xiàn)以下幾種情況,重點是解析開始標簽和結(jié)束標簽。
<!--
開頭的注釋:會找到注釋的結(jié)尾,將注釋截取出來,移動指針,并將注釋當(dāng)做當(dāng)前父節(jié)點的一個子元素存儲到 children 中。<![
開頭的 條件注釋:如果是條件注釋,會直接移動指針,不做任何其他操作。<!DOCTYPE
開頭的 doctype:如果是 doctype,會直接移動指針,不做任何其他操作。<
開頭的開始標簽<
開頭的結(jié)束標簽 接下來重點講講如何解析開始標簽和結(jié)束標簽。
解析開始標簽
①,通過正則匹配到開始標簽,如果匹配到就會返回一個 match 的匹配結(jié)果。例如:
<div id="test-id" class="test-calss" v-show='show'></div>
template 中有一個 div,當(dāng)匹配到開始標簽(結(jié)束標簽類似)時,會返回這樣數(shù)組結(jié)果。
0: "<div"
1: "div"
groups: undefined
index: 0
input: "<div>\n <ul>\n <li>1</li>\n </ul>\n </div>"
length: 2
②,接下來: 定義了 match 變量,它是一個對象,初始狀態(tài)下?lián)碛腥齻€屬性:
- tagName:存儲標簽的名稱。
div
。 - attrs :用來存儲將來被匹配到的屬性,例如:id、class、v-if 這些屬性。
- start:初始值為 index,是當(dāng)前字符流讀入位置在整個 html 字符串中的相對位置。 ③,然后通過
advance
函數(shù)移動指針。
④,如果沒有匹配到開始標簽的結(jié)束部分,并且存在屬性,就會遍歷找出所有屬性和動態(tài)屬性。保存在 match 的 attrs 中。
⑤,上一步獲取了標簽的屬性和動態(tài)屬性,但是即使這樣并不能說明這是一個完整的標簽,只有當(dāng)匹配到開始標記的結(jié)束標記時,才能證明這是一個完整的標簽,所以才會有這一步的判斷。varstartTagClose= /^\s*(/?)>/;
并且標記 unarySlash
屬性。
⑥,假設(shè)正常匹配了,有匹配結(jié)果,也返回了 match (結(jié)構(gòu)如上),就會走到handleStartTag
這個函數(shù)的作用就是用來處理開始標簽的解析結(jié)果,所以它接收 parseStartTag 函數(shù)的返回值作為參數(shù)。
handleStartTag 的核心邏輯很簡單,先判斷開始標簽是否是一元標簽,類似 <img />、<br/>
這樣,接著對 match.attrs 遍歷并做了一些處理,最后判斷如果非一元標簽,則往 stack 里 push 一個對象,并且把 tagName 賦值給 lastTag。
function parseStartTag () { // ① var start = html.match(startTagOpen); if (start) { // ② var match = { tagName: start[1], attrs: [], start: index }; // ③ advance(start[0].length); var end, attr; // ④ while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) { ... } // ⑤ if (end) { match.unarySlash = end[1]; advance(end[0].length); match.end = index; return match } } } // Start tag: var startTagMatch = parseStartTag(); if (startTagMatch) { handleStartTag(startTagMatch); ... } // ⑥ function handleStartTag (match) { ... }
解析結(jié)束標簽
有解析開始標簽就會解析結(jié)束標簽。所以接下來我們來看看如何解析結(jié)束標簽。
①,正則匹配結(jié)束標簽(具體的正則看前面)。
②,匹配到結(jié)束標簽,進行解析處理,獲取到結(jié)束標簽的標簽名稱、開始位置和結(jié)束位置,開始進行解析操作。
③,查找同一類型的最近打開的標記,并記錄位置。
④,如果存在同一類型的標記,就將 stack 中匹配的標記彈出。
⑤,如果沒有同一類型的標記,分別處理 </br>、</p>
標簽。這是為了和瀏覽器保持同樣的行為。舉個例子:在代碼中,分別寫了</br>、</p>
的結(jié)束標簽,但注意我們并沒有寫起始標簽,但是瀏覽器是能夠正常解析他們的,其中 </br>
標簽被正常解析為 <br>
標簽,而</p>
標簽被正常解析為 <p></p>
。除了 br
與 p
其他任何標簽如果你只寫了結(jié)束標簽?zāi)敲礊g覽器都將會忽略。所以為了與瀏覽器的行為相同,parseEndTag 函數(shù)也需要專門處理 br
與 p
的結(jié)束標簽,即:</br> 和</p>
。
<div> </br> </p> </div>
// ① var endTagMatch = html.match(endTag); if (endTagMatch) { var curIndex = index; advance(endTagMatch[0].length); // ② // 獲取到結(jié)束標簽的標簽名稱、開始位置和結(jié)束位置 parseEndTag(endTagMatch[1], curIndex, index); continue } function parseEndTag (tagName, start, end) { ... // ③ if (tagName) { lowerCasedTagName = tagName.toLowerCase(); for (pos = stack.length - 1; pos >= 0; pos--) { if (stack[pos].lowerCasedTag === lowerCasedTagName) { break } } } else { pos = 0; } // ④ if (pos >= 0) { ... stack.length = pos; lastTag = pos && stack[pos - 1].tag; // ⑤ } else if (lowerCasedTagName === 'br') { if (options.start) { options.start(tagName, [], true, start, end); } } else if (lowerCasedTagName === 'p') { if (options.start) { options.start(tagName, [], false, start, end); } if (options.end) { options.end(tagName, start, end); } } }
到這里結(jié)束標簽頁解析完成,但是在 Vue 中對開始標簽和結(jié)束標簽的解析遠不止這樣,因為為了瀏覽器行為保持一下在解析的過程中還會對一些特殊標簽特殊處理,典型的就是 p、br
標簽,我會在后面出一篇文章來詳細講講 Vue 是如何處理它們的。
當(dāng)前 template 不存在 <
當(dāng)解析到的 template 中不存在 < 時,這認為是一個文本。操作很簡單就是移動指針。
并且這里在源碼中發(fā)現(xiàn)初始化變量的時候,都是這樣寫的 var text =(void0), rest =(void0), next =(void0);而不是直接 var xx = undefined。原因是這樣操作更加的安全,我在之前的一篇文章中專門解析過,有興趣的可以再去看看。
if (textEnd < 0) { text = html; } if (text) { advance(text.length); }
當(dāng)前 template < 不在第一個字符串
這里的判斷處理就是為了處理我們在一些純文本中也會寫 <
標記的場景。例如:
<div>1<2</div>
現(xiàn)在有這樣一段模塊,<div>
被解析之后,還剩 1<2
,這時解析到存在 <
標記但是位置不在第一個。就循環(huán)找出包含<
的這一段文本,并將這一段當(dāng)成一個純文本處理。
if (textEnd >= 0) { rest = html.slice(textEnd); while ( !endTag.test(rest) && !startTagOpen.test(rest) && !comment.test(rest) && !conditionalComment.test(rest) ) { next = rest.indexOf('<', 1); if (next < 0) { break } textEnd += next; rest = html.slice(textEnd); } text = html.substring(0, textEnd); }
處理 stack 棧中剩余未處理的標簽
當(dāng) while 循環(huán)解析了一遍 template 之后,會再調(diào)用一次 parseEndTag
,這樣做的目的是為了處理 stack 棧中剩余未處理的標簽。當(dāng)調(diào)用時,沒有傳遞任何參數(shù),也意味著 tagName, start, end
都是空的,這時 pos 為 0 ,所以 i >= pos 始終成立,這個時候 stack 棧中如果有剩余未處理的標簽,則會逐個警告缺少閉合標簽,并調(diào)用 options.end 將其閉合。
// Clean up any remaining tags parseEndTag(); function parseEndTag (tagName, start, end) { if (tagName) { ... } else { pos = 0; } if (pos >= 0) { for (var i = stack.length - 1; i >= pos; i--) { if (i > pos || !tagName && options.warn) { options.warn( ("tag <" + (stack[i].tag) + "> has no matching end tag."), { start: stack[i].start, end: stack[i].end } ); } } ... } }
到這里解析 template 的重點過程都基本結(jié)束了,整個過程就是遍歷 template 字符串,然后通過正則一點一點的匹配解析字符串,直到整個字符串被解析完成。
生成 AST
當(dāng)然解析完 template 目的是生成 AST,經(jīng)過上面的一些列操作,只是解析完 template 字符串,并沒有生成一顆 AST 抽象語法樹。正常的來說抽象語法樹應(yīng)該是如下這樣的,節(jié)點與節(jié)點之間通過 parent 和 children 建立聯(lián)系,每個節(jié)點的 type 屬性用來標識該節(jié)點的類別,比如 type 為 1 代表該節(jié)點為元素節(jié)點,type 為 3 代表該節(jié)點為文本節(jié)點。
生成 AST 的主要步驟是在解析的過程中,會調(diào)用對應(yīng)的鉤子函數(shù)。解析到開始標簽,就調(diào)用開始的鉤子函數(shù),解析到結(jié)束標簽就調(diào)用結(jié)束的鉤子函數(shù),解析到文本就會調(diào)用文本的鉤子,解析到注釋就調(diào)用注釋的鉤子函數(shù)。這些鉤子函數(shù)就會將所有的節(jié)點串聯(lián)起來,并生成 AST 樹的結(jié)構(gòu)。
start 鉤子函數(shù)
這個鉤子函數(shù)會在解析到開始標簽的時候被調(diào)用。為了更加清楚解析過程,我們引入如下一個模板,如下:
<div><span></span><p></p></div>
解析 <div>
①,解析到<div>
會調(diào)用 start 鉤子函數(shù)。
②,創(chuàng)建一個基礎(chǔ)元素對象。
{ type: 1, tag:"div", parent: null, children: [], attrsList: [] } function createASTElement ( tag, attrs, parent ) { return { type: 1, tag: tag, attrsList: attrs, attrsMap: makeAttrsMap(attrs), rawAttrsMap: {}, parent: parent, children: [] } }
③,接著判斷 root 是否存在,如果不存在則直接將 element 賦值給 root 。root 是一個記錄值,也就是最后解析返回的整個 AST。
④,如果當(dāng)前標簽不是一元標簽時,會將當(dāng)前的 element
賦值給 currentParent
目的是為建立父子元素的關(guān)系。
⑤,將元素入棧,入棧的目的是為了做回退操作,這里先不講為什么需要做回退,后面在講。此時 stack = [{ tag : "div"... }]
。
// parseHTML函數(shù) 解析到開始標簽 function handleStartTag (match) { if (options.start) { // ① options.start(tagName, attrs, unary, match.start, match.end); } } // start 鉤子函數(shù) start: { // ② var element = createASTElement(tag, attrs, currentParent); // element: // { // type: 1, // tag:"div", // parent: null, // children: [], // attrsList: [] // } // ③ if (!root) { root = element; } // ④ if (!unary) { currentParent = element; // currentParent: // { // type: 1, // tag:"div", // parent: null, // children: [], // attrsList: [] // } // ⑤ stack.push(element); } }
解析 <span>
接著解析到 <span>
。此時 root 已經(jīng)存在,currentParent 也存在,所以會將 span 元素的描述對象添加到 currentParent 的 children 數(shù)組中作為子節(jié)點,并將自己的 parent 元素進行標記。所以最終生成的描述對象為:
{ type: 1, tag:"div", parent: {/*div 元素的描述*/}, attrsList: [] children: [{ type: 1, tag:"span", parent: div, attrsList: [], children:[] }], }
此時 stack = [{ tag : "div"... }, {tag : "span"...}]。
end 鉤子函數(shù)
當(dāng)解析到結(jié)束標簽就會調(diào)用結(jié)束標簽的鉤子函數(shù),還是這段模板代碼,解析完<div><span>
后遇到了</span>
。
<div><span></span><p></p></div>
解析
①,首先就是保存最后一個元素,將 stack 的最后一個元素刪除,也就是變成 stack = [{tag: "div" ...}],這就是做了一個回退操作 。
②,設(shè)置 currentParent 為 stack 的最后一個元素。
end: function end (tag, start, end$1) { // ① var element = stack[stack.length - 1]; stack.length -= 1; // ② currentParet = stack[stack.length - 1]; ... },
為什么回退?
解析 <p>
當(dāng)再次解析到開始標簽時,就會再次調(diào)用 start 鉤子函數(shù),這里重點是在解析 p 的開始標簽時:stack = [{tag:"div"...},{tag:"p"...}] ,由于在解析到上一個 </span>
標簽時做了一個回退操作, 這就能保證在解析 p 開始標簽的時候,stack 中存儲的是 p 標簽父級元素的描述對象。
解析 </p>
解析結(jié)束標簽,做回退操作。
遇到開始標簽就生成元素,勾勒上下文關(guān)系 parent、children 等,每當(dāng)遇到一個非一元標簽的結(jié)束標簽時,都會回退 currentParent 變量的值為之前的值,這樣就修正了當(dāng)前正在解析的元素的父級元素。
chars 鉤子函數(shù)
當(dāng)然在我們的代碼中肯定不止是開始和結(jié)束標簽,還會有文本。當(dāng)遇到文本時,就會調(diào)用 chars 鉤子函數(shù)。
①,首先判斷 currentParent(指向的是當(dāng)前節(jié)點的父節(jié)點) 變量是否存在,不存在就說明,說明 1:只有文本節(jié)點。2:文本在根元素之外。這兩種情況都會警告 ?? 提醒,接觸后面的操作。
②,第二個判斷主要是解決 ie textarea 占位符的問題。issue
③,判斷當(dāng)前元素未使用 v-pre 指令,text 不為空,使用 parseText 函數(shù)成功解析當(dāng)前文本節(jié)點的內(nèi)容。這里的重點在于 parseText 函數(shù),parseText 函數(shù)的作用就是用來解析如果我們的文本包含了字面量表達式。例如:
<div>1111: {{ text }}</div>
這樣的文本就會解析成如下的一個描述對象, 包含 expression 、tokens (包含原始的文本)。
解析完之后會生成一個 type = 2 的描述對象:
child = { type: 2, expression: res.expression, tokens: res.tokens, text: text };
④,如果使用了 v-pre || test 為空 || parseText 解析失敗,那么就會生成一個 type = 3 的存文本描述對象。
child = { type: 1, text: text };
⑤,最后將解析到描述對象,添加到當(dāng)前父元素的 children 列表中,注意:這里之前說明過因為我們的整個 template 是不能是純文本的,必須由根元素,所以如果是文本節(jié)點,一點是會有父元素的。
chars: function chars (text, start, end) { // ① if (!currentParent) { { if (text === template) { ...警告 } else if ((text = text.trim())) { ...警告 } } return } // ② if (isIE && currentParent.tag === 'textarea' && currentParent.attrsMap.placeholder === text ) { return } var children = currentParent.children; ... if (text) { ... // ③ if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) { child = { type: 2, expression: res.expression, tokens: res.tokens, text: text }; // ④ } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') { child = { type: 3, text: text }; } // ⑤ if (child) { ... children.push(child); } } },
到這里文本節(jié)點的解析完成。接下來看看注釋解析的鉤子函數(shù)。
commit 鉤子函數(shù)
當(dāng)我們配置了 options.comments = true ,也就意味著我們需要保留我們的注釋,這個配置需要我們手動開啟,開啟后就會在頁面渲染后保留注釋。
注意:如果開啟了保留注釋匹配后,瀏覽器會保留注釋。但是可能對布局產(chǎn)生影響,尤其是對行內(nèi)元素的影響。為了消除這些影響帶來的問題,好的做法是將它們?nèi)サ簟?/p>
注釋的解析比較簡單,就是創(chuàng)建注釋節(jié)點,然后添加當(dāng)前父元素的子階段列表中。要注意的是純文本節(jié)點和注釋節(jié)點的描述對象的 type 都是 3,不同的是注釋節(jié)點的元素描述對象擁有 isComment 屬性,并且該屬性的值為 true,目的就是用來與普通文本節(jié)點作區(qū)分的。
shouldKeepComment: options.comments, if (textEnd === 0) { ... if (options.shouldKeepComment) { options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3); } ... } comment: function comment (text, start, end) { if (currentParent) { var child = { type: 3, text: text, isComment: true }; ... currentParent.children.push(child); } }
到這里在生成 AST 過程中的 四個鉤子函數(shù)已經(jīng)全部講完。但是 Vue 本身在對元素做處理的時候的時候肯定不會是這么簡單的,因為這處理的過程中還要處理一元標簽、靜態(tài)屬性、動態(tài)屬性等。
番外(可跳過)
這一小節(jié)注意是看看在生成 AST 過程中的一些重要的工具函數(shù)。
createASTElement 函數(shù)
創(chuàng)建元素的描述對象。
function createASTElement ( tag, attrs, parent ) { return { type: 1, tag: tag, attrsList: attrs, attrsMap: makeAttrsMap(attrs), rawAttrsMap: {}, parent: parent, children: [] } }
指令解析相關(guān)的正則
前面也講到關(guān)于一些標簽匹配相關(guān)的正則。其實這些正則大家在平時的項目中有涉及也可以用起來,畢竟這些正則是經(jīng)過千萬人測試的。
var onRE = /^@|^v-on:/; var dirRE = /^v-|^@|^:|^#/; var forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/; var forIteratorRE = /,([^,}]]*)(?:,([^,}]]*))?$/; var stripParensRE = /^(|)$/g; var dynamicArgRE = /^[.*]$/; var argRE = /:(.*)$/; var bindRE = /^:|^.|^v-bind:/; var modifierRE = /.[^.]]+(?=[^]]*$)/g;
onRE
匹配已字符 @
或者 v-on
開頭的字符串,檢測標簽屬性是否是監(jiān)聽事件的指令。
var onRE = /^@|^v-on:/;
dirRE
匹配 v-
、@
、:
、#
開頭的字符串,檢測屬性名是否是指令。v-
開頭的屬性統(tǒng)統(tǒng)都認為是指令。@
字符是 v-on 的縮寫。:
是 v-bind 的縮寫。 #
是 v-slot 的縮寫。
var dirRE = /^v-|^@|^:|^#/;
forAliasRE
匹配 v-for
屬性的值,目的是捕獲 in 或者 of 前后的字符串。
var forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/;
forIteratorRE
這個也是用來匹配 v-for
屬性的,不同的是,這里是匹配遍歷時的 value 、 key 、index 。
var forIteratorRE = /,([^,}]]*)(?:,([^,}]]*))?$/;
stripParensRE
匹配以字符 (
開頭、)
結(jié)尾的字符串。作用是配合上面的正則對字符進行處理(
、)
。
var stripParensRE = /^(|)$/g;
argRE
匹配指令中的參數(shù)。作用是捕獲指令中的參數(shù)。常見的就是指令中的修飾符。
var argRE = /:(.*)$/;
bindRE
匹配:
、.
、v-bind:
開頭的字符串。作用是檢查屬性是否是綁定。
var bindRE = /^:|^.|^v-bind:/;
modifierRE
匹配修飾符。主要作用是判斷是否有修飾符。
var modifierRE = /.[^.]]+(?=[^]]*$)/g;
parseText 函數(shù)
這個函數(shù)的作用是解析 text,在上面講 chars 鉤子函數(shù)的時候也說到這個函數(shù)。函數(shù)有兩個參數(shù)text
、delimiters
。delimiters
參數(shù)作用就是:改變純文本插入分隔符。例如:
delimiters: ['${', '}'], // 模板 <div>{{ text }}</div>
模板會被編譯成這樣。
在 parseText 函數(shù)中,重點邏輯是開啟一個 while 循環(huán),使用 tagRE 正則匹配文本內(nèi)容,并將匹配結(jié)果保存在 match 變量中,直到匹配失敗循環(huán)才會終止,這時意味著所有的字面量表達式都已經(jīng)處理完畢了。
while ((match = tagRE.exec(text))) { index = match.index; if (index > lastIndex) { rawTokens.push(tokenValue = text.slice(lastIndex, index)); tokens.push(JSON.stringify(tokenValue)); } var exp = parseFilters(match[1].trim()); tokens.push(("_s(" + exp + ")")); rawTokens.push({ '@binding': exp }); lastIndex = index + match[0].length; }
例如有一段這樣的 template:
// text: '小白', // message: '好久不見' <div>hello, {{ text }},{{ message }}</div>
會被解析成如下 AST:
closeElement 函數(shù)
這個函數(shù)會在解析非一元開始標簽和解析結(jié)束標簽的時候調(diào)用,主要作用有兩個:
- 對數(shù)據(jù)狀態(tài)進行還原,
- 調(diào)用后置處理轉(zhuǎn)換鉤子函數(shù)。
整體流程
總結(jié)
Vue 編譯三部曲第一步 parse
的整個流程就已經(jīng)分享完了,雖然源碼看似非常的復(fù)制,但是如果只是抽離主流程的話,還是比較簡單的。parse
的目的是將開發(fā)者寫的 template
模板字符串轉(zhuǎn)換成抽象語法樹 AST ,AST 就這里來說就是一個樹狀結(jié)構(gòu)的 JavaScript 對象,描述了這個模板,這個對象包含了每一個元素的上下文關(guān)系。那么整個 parse
的過程是利用很多正則表達式順序解析模板,當(dāng)解析到開始標簽、閉合標簽、文本的時候都會分別執(zhí)行對應(yīng)的回調(diào)函數(shù),來達到構(gòu)造 AST 樹的目的。
template 編譯成 AST 的過程就為大家解析到這里,下一篇為大家來分享編譯中關(guān)于 「模型樹的優(yōu)化」。
參考
https://cn.vuejs.org/v2/guide/installation.html
parseHTML 函數(shù)源碼解析chars、end、comment鉤子函數(shù)
https://github.com/vuejs/vue/issues/4098
更多關(guān)于Vue template編譯成AST的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
springboot+vue+對接支付寶接口+二維碼掃描支付功能(沙箱環(huán)境)
這篇文章主要介紹了springboot+vue+對接支付寶接口+二維碼掃描支付(沙箱環(huán)境),本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-10-10vue+elementUI中el-radio設(shè)置默認值方式
這篇文章主要介紹了vue+elementUI中el-radio設(shè)置默認值方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-12-12解決VUE自定義拖拽指令時 onmouseup 與 click事件沖突問題
這篇文章主要介紹了解決VUE自定義拖拽指令時 onmouseup 與 click事件沖突問題,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-07-07Vue+ElementUI實現(xiàn)表單動態(tài)渲染、可視化配置的方法
這篇文章主要介紹了Vue+ElementUI實現(xiàn)表單動態(tài)渲染、可視化配置的方法,需要的朋友可以參考下2018-03-03iview-table組件嵌套input?select數(shù)據(jù)無法雙向綁定解決
這篇文章主要為大家介紹了iview-table組件嵌套input?select數(shù)據(jù)無法雙向綁定解決示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-09-09iview table render集成switch開關(guān)的實例
下面小編就為大家分享一篇iview table render集成switch開關(guān)的實例,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-03-03