vue的模板编译—ast(抽象语法树)详解与实现
⾸先AST是什么?
在计算机科学中,抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这⾥特指编程语⾔的源代码。
我们可以理解为:把 template(模板)解析成⼀个对象,该对象是包含这个模板所以信息的⼀种数据,⽽这种数据浏览器是不⽀持的,为Vue后⾯的处理template提供基础数据。
这⾥我模拟Vue去实现把template解析成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 = im(), 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 = LowerCase();
/
/ 缓存匹配textarea 的正则表达式
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'));
// 清除匹配项,处理text
const rest = place(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: LowerCase(),
attrs,
});
// 缓存这次的开始标签
lastTag = tagName;
}
options.start(tagName, attrs, unary, match.start, d);
}
/**
* 匹配到元素的名字和属性,保存到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 这段长度就是这次执⾏,所处理的字符串长度
return match;
}
}
}
具体逻辑我已经写到代码中了,其中应⽤了⼤量的正则和循环,当匹配到后就调⽤advance() 删除匹配的字符串更新html。
结束标签
/**
* 解析关闭标签,
* 查我们之前保存到stack栈中的元素,
* 如果到了,也就代表这个标签的开始和结束都已经到了,此时stack中保存的也就需要删除(pop)了
* 并且缓存最近的标签lastTag
* @param tagName
* @param start
* @param end
*/
function parseEndTag(tagName, start, end) {
const lowerCasedTag = tagName && LowerCase();
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) {
}
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 && pe === 3 && === ' ') {
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 = im()
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 (st(text)) {
return;
}
const tokens = [];
let lastIndex = tagRE.lastIndex = 0;
let match, index;
/
/ exec中不管是不是全局的匹配,只要没有⼦表达式,
// 其返回的都只有⼀个元素,如果是全局匹配,可以利⽤lastIndex进⾏下⼀个匹配,
// 匹配成功后lastIndex的值将会变为上次匹配的字符的最后⼀个位置的索引。
// 在设置g属性后,虽然匹配结果不受g的影响,
// 返回结果仍然是⼀个数组(第⼀个值是第⼀个匹配到的字符串,以后的为分组匹配内容),
// 但是会改变index和 lastIndex等的值,将该对象的匹配的开始位置设置到紧接这匹配⼦串的字符位置,// 当第⼆次调⽤exec时,将从lastIndex所指⽰的字符位置开始检索。
while ((match = (text))) {
index = match.index;
// 当⽂本标签中既有{{}} 在其左边⼜有普通⽂本时,
// 如:<span>我是普通⽂本{{value}}</span>,就会执⾏下⾯的⽅法,添加到tokens数组中。
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)));
}html实现用户注册登录代码
// 把匹配到{{}}中的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)));

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。