上一篇文章中,我們從 packges/vue/src/index.ts 的入口開始,了解了一個(gè) Vue 對(duì)象的編譯流程,在文中我們提到 baseCompile 函數(shù)在執(zhí)行過程中會(huì)生成 AST 抽象語法樹,毫無疑問這是很關(guān)鍵的一步,因?yàn)橹挥心玫缴傻?AST 我們才能遍歷 AST 的節(jié)點(diǎn)進(jìn)行 transform 轉(zhuǎn)換操作,比如解析 v-if、v-for 等各種指令,或者對(duì)節(jié)點(diǎn)進(jìn)行分析將滿足條件的節(jié)點(diǎn)靜態(tài)提升,這些都依賴之前生成的 AST 抽象語法樹。那么今天我們就一起來看一下 AST 的解析,看看 Vue 是如何解析模板的。
生成 AST 抽象語法樹
首先我們來重溫一下 baseCompile 函數(shù)中有關(guān) ast 的邏輯及后續(xù)的使用:
export function baseCompile(
template: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
/* 忽略之前邏輯 */
const ast = isString(template) ? baseParse(template, options) : template
transform(
ast,
{/* 忽略參數(shù) */}
)
return generate(
ast,
extend({}, options, {
prefixIdentifiers
})
)
}
因?yàn)槲乙呀?jīng)將咱們不需要關(guān)注的邏輯注釋處理,所以現(xiàn)在看函數(shù)體內(nèi)的邏輯會(huì)非常清晰:
- 生成 ast 對(duì)象
- 將 ast 對(duì)象作為參數(shù)傳入 transform 函數(shù),對(duì) ast 節(jié)點(diǎn)進(jìn)行轉(zhuǎn)換操作
- 將 ast 對(duì)象作為參數(shù)傳入 generate 函數(shù),返回編譯結(jié)果
這里我們主要關(guān)注 ast 的生成。可以看到 ast 的生成有一個(gè)三目運(yùn)算符的判斷,如果傳進(jìn)來的 template 模板參數(shù)是一個(gè)字符串,那么則調(diào)用 baseParse 解析模板字符串,否則直接將 template 作為 ast 對(duì)象。baseParse 里做了什么事情才能生成 ast 呢?一起來看一下源碼,
export function baseParse(
content: string,
options: ParserOptions = {}
): RootNode {
const context = createParserContext(content, options) // 創(chuàng)建解析的上下文對(duì)象
const start = getCursor(context) // 生成記錄解析過程的游標(biāo)信息
return createRoot( // 生成并返回 root 根節(jié)點(diǎn)
parseChildren(context, TextModes.DATA, []), // 解析子節(jié)點(diǎn),作為 root 根節(jié)點(diǎn)的 children 屬性
getSelection(context, start)
)
}
在 baseParse 的函數(shù)中我添加了注釋,方便大家理解各個(gè)函數(shù)的作用,首先會(huì)創(chuàng)建解析的上下文,之后根據(jù)上下文獲取游標(biāo)信息,由于還未進(jìn)行解析,所以游標(biāo)中的 column、line、offset 屬性對(duì)應(yīng)的都是 template 的起始位置。之后就是創(chuàng)建根節(jié)點(diǎn)并返回根節(jié)點(diǎn),至此ast 樹生成,解析完成。
創(chuàng)建 AST 的根節(jié)點(diǎn)
export function createRoot(
children: TemplateChildNode[],
loc = locStub
): RootNode {
return {
type: NodeTypes.ROOT,
children,
helpers: [],
components: [],
directives: [],
hoists: [],
imports: [],
cached: 0,
temps: 0,
codegenNode: undefined,
loc
}
}
看 createRoot 函數(shù)的代碼,我們能發(fā)現(xiàn)該函數(shù)就是返回了一個(gè) RootNode 類型的根節(jié)點(diǎn)對(duì)象,其中我們傳入的 children 參數(shù)會(huì)被作為根節(jié)點(diǎn)的 children 參數(shù)。這里非常好理解,按樹型數(shù)據(jù)結(jié)構(gòu)來想象就可以。所以生成 ast 的關(guān)鍵點(diǎn)就會(huì)聚焦到 parseChildren 這個(gè)函數(shù)上來。parseChildren 函數(shù)如果不去看它的源碼,見文之意也可以大致了解這是一個(gè)解析子節(jié)點(diǎn)的函數(shù)。接下來我們就來一起來看一下 AST 解析中最關(guān)鍵的 parseChildren 函數(shù),還是老規(guī)矩,為了幫助大家理解,我會(huì)精簡函數(shù)體內(nèi)的邏輯。
解析子節(jié)點(diǎn)
function parseChildren(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[]
): TemplateChildNode[] {
const parent = last(ancestors) // 獲取當(dāng)前節(jié)點(diǎn)的父節(jié)點(diǎn)
const ns = parent ? parent.ns : Namespaces.HTML
const nodes: TemplateChildNode[] = [] // 存儲(chǔ)解析后的節(jié)點(diǎn)
// 當(dāng)標(biāo)簽未閉合時(shí),解析對(duì)應(yīng)節(jié)點(diǎn)
while (!isEnd(context, mode, ancestors)) {/* 忽略邏輯 */}
// 處理空白字符,提高輸出效率
let removedWhitespace = false
if (mode !== TextModes.RAWTEXT && mode !== TextModes.RCDATA) {/* 忽略邏輯 */}
// 移除空白字符,返回解析后的節(jié)點(diǎn)數(shù)組
return removedWhitespace ? nodes.filter(Boolean) : nodes
}
從上文代碼中,可以知道 parseChildren 函數(shù)接收三個(gè)參數(shù),context:解析器上下文,mode:文本數(shù)據(jù)類型,ancestors:祖先節(jié)點(diǎn)數(shù)組。而函數(shù)的執(zhí)行中會(huì)首先從祖先節(jié)點(diǎn)中獲取當(dāng)前節(jié)點(diǎn)的父節(jié)點(diǎn),確定命名空間,以及創(chuàng)建一個(gè)空數(shù)組,用來儲(chǔ)存解析后的節(jié)點(diǎn)。之后會(huì)有一個(gè) while 循環(huán),判斷是否到達(dá)了標(biāo)簽的關(guān)閉位置,如果不是需要關(guān)閉的標(biāo)簽,則在循環(huán)體內(nèi)對(duì)源模板字符串進(jìn)行分類解析。之后會(huì)有一段處理空白字符的邏輯,處理完成后返回解析好的 nodes 數(shù)組。在大家對(duì)于 parseChildren 的執(zhí)行流程有一個(gè)初步理解之后,我們一起來看一下函數(shù)的核心,while 循環(huán)內(nèi)的邏輯。
在 while 中解析器會(huì)判斷文本數(shù)據(jù)的類型,只有當(dāng) TextModes 為 DATA 或 RCDATA 時(shí)會(huì)繼續(xù)往下解析。
第一種情況就是判斷是否需要解析 Vue 模板語法中的 “Mustache”語法 (雙大括號(hào)) ,如果當(dāng)前上下文中沒有 v-pre 指令來跳過表達(dá)式,并且源模板字符串是以我們指定的分隔符開頭的(此時(shí) context.options.delimiters 中是雙大括號(hào)),就會(huì)進(jìn)行雙大括號(hào)的解析。這里就可以發(fā)現(xiàn),如果當(dāng)你有特殊需求,不希望使用雙大括號(hào)作為表達(dá)式插值,那么你只需要在編譯前改變選項(xiàng)中的 delimiters 屬性即可。
接下來會(huì)判斷,如果第一個(gè)字符是 “<” 并且第二個(gè)字符是 '!'的話,會(huì)嘗試解析注釋標(biāo)簽,<!DOCTYPE 和 <!CDATA 這三種情況,對(duì)于 DOCTYPE 會(huì)進(jìn)行忽略,解析成注釋。
之后會(huì)判斷當(dāng)?shù)诙€(gè)字符是 “/” 的情況,“</” 已經(jīng)滿足了一個(gè)閉合標(biāo)簽的條件了,所以會(huì)嘗試去匹配閉合標(biāo)簽。當(dāng)?shù)谌齻€(gè)字符是 “>”,缺少了標(biāo)簽名字,會(huì)報(bào)錯(cuò),并讓解析器的進(jìn)度前進(jìn)三個(gè)字符,跳過 “</>”。
如果“</”開頭,并且第三個(gè)字符是小寫英文字符,解析器會(huì)解析結(jié)束標(biāo)簽。
如果源模板字符串的第一個(gè)字符是 “<”,第二個(gè)字符是小寫英文字符開頭,會(huì)調(diào)用 parseElement 函數(shù)來解析對(duì)應(yīng)的標(biāo)簽。
當(dāng)這個(gè)判斷字符串字符的分支條件結(jié)束,并且沒有解析出任何 node 節(jié)點(diǎn),那么會(huì)將 node 作為文本類型,調(diào)用 parseText 進(jìn)行解析。
最后將生成的節(jié)點(diǎn)添加進(jìn) nodes 數(shù)組,在函數(shù)結(jié)束時(shí)返回。
這就是 while 循環(huán)體內(nèi)的邏輯,且是 parseChildren 中最重要的部分。在這個(gè)判斷過程中,我們看到了雙大括號(hào)語法的解析,看到了注釋節(jié)點(diǎn)的怎樣被解析的,也看到了開始標(biāo)簽和閉合標(biāo)簽的解析,以及文本內(nèi)容的解析。精簡后的代碼在下方框中,大家可以對(duì)照上述的講解,來理解一下源碼。當(dāng)然,源碼中的注釋也是非常詳細(xì)了喲。
while (!isEnd(context, mode, ancestors)) {
const s = context.source
let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined
if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
/* 如果標(biāo)簽沒有 v-pre 指令,源模板字符串以雙大括號(hào) `{{` 開頭,按雙大括號(hào)語法解析 */
node = parseInterpolation(context, mode)
} else if (mode === TextModes.DATA && s[0] === '<') {
// 如果源模板字符串的第以個(gè)字符位置是 `!`
if (s[1] === '!') {
// 如果以 '<!--' 開頭,按注釋解析
if (startsWith(s, '<!--')) {
node = parseComment(context)
} else if (startsWith(s, '<!DOCTYPE')) {
// 如果以 '<!DOCTYPE' 開頭,忽略 DOCTYPE,當(dāng)做偽注釋解析
node = parseBogusComment(context)
} else if (startsWith(s, '<![CDATA[')) {
// 如果以 '<![CDATA[' 開頭,又在 HTML 環(huán)境中,解析 CDATA
if (ns !== Namespaces.HTML) {
node = parseCDATA(context, ancestors)
}
}
// 如果源模板字符串的第二個(gè)字符位置是 '/'
} else if (s[1] === '/') {
// 如果源模板字符串的第三個(gè)字符位置是 '>',那么就是自閉合標(biāo)簽,前進(jìn)三個(gè)字符的掃描位置
if (s[2] === '>') {
emitError(context, ErrorCodes.MISSING_END_TAG_NAME, 2)
advanceBy(context, 3)
continue
// 如果第三個(gè)字符位置是英文字符,解析結(jié)束標(biāo)簽
} else if (/[a-z]/i.test(s[2])) {
parseTag(context, TagType.End, parent)
continue
} else {
// 如果不是上述情況,則當(dāng)做偽注釋解析
node = parseBogusComment(context)
}
// 如果標(biāo)簽的第二個(gè)字符是小寫英文字符,則當(dāng)做元素標(biāo)簽解析
} else if (/[a-z]/i.test(s[1])) {
node = parseElement(context, ancestors)
// 如果第二個(gè)字符是 '?',當(dāng)做偽注釋解析
} else if (s[1] === '?') {
node = parseBogusComment(context)
} else {
// 都不是這些情況,則報(bào)出第一個(gè)字符不是合法標(biāo)簽字符的錯(cuò)誤。
emitError(context, ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME, 1)
}
}
}
// 如果上述的情況解析完畢后,沒有創(chuàng)建對(duì)應(yīng)的節(jié)點(diǎn),則當(dāng)做文本來解析
if (!node) {
node = parseText(context, mode)
}
// 如果節(jié)點(diǎn)是數(shù)組,則遍歷添加進(jìn) nodes 數(shù)組中,否則直接添加
if (isArray(node)) {
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
} else {
pushNode(nodes, node)
}
}
解析模板元素 Element
在 while 的循環(huán)內(nèi),各個(gè)分支判斷分支內(nèi),我們能看到 node 會(huì)接收各種節(jié)點(diǎn)類型的解析函數(shù)的返回值。而這里我會(huì)詳細(xì)的說一下 parseElement 這個(gè)解析元素的函數(shù),因?yàn)檫@是我們?cè)谀0逯杏玫淖铑l繁的場(chǎng)景。
我先把 parseElement 的源碼精簡一下貼上來,然后來嘮一嘮里面的邏輯。
function parseElement(
context: ParserContext,
ancestors: ElementNode[]
): ElementNode | undefined {
// 解析起始標(biāo)簽
const parent = last(ancestors)
const element = parseTag(context, TagType.Start, parent)
// 如果是自閉合的標(biāo)簽或者是空標(biāo)簽,則直接返回。voidTag例如: `<img>`, `<br>`, `<hr>`
if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
return element
}
// 遞歸的解析子節(jié)點(diǎn)
ancestors.push(element)
const mode = context.options.getTextMode(element, parent)
const children = parseChildren(context, mode, ancestors)
ancestors.pop()
element.children = children
// 解析結(jié)束標(biāo)簽
if (startsWithEndTagOpen(context.source, element.tag)) {
parseTag(context, TagType.End, parent)
} else {
emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
const first = children[0]
if (first && startsWith(first.loc.source, '<!--')) {
emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
}
}
}
// 獲取標(biāo)簽位置對(duì)象
element.loc = getSelection(context, element.loc.start)
return element
}
首先我們會(huì)獲取當(dāng)前節(jié)點(diǎn)的父節(jié)點(diǎn),然后調(diào)用 parseTag 函數(shù)解析。
parseTag 函數(shù)會(huì)按的執(zhí)行大體是以下流程:
- 首先匹配標(biāo)簽名。
- 解析元素中的 attribute 屬性,存儲(chǔ)至 props 屬性
- 檢測(cè)是否存在 v-pre 指令,若是存在的話,則修改 context 上下文中的 inVPre 屬性為 true
- 檢測(cè)自閉合標(biāo)簽,如果是自閉合,則將 isSelfClosing 屬性置為 true
- 判斷 tagType,是 ELEMENT 元素還是 COMPONENT 組件,或者 SLOT 插槽
- 返回生成的 element 對(duì)象
由于篇幅原因,我這里就不貼 parseTag 的源碼了,感興趣的同學(xué)可以自行查看。
在獲取到 element 對(duì)象后,會(huì)判斷 element 是否是自閉合標(biāo)簽,或者是空標(biāo)簽,例如 <img>, <br>, <hr> ,如果是這種情況,則直接返回 element 對(duì)象。
然后我們會(huì)嘗試解析 element 的子節(jié)點(diǎn),將 element 壓入棧中中,然后遞歸的調(diào)用 parseChildren 來解析子節(jié)點(diǎn)。
const parent = last(ancestors)
再回頭看看 parseChildren 以及 parseElement 中的這行代碼,就可以發(fā)現(xiàn)在將 element 入棧后,我們拿到的父節(jié)點(diǎn)就是當(dāng)前節(jié)點(diǎn)。在解析完畢后,調(diào)用 ancestors.pop() ,使當(dāng)前解析完子節(jié)點(diǎn)的 element 對(duì)象出棧,將解析后的 children 對(duì)象賦值給 element 的 children 屬性,完成 element 的子節(jié)點(diǎn)解析,這里是個(gè)很巧妙的設(shè)計(jì)。
最后匹配結(jié)束標(biāo)簽,設(shè)置 element 的 loc 位置信息,返回解析完畢的 element 對(duì)象。
示例:模板元素解析
請(qǐng)看下方我們要解析的模板,圖片中是解析過程中,保存解析后節(jié)點(diǎn)的棧的存儲(chǔ)情況,
<div>
<p>Hello World</p>
</div>

圖中的黃色矩形是一個(gè)棧,當(dāng)開始解析時(shí),parseChildren 首先會(huì)遇到 div 標(biāo)簽,開始調(diào)用的 parseElement 函數(shù)。通過 parseTag 函數(shù)解析出了 div 元素,并將它壓入棧中,遞歸解析子節(jié)點(diǎn)。第二次調(diào)用 parseChildren 函數(shù),遇見 p 元素,調(diào)用 parseElement 函數(shù),將 p 標(biāo)簽壓入棧中,此時(shí)棧中有 div 和 p 兩個(gè)標(biāo)簽。再次解析 p 中的子節(jié)點(diǎn),第三次調(diào)用 parseChildren 標(biāo)簽,這次不會(huì)匹配到任何標(biāo)簽,不會(huì)生成對(duì)應(yīng)的 node,所以會(huì)通過 parseText 函數(shù)去生成文本,解析出 node 為 HelloWorld,并返回 node。
將這個(gè)文本類型的 node 添加進(jìn) p 標(biāo)簽的 children 屬性后,此時(shí) p 標(biāo)簽的子節(jié)點(diǎn)解析完畢,彈出祖先棧,完成結(jié)束標(biāo)簽的解析后,返回 p 標(biāo)簽對(duì)應(yīng)的 element 對(duì)象。
p 標(biāo)簽對(duì)應(yīng)的 node 節(jié)點(diǎn)生成,并在 parseChildren 函數(shù)中返回對(duì)應(yīng) node。
div 標(biāo)簽在接收到 p 標(biāo)簽的 node 后,添加進(jìn)自身的 children 屬性中,出棧。此時(shí)祖先棧中就空空如也了。而 div 的標(biāo)簽完成閉合解析的邏輯后,返回 element 元素。
最終 parseChildren 的第一次調(diào)用返回結(jié)果,生成了 div 對(duì)應(yīng)的 node 對(duì)象,也返回了結(jié)果,將這個(gè)結(jié)果作為 createRoot 函數(shù)的 children 參數(shù)傳入,生成根節(jié)點(diǎn)對(duì)象,完成 ast 解析。
后記
這篇文章我們從 ast 生成時(shí)調(diào)用的 baseParse 函數(shù)分析,再到 baseParse 返回 createRoot 的調(diào)用結(jié)果,一直到細(xì)化的講解了 parseChildren 解析子節(jié)點(diǎn)函數(shù)中的其中某一個(gè)具體解析器的執(zhí)行過程。最后通過一個(gè)簡單模板舉例,看 Vue 的解析器是如何解析以及分析祖先棧中的情況,比較全面的講解了解析器的工作流程。
如果這篇文章能輔助你來了解 Vue3 中解析器的工作流程,希望能給文章點(diǎn)贊哦。??