vue 的模板编译—ast(抽象语法树) 详解与实现
首先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,需要大量的循环与匹配,需要考虑不同字符串的情况,而这种情况正是我们静下心来好好思考的。本人才疏学浅,有问题请批评指出。