本文我們一起通過學習Vue模板編譯原理(一) Template生成AST來分析Vue源碼。預計接下來會圍繞Vue源碼來整理一些文章,如下。 "一起來學Vue雙向綁定原理 數據劫持和發佈訂閱" "一起來學Vue模板編譯原理(一) Template生成AST" "一起來學Vue模板編譯原理(二) AST ...
本文我們一起通過學習Vue模板編譯原理(一)-Template生成AST來分析Vue源碼。預計接下來會圍繞Vue源碼來整理一些文章,如下。
- 一起來學Vue雙向綁定原理-數據劫持和發佈訂閱
- 一起來學Vue模板編譯原理(一)-Template生成AST
- 一起來學Vue模板編譯原理(二)-AST生成Render字元串
- 一起來學Vue虛擬DOM解析-Virtual Dom實現和Dom-diff演算法
這些文章統一放在我的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解析器解析出來的文本進行二次加工,主要是為了處理帶變數的文本。
相關
- https://juejin.im/post/5ca44160518825440a4b9fab
- https://segmentfault.com/a/1190000012922342
- https://www.jianshu.com/p/743166a8968c
- https://segmentfault.com/a/1190000013763590
- https://github.com/liutao/vue2.0-source/blob/master/compile%E2%80%94%E2%80%94%E7%94%9F%E6%88%90ast.md