本文我們一起通過學習Vue模板編譯原理(一)-Template生成AST來分析Vue原始碼。預計接下來會圍繞Vue原始碼來整理一些文章,如下。
- 一起來學Vue雙向繫結原理-資料劫持和釋出訂閱
- 一起來學Vue模板編譯原理(一)-Template生成AST
- 一起來學Vue模板編譯原理(二)-AST生成Render字串
- 一起來學Vue虛擬DOM解析-Virtual Dom實現和Dom-diff演算法
這些文章統一放在我的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上;接著解析到{{name}}這行文字,此時觸發了文字鉤子函式chars,處理匹配片段,生成一個帶變數文字(變數文字下面會講到)標籤節點並作為上一個節點的子節點新增到AST上;然後解析到
,觸發了標籤結束的鉤子函式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>
複製程式碼
第一輪迴圈時,擷取出一段字串
<p>{{text}}</p>
</div>
複製程式碼
第二輪迴圈時,擷取出一段換行空字串,會觸發鉤子函式chars,擷取後的結果為:
<p>{{text}}</p>
</div>
複製程式碼
第三輪迴圈時,擷取出一段字串
,解析出是p開始標籤並且觸發鉤子函式start,擷取後的結果為:
{{text}}</p>
</div>
複製程式碼
第四輪迴圈時,擷取出一段字串{{name}},解析出是變數字串並且觸發鉤子函式chars,擷取後的結果為:
</p>
</div>
複製程式碼
第五輪迴圈時,擷取出一段字串
,解析出是p閉合標籤並且觸發鉤子函式end,擷取後的結果為:
</div>
複製程式碼
第六輪迴圈時,擷取出一段換行空字串,會觸發鉤子函式chars,擷取後的結果為:
</div>
複製程式碼
第七輪迴圈時,擷取出一段字串
複製程式碼
第八輪迴圈時,發現只有一個空字串,解析完畢,迴圈結束。
現在,是不是就對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解析器解析出來的文字進行二次加工,主要是為了處理帶變數的文字。