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

圖中的黃色矩形是一個棧,當開始解析時,parseChildren 首先會遇到 div 標簽,開始調用的 parseElement 函數。通過 parseTag 函數解析出了 div 元素,并將它壓入棧中,遞歸解析子節(jié)點。第二次調用 parseChildren 函數,遇見 p 元素,調用 parseElement 函數,將 p 標簽壓入棧中,此時棧中有 div 和 p 兩個標簽。再次解析 p 中的子節(jié)點,第三次調用 parseChildren 標簽,這次不會匹配到任何標簽,不會生成對應的 node,所以會通過 parseText 函數去生成文本,解析出 node 為 HelloWorld,并返回 node。
將這個文本類型的 node 添加進 p 標簽的 children 屬性后,此時 p 標簽的子節(jié)點解析完畢,彈出祖先棧,完成結束標簽的解析后,返回 p 標簽對應的 element 對象。
p 標簽對應的 node 節(jié)點生成,并在 parseChildren 函數中返回對應 node。
div 標簽在接收到 p 標簽的 node 后,添加進自身的 children 屬性中,出棧。此時祖先棧中就空空如也了。而 div 的標簽完成閉合解析的邏輯后,返回 element 元素。
最終 parseChildren 的第一次調用返回結果,生成了 div 對應的 node 對象,也返回了結果,將這個結果作為 createRoot 函數的 children 參數傳入,生成根節(jié)點對象,完成 ast 解析。
到此這篇關于Vue3 AST解析器 源碼解析的文章就介紹到這了,更多相關Vue3 AST解析器內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Vue2.0+Vux搭建一個完整的移動webApp項目的示例
這篇文章主要介紹了Vue2.0+Vux搭建一個完整的移動webApp項目的示例,小編覺得挺不錯的,現在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2019-03-03
vue 中this.$set 動態(tài)綁定數據的案例講解
這篇文章主要介紹了vue 中this.$set 動態(tài)綁定數據的案例講解,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-01-01
Vue動態(tài)組件?component?:is的使用代碼示范
vue?動態(tài)組件用于實現在指定位置上,動態(tài)加載不同的組件,這篇文章主要介紹了Vue動態(tài)組件?component?:is的使用,需要的朋友可以參考下2023-09-09

