Angular 是 Google 親兒子,React 是 Facebook 小正太,那咱為啥偏偏選擇了 Vue 下手,一句話,Vue 是咱見過的最對脾氣的 MVVM 框架。之前也使用過 knockout,angular,react 這些框架,但都沒有讓咱產生 follow 的衝動。直到見到 Vue,... ...
閱讀目錄
本篇探討 Vue 根據 html 模板片段構建出 AST 的具體過程。這對 Vue 的使用通常沒什麼幫助,但熟悉這個過程會對 Vue 的內部工作原理有更清晰的認識。
主代碼位置:Vue 項目的 src/compiler/parser/html-parser.js 文件。
AST 節點定義
AST 是由一個個節點組成的,正如 DOM 樹是由 DOM 節點組成的一樣。
Vue 使用正則表達式匹配 html 標簽,並將標簽解析成 AST 節點,所以繼續下麵的內容之前最好對正則表達式有一定瞭解。
Vue 的 AST 節點數據結構定義如下:
// 節點包含 3 種類型:標簽元素、普通文本、插值表達式
declare type ASTNode = ASTElement | ASTText | ASTExpression;
declare type ASTElement = {
type: 1;
tag: string;
attrsList: [];
parent: ASTElement | void;
children: [];
}
declare type ASTExpression = {
type: 2;
expression: string;
text: string;
}
declare type ASTText = {
type: 3;
text: string;
isComment: boolean;
}
declare type
是 flow.js 的語法,用於靜態類型檢查。請留意 ASTElement 定義中的 parent
和 children
欄位,它們將是用於建立父子關係從而構成一顆樹的依據。
接下來開始剖析代碼細節。
標簽的正則匹配
下麵是比較枯燥的正則式環節。
1、匹配標簽名
const tagName = '([a-zA-Z_][\\w\\-\\.]*)'
需要註意的是,不同於[a-zA-Z_]
,正則式 \w
用於匹配包括下劃線的任何單詞字元,包括中文字元。因此上面一行正則式的意思是匹配以英文字母或下劃線開頭([a-zA-Z_]
)接若幹個單詞字元或下劃線([\w\-\.]*
)的字元串。
該正則式可匹配到 <div id="index">
的 div 名稱部分。
2、匹配標簽屬性
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
這行正則式用於匹配 key = value
這種屬性鍵值對,雖然看起來挺複雜,但其實是挺簡單的匹配,主要是相容處理屬性值的雙引號,單引號和數字等寫法。
該正則式可匹配到 <div id="index">
的 id="index" 屬性部分。
3、匹配開始標簽
const startTagOpen = new RegExp(`^<${tagName}`)
const startTagClose = /^\s*(\/?)>/
startTagOpen
用於匹配開始標簽的左邊開頭部分,即 <div id="index">{{msg}}</div>
的 <div
部分。
startTagClose 用於匹配開始標簽的右邊閉合部分,即 >{{msg}}</div>
左邊開頭的 > 部分,請註意這一點,因為 Vue 是用步步蠶食(也就是解析一點,剪掉一點)的方法一點一點進行解析的。
開始標簽?結束標簽?
在這裡把 <div></div>
的 <div>
叫做開始標簽(startTag),把 </div>
叫做結束標簽(endTag)。
4、匹配結束標簽
const endTag = new RegExp(`^<\\/${tagName}[^>]*>`)
註意正則式中 ^
放在首位表示匹配行首。因此該正則式可匹配到 </div><h1></h1>
的 </div>
。
解析 html 模板主要就用到這 4 個關鍵的正則式,接下來開始正式解析。
解析用到的工具方法
1、advance 方法
該方法用於步步蠶食,也就是每解析一部分,就從待解析的模板片段中去掉一部分,直到解析完畢,html.length
為 0
:
let index = 0;
function advance (n) {
index += n
html = html.substring(n)
}
比如 <div id="index">
經過 advance(4)
就變成 id="index">
,index
變數也從 0
變成了 4
,表示已經解析了 4 個字元。
2、createASTElement 方法
這個方法用於構造一個 AST 元素節點(對應上面的 AST 節點類型定義),每解析一個標簽就要生成一個這樣的 AST 元素節點。註意傳入的 parent
參數,除了根元素,其它節點一般都有一個 parent 元素,還是那句話,多類比 DOM 樹。
function createASTElement (tag, attrs, parent){
return {
type: 1,
tag,
lowerCasedTag: tag.toLowerCase(),
attrsList: attrs,
parent,
children: []
}
}
解析開始標簽
接下來的內容就比較消耗腦細胞了,建議先仔細瞭解一下字元串的 match 方法,因為之後的解析里會多處用到。
老規矩,先看方法定義:
let root
let currentParent
let stack = [] // 標簽元素棧
function parseStartTag () {
//-- 第一步 首先匹配開始標簽的左邊開頭部分 --
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
//-- 第二步 迴圈解析開始標簽上的每一個屬性鍵值對 --
let end, attr
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push({
name: attr[1],
value: attr[3]
})
}
//-- 第三步 匹配到開始標簽的閉合部分,至此開始標簽解析結束 --
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
}
// 解析完標簽創建一個 AST 節點
let element = createASTElement(match.tagName, match.attrs, currentParent)
if(!root){
root = element
}
if(currentParent){
currentParent.children.push(element);
}
// 自閉合就不用壓入棧中了
if (!match.unarySlash) {
stack.push(element)
currentParent = element
}
}
}
為了在解析到結束標簽時找到與之對應的開始標簽,Vue 通過維護一個標簽棧 stack
來匹配對應的標簽。currentParent
用於指向棧頂的 AST 節點。
以解析 <div id="index" class="content">
為例,
經過第一步解析標簽名,解析的結果如下:
match = {
tagName: "div",
attrs: [],
start: 0
}
此時 html 也經 advance 成了 id="index" class="content">
。
接著經過第二步解析屬性鍵值對,解析的結果變成:
match = {
tagName: "div",
attrs: [
{
"name": "id",
"value": "index"
},
{
"name": "class",
"value": "content"
}
],
start: 0
}
此時 html 經過多次 advance 成了 >
。
然後經過第三步解析開始標簽閉合部分,並且生成了一個 AST 節點,最終的變數狀態如下:
match = {
tagName: "div",
attrs: [
{
"name": "id",
"value": "index"
},
{
"name": "class",
"value": "content"
}
],
start: 0,
end: 32,
unarySlash: "",
}
root = element
stack = [element]
currentParent = element
此時 html 經過 advance 已經變成了空字元串,解析完畢。
什麼是棧?
類似於數組,是一種常用的線性表數據結構,可以使用數組輕鬆地實現。後進先出的操作方式特別適合 html 標簽的這種嵌套語法結構。
解析結束標簽
解析結束標簽的關鍵點是找到與之對應的開始標簽。
先看方法定義:
function parseEndTag () {
const end = html.match(endTag);
if (end) {
advance(end[0].length)
let tagName = end[1], lowerCasedTagName = tagName.toLowerCase()
let pos
// 從棧頂往棧底找,直到找到棧中離的最近的同類型標簽
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
// 如果找到了就取出對應的開始標簽
if (pos >= 0) {
stack.length = pos
currentParent = stack[stack.length - 1]
}
}
}
可以看到,在解析結束標簽時,會去找棧中離的最近的同類型標簽。在找到後會取出找到的節點並更新 currentParent
指向,也就是說假設 stack 現在為 ['div', 'p', 'a']
,經過 parseEndTag
之後可能就會變成 ['div', 'p']
,currentParent
也從指向 a
變成了指向棧頂的 p
。
解析文本
文本為什麼需要解析?別忘了,Vue 是支持在文本中插值的,即 <div>hello, {{msg}}</div>
的 {{msg}}
。文本解析就是解析這些混在文本中的表達式。
建議先瞭解一下正則式的 exec 方法,本段代碼在遍歷時使用了它,註意它與字元串的 match 方法不同。
先看方法定義:
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
function parseText(text){
if (defaultTagRE.test(text)) {
// tokens 用於分割普通文本和插值文本
const tokens = []
let lastIndex = defaultTagRE.lastIndex = 0
let match, index
while ((match = defaultTagRE.exec(text))) {
index = match.index
// push 普通文本
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
// push 插值表達式
tokens.push(`_s(${match[1].trim()})`)
// 游標前移
lastIndex = index + match[0].length
}
// 將剩餘的普通文本壓入 tokens 中
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
// 生成 ASTExpression 節點
currentParent.children.push({
type: 2,
expression: tokens.join('+'),
text
})
}else{
// 生成 ASTText 節點
currentParent.children.push({
type: 3,
text
});
}
}
可以看到,並沒有什麼特別的地方,只是遍歷傳入的字元串並將所有插值摘出來。例如 hello, {{msg}}
會被分割成 ['"hello"', '_s(msg)']
,註意普通文本是被 JSON.stringify
了的,這樣在後面 tokens.join('+')
時才會變成 "hello"+_s(msg)
這種所期望的格式,也就是最簡單的字元串和變數拼接。
文本通常就是葉子節點了,因此文本和表達式的節點定義(ASTText和ASTExpression)中並沒有 parent
和 children
欄位。
解析整塊 HTML 模板
終於到最後了,這是咱這幾年寫過的最長文章了o(╥﹏╥)o
html 文檔的結構基本上就是 <tag>text</tag>
這類標簽的各種嵌套,套來套去套出一個頁面。上面解析各部分(開始標簽、結束標簽、文本)的方法都已經有了,接下來就是使用上面的方法將整塊 html 模板一層一層剝開,從而構建出整棵 AST。
先看方法定義:
let html
function parseHTML(_html){
html = _html
while (html) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
//-- 匹配開始標簽 --
const startTagMatch = html.match(startTagOpen)
if (startTagMatch) {
parseStartTag()
continue
}
//-- 匹配結束標簽 --
const endTagMatch = html.match(endTag)
if (endTagMatch) {
parseEndTag()
continue
}
}
//-- 匹配文本 --
let text, rest
if (textEnd >= 0) {
rest = html.slice(textEnd)
text = html.substring(0, textEnd)
advance(textEnd)
}
if (textEnd < 0) {
text = html
html = ''
}
text && parseText(text)
}
return root
}
可以看到,parseHTML
是迴圈一截一截把整塊 html 蠶食掉的。返回值 root
就是對生成的 AST 的引用,其實就是一個被精心組織的 JSON 對象,上篇已經提到,使用 JSON 描述樹形結構具有天然優勢。
現在看看忙活了半天的成果:
let tpl = `<div id="index"><p>hello, {{msg}}</p> by DOM哥</div>`
console.info(parseHTML(tpl))
控制台輸出截圖如下:
parseHTML 執行結果
Vue 解析 HTML 的主流程基本上就是這樣,由於是基於 HTML,還是比較簡單的。
未提及的細節
Vue 的實際實現做了大量的相容性處理,有針對某些瀏覽器(IE:看我乾什麼)的,也有針對 HTML 標簽的,比如 <p>
標簽既可以有結束標簽,也可以沒有結束標簽,因此需要特殊處理。另外還要考慮註釋的解析,特殊 html 標簽如 Doctype 的處理。總之需要考慮的地方很多,因此實際實現比上面要複雜的多,但處理的思路基本上是一樣的。
Vue 代碼分割的很嚴重,因此上面的實現代碼不可能全部集成在一個文件里,而是分成了好幾個小模塊,比如生成 AST 節點的模塊是抽出來的,處理文本的模塊也是單獨抽出來的。
如果想要錙銖必較地咀嚼每一行代碼,這是非常困難的,而且寸步難行,甚至最後會半途而廢。研究源碼最主要的是去學習其中的思路,而不要糾結在一字一句。
還記得 Vue 編譯器編譯成 render 函數的 3 個步驟嗎,生成 AST,優化 AST,生成 render 函數。本篇暫告一段落,將在下篇繼續研究 Vue 是如何優化 AST 的以及如何根據 AST 生成 render 函數。
本系列會以每周一篇的速度持續更新,喜歡的小伙伴記得點關註哦。