首先AST是什么?
在計算機科學中,抽象語法樹(abstract syntax tree或者縮寫為AST),或者語法樹(syntax tree),是源代碼的抽象語法結構的樹狀表現形式,這里特指編程語言的源代碼。
我們可以理解為:把 template(模板)解析成一個對象,該對象是包含這個模板所以信息的一種數據,而這種數據瀏覽器是不支持的,為Vue后面的處理template提供基礎數據。
這里我模擬Vue去實現把template解析成ast,代碼已經分享到 https://github.com/zhangKunUserGit/vue-ast,具體邏輯都用文字進行了描述,請大家下載運行。
基礎
(1)了解正則表達式,熟悉match,test, exec 等等JavaScript匹配方法;
(2)了解JavaScript柯里化;
獲取模板
import { compileToFunctions } from './compileToFunctions'; // Vue 對象 function Vue(options) { // 獲取模板 const selected = document.querySelector(options.el); this.$mount(selected); } // mount 模板 Vue.prototype.$mount = function (el) { const html = el.outerHTML; compileToFunctions(html, {}); }; export default Vue;
這里我僅僅使用querySelector的方式獲取模板,其他的方式沒有處理。因為我們的重點是如何解析模板。
JavaScript 柯里化
import { createCompiler } from "./createCompiler"; const { compileToFunctions } = createCompiler({}); export { compileToFunctions }
import { parse } from "./parse"; function createCompileToFunctionFn(compile) { return function compileToFunctions(template, options) { const compiled = compile(template, options) } } function createCompilerCreator(baseCompile) { return function createCompiler() { function compile(template, options) { const compiled = baseCompile(template, options) } return { compile, compileToFunctions: createCompileToFunctionFn(compile) } } } // js柯里化是逐步傳參,逐步縮小函數的適用范圍,逐步求解的過程。 export const createCompiler = createCompilerCreator(function(template, options) { console.log('這是要處理的template字符串 -->', template); const ast = parse(template.trim(), options); console.log('這是處理后的ast(抽象語法樹)字符串 -->', ast); });
這里我按照Vue源碼邏輯書寫的,柯里化形式的代碼看了容易讓人暈,但是它也有它的好處,在這里體現的淋漓盡致,通過柯里化可以逐步傳參,逐步求解。現在忽略此處,直接看createCompilerCreator()里面的函數就可以了。
解析
我們知道HTML模板是有標簽、文本、注釋組成的,這里不考慮注釋,而標簽又分為單元素標簽(如:img,br 等)和普通標簽(如: div, table 等)。文本又分為帶有綁定的文本(含有{{}} 雙大括號)和普通文本(不含有{{}} 雙大括號)。
所以解析HTML最少要分兩個方法,一個處理標簽,一個處理文本,但是無論單元素還是普通標簽都有開始和閉合,只是形式不一樣罷了。所以把解析HTML 可以分成start(處理開始標簽)、end(處理結束標簽)、char(處理文本):
export function parse(template, options) { // 暫存沒有閉合的標簽元素基本信息, 當找到閉合標簽后清除存在於stack里面的元素 const stack = []; // 這里就是解析后的最終數據,這里主要應用了引用類型的特性,最終使root滾雪球一樣,保存標簽的所有信息 let root; // 當前需要處理的元素父級元素 let currentParent; parseHTML(template, { start(tag, attrs, unary) {}, end() {}, chars(text) {}, }); // 把解析后返回出去,這個就是ast(抽象語法樹) return root; }
此時,我們調用了parseHTML函數,看看它干了什么:
export function parseHTML(html, options) { const stack = []; let index = 0; let last, lastTag; // 循環html字符串 while (html) { last = html; // 處理非script,style,textarea的元素 if(!lastTag || !isPlainTextElement(lastTag)) { let textEnd = html.indexOf('<'); if (textEnd === 0) { // 結束標簽 const endTagMatch = html.match(endTag); if (endTagMatch) { const curIndex = index; advance(endTagMatch[0].length); parseEndTag(endTagMatch[1], curIndex, index); continue; } // 開始標簽 const startTagMatch = parseStartTag(); if (startTagMatch) { handleStartTag(startTagMatch); continue; } } let text; // 判斷 '<' 首次出現的位置,如果大於等於0,截取這段,賦值給text, 並刪除這段字符串 // 這里有可能是空文本,如這種 ' '情況, 他將會在chars里面處理 if (textEnd >= 0) { text = html.substring(0, textEnd); advance(textEnd); } else { text = html; html = ''; } // 處理文本標簽 if (text) { options.chars(text); } } else { // 處理script,style,textarea的元素, // 這里我們只處理textarea元素, 其他的兩種Vue 會警告,不提倡這么寫 let endTagLength = 0; const stackedTag = lastTag.toLowerCase(); // 緩存匹配textarea 的正則表達式 const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i')); // 清除匹配項,處理text const rest = html.replace(reStackedTag, function(all, text, endTag) { endTagLength = endTag.length; options.chars(text); return '' }); index += html.length - rest.length; html = rest; parseEndTag(stackedTag, index - endTagLength, index); } } }
我們第一眼看到的就是那個藍色的while循環。它在那兒默默無聞的循環,直到html為空。在循環體中,用正則判斷html字符串是開始標簽、結束標簽或文本標簽,並分別進行處理。
開始標簽
/** * 處理解析后的屬性,重新分割並保存到attrs數組中 * @param match */ function handleStartTag(match) { const tagName = match.tagName; const unary = isUnaryTag(tagName) || !!match.unarySlash; const l = match.attrs.length; const attrs = new Array(l); for (let i = 0; i < l; i += 1) { const args = match.attrs[i]; attrs[i] = { name:args[1], // 屬性名 value: args[3] || args[4] || args[5] || '' // 屬性值 }; } // 非單元素 if (!unary) { // 因為我們的parse必定是深度優先遍歷, // 所以我們可以用一個stack來保存還沒閉合的標簽的父子關系, // 並且標簽結束時一個個pop出來就可以了 stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs, }); // 緩存這次的開始標簽 lastTag = tagName; } options.start(tagName, attrs, unary, match.start, match.end); } /** * 匹配到元素的名字和屬性,保存到match對象中並返回 * @returns {{tagName: *, attrs: Array, start: number}} */ 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); // 把元素屬性都取出,並添加到attrs中 match.attrs.push(attr); } if (end) { match.unarySlash = end[1]; advance(end[0].length); // start 到 end 這段長度就是這次執行,所處理的字符串長度 match.end = index; return match; } } }
具體邏輯我已經寫到代碼中了,其中應用了大量的正則和循環,當匹配到后就調用advance() 刪除匹配的字符串更新html。
結束標簽
/** * 解析關閉標簽, * 查找我們之前保存到stack棧中的元素, * 如果找到了,也就代表這個標簽的開始和結束都已經找到了,此時stack中保存的也就需要刪除(pop)了 * 並且緩存最近的標簽lastTag * @param tagName * @param start * @param end */ function parseEndTag(tagName, start, end) { const lowerCasedTag = tagName && tagName.toLowerCase(); let pos = 0; if (lowerCasedTag) { for (pos = stack.length -1; pos >= 0; pos -= 1) { if (stack[pos].lowerCasedTag === lowerCasedTag) { break; } } } if (pos >= 0) { // 關閉 pos 以后的元素標簽,並更新stack數組 for (let i = stack.length - 1; i >= pos; i -= 1) { options.end(stack[i].tag, start, end); } stack.length = pos; // stack 取出數組存儲的最后一個元素 lastTag = pos && stack[pos - 1].tag; } }
此時當執行parseEndTag()函數,更新stack和lastTag。
上面提到start(開始標簽)
/** * 這個和end相對應,主要處理開始標簽和標簽的屬性(內置和普通屬性), * @param tag 標簽名 * @param attrs 元素屬性 * @param unary 該元素是否單元素, 如img */ start(tag, attrs, unary) { // 創建ast容器 let element = createASTElement(tag,attrs, currentParent); // 下面是加工、處理各種Vue支持的內置屬性和普通屬性 processFor(element); processIf(element); processOnce(element); processElement(element); if (!root) { root = element; } else if (!stack.length && root.if && (element.elseif || element.else)) { // 在element的ifConditions屬性中加入condition addIfCondition(root, { exp: element.elseif, block: element }) } if (currentParent) { if (element.elseif || element.else) { processIfConditions(element, currentParent); } else if (element.slotScope) { // 父級元素是普通元素 currentParent.plain = false; const name = element.slotTarget || '"default"'; (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element; } else { // 把當前元素添加到父元素的children數組中 currentParent.children.push(element); // 設置當前元素的父元素 element.parent = currentParent; } } // 非單元素,更新父級和保存該元素 if (!unary) { currentParent = element; stack.push(element); } },
上面提到end(結束標簽)
/** * 閉合元素,更新stack和currentParent */ end() { // 取出stack中最后一個元素,其實這也是需要閉合元素的開始標簽,如</div> 的開始標簽就是<div> // 此時取出的element包含該元素的所有信息,包括他的子元素信息 const element = stack[stack.length - 1]; // 取出當前元素的最后一個子節點 const lastNode = element.children[element.children.length - 1]; // 如果最后一個子節點是空文本節點,清除當前子節點, 為什么這么做呢? // 因為我們在寫HTML時,標簽之間都有間距,有時候就需要這個間距才能達到我們想要的效果, // 比如:<div> <span>111</span> <span>222</span> </div> // 此時111與222之間就有一格的間距,在ast模板解析時,這個不能忽略, // 此時的div的子節點會解析成三個數組, 中間的就是一個文本,只是這個文本是個空格, // 而222的span標簽后面的空格我們是不需要的,因為如果我們寫了,div的兄弟節點之間會有一個空格的。 // 所以我們需要清除children數組中沒有用的項 if (lastNode && lastNode.type === 3 && lastNode.text === ' ') { element.children.pop(); } // 下面才是最重要的,也是end方法真正要做的, // 就是找到了閉合標簽,就把保存的開始標簽的信息清除,並更新currentParent stack.length -= 1; currentParent = stack[stack.length - 1]; },
上面提到的char(文本標簽)
/** * 處理文本和{{}} * @param text 文本內容 */ chars(text) { // 如果是文本,沒有父節點,直接返回 if (!currentParent) { return; } const children = currentParent.children; // 判斷與處理text, 如果children有值,text為空,那么text = ' '; 原因在end中 text = text.trim() ? text : children.length ? ' ' : ''; if (text) { // 解析文本,處理{{}} 這種形式的文本 const expression = parseText(text); if (text !== ' ' && expression) { children.push({ type: 2, expression, text, }); } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') { children.push({ type: 3, text, }) } } },
這里我們重點需要說一下parseText()方法,解釋都寫在了代碼中。
const tagRE = /\{\{((?:.|\n)+?)\}\}/g; export function parseText(text) { if (tagRE.test(text)) { return; } const tokens = []; let lastIndex = tagRE.lastIndex = 0; let match, index; // exec中不管是不是全局的匹配,只要沒有子表達式, // 其返回的都只有一個元素,如果是全局匹配,可以利用lastIndex進行下一個匹配, // 匹配成功后lastIndex的值將會變為上次匹配的字符的最后一個位置的索引。 // 在設置g屬性后,雖然匹配結果不受g的影響, // 返回結果仍然是一個數組(第一個值是第一個匹配到的字符串,以后的為分組匹配內容), // 但是會改變index和 lastIndex等的值,將該對象的匹配的開始位置設置到緊接這匹配子串的字符位置, // 當第二次調用exec時,將從lastIndex所指示的字符位置 開始檢索。 while ((match = tagRE.exec(text))) { index = match.index; // 當文本標簽中既有{{}} 在其左邊又有普通文本時, // 如:<span>我是普通文本{{value}}</span>, 就會執行下面的方法,添加到tokens數組中。 if (index > lastIndex) { tokens.push(JSON.stringify(text.slice(lastIndex, index))); } // 把匹配到{{}}中的tag 添加到tokens數組中 const exp = match[1].trim(); tokens.push(`_s(${exp})`); lastIndex = index + match[0].length } // 當文本標簽中既有{{}} 在其右邊又有普通文本時, // 如:<span>{{value}} 我是普通文本</span>, 就會執行下面的方法,添加到tokens數組中。 if (lastIndex < text.length) { tokens.push(JSON.stringify(text.slice(lastIndex))); } return tokens.join('+'); }
區分parse和parseHTML
通過上面的代碼我們大概了解了實現方式,但是我們可能暫時無法區分parse和parseHTML方法都做了什么。因為parse里面調用了parseHTML,我們先講講它。
parseHTML: 用正則匹配的方式,逐一循環HTML字符串,分類不同匹配項,保存最基本的tagName(標簽名),attrs(屬性),此時屬性並沒有區分是內置屬性還是普通屬性,只是簡單的分隔了屬性名和屬性值。從函數名中可以看到加了HTML
比如:attrs 可能是這樣的:
attrs = [ { name: '@click', value: 'myMethod' }, { name: ':class', value: 'my-class' }, { name: 'type', value: 'button' }, { name: 'v-if', value: 'show' } ];
parse: 從parseHTML解析的基本屬性數組中重新解析,區分不同屬性做不同處理,普通屬性與內置屬性處理方式是不一樣的。並且判斷該元素是在哪個位置,也就是確定該元素的父節點、兄弟節點、子節點,最終形成ast。
如何理解stack
stack翻譯成漢語就是“棧”。這里我們可以理解為一個容器,存儲開始標簽的屬性和標簽名。這里Vue進行了巧妙的設計:
當是開始標簽並且標簽是普通標簽(如:div),就push到數組最后面,
當是結束標簽時,找到保存到stack中的項,然后刪除找到的項,刪除就是代表着標簽閉合。
注意:stack 是按照字符串先后順序存儲的,所以我們在接下來解析html字符串時,遇到的閉合標簽就是stack存儲的最后一項。如:
<div><span></span></div>
當執行到</span>字符串前,stack存儲結果:
stack = [div, span];
在執行</span>時,找到stack最后一項,就是span的開始標簽(此時里面包含標簽名和元素屬性)。我們刪除stack中的span(span標簽閉合,span元素的解析結束),此時stack 就只剩下 [div], 以此類推。
總結
最后看看運行前后的效果:
模板解析為ast,需要大量的循環與匹配,需要考慮不同字符串的情況,而這種情況正是我們靜下心來好好思考的。本人才疏學淺,有問題請批評指出。