《工欲善其事,必先利其器》
既然点进来了,麻烦你看下去,希望你有不一样的收获。
AST
,全称 Abstract Syntax Trees,中文名称为 抽象语法树
。它是源代码语法结构的一种抽象表示,以树状的形式表现编程语言的语法结构,树上的每一个节点都表示源代码中的一种结构。诸如,编辑器(IDE)和 模板语法的模板编译,又或是 Babel 的源代码编译,都应用到抽象语法树的思想。
例如:我们熟知的 Vue
的 template 模板语法,当我们在里面书写 HTML 代码的时候,就会经历以下几个步骤:
Vue-Loader
将 template 里面的语法转化为字符串;AST
抽象语法树;patchVnode
和 diff
算法将 AST
转化为虚拟节点继而是实现最小量更新。这里我们通过一张图就可以很清晰的了解整个 Vue2.0 的模板编译机制
和响应式原理
:
这里可能会有人要问:
innerHTML
嘛?它不香嘛?Vue 它是一个渐进式的 SPA(单页面)框架,视图无感更新和数据实时响应以及页面性能是必须要重点优化的地方。尤其是视图更新,想要做到以最小的性能损耗达到实时更新的目的,就必须借助 虚拟节点
和 Diff
的手段,而不是每次改变数据就操作节点。
因此,为了实现虚拟节点,我们就需要先实现 AST
。
AST
的解析,如果不考虑 Vue-Loader
的转化步骤,归根结底是一个模板字符串转化为树状结构的算法问题。那么是算法问题,就跟我们昨天刚学习的《栈数据结构》一样,我们先要分析怎么计算:
确定好算法之后,我会再分析一下计算的思路:
那么话不多说,我们开始~
以下代码均在 node 环境下编写,需要的小伙伴们自行配置环境,或拉取文末的 git。
假设现有模板字符串如下:
var templateString = `
你好
- 1
- 2
- 3
`
解析模板字符串的代码,可能会有点长,但是需要你仔细阅读:
export default function parse(templateString) {
// 指针
let index = 0;
// 剩余部分
let rest = templateString;
// 标签栈
let tagSections = [];
// 字符栈,先用 children 占位,防止弹栈
let charSections = [{ children: [] }];
// 开始标签正则
const startRegExp = /^\<([a-z]+[1-6]?)(\s[^\<]+)?\>/;
// 结束标签正则
const endRegExp = /^\<\/([a-z]+[1-6]?)\>/;
// 中间文字正则
const wordRegExp = /^([^\<]+)\<\/[a-z]+[1-6]?\>/;
while(index < templateString.length - 1) {
// 截取剩余部分
rest = templateString.substring(index);
// 若当前字符检测到开始标签
if (startRegExp.test(rest)) {
// 获取开始标签
let tag = rest.match(startRegExp)[1];
// 标签栈入栈
tagSections.push(tag);
// 字符栈入栈,用 children 和 tag 占位
charSections.push({ "tag": tag, "children": [] });
// 指针步进为 标签长度加2(< >)
index += tag.length + 2;
} else if (wordRegExp.test(rest)) {
// 若当前字符检测到文字
let word = rest.match(wordRegExp)[1];
// 判断获取到的文字是否全为空
if (!/^\s+$/.test(word)) {
// 如果不是,则入栈,工作栈为字符栈的栈顶那一项
charSections[charSections.length - 1].children.push({ "text": word, "type": 3 });
}
// 指针步进为 文字长度
index += word.length;
} else if (endRegExp.test(rest)) {
// 若当前字符检测到结束标签
let endTag = rest.match(endRegExp)[1];
// 标签栈弹栈
let pop_tag = tagSections.pop();
// 判断开始标签与结束标签是否闭合
if (pop_tag == endTag) {
let pop_char = charSections.pop();
// 判断字符栈是否还存在工作栈,如果是则并入到上一个工作栈
if (charSections.length > 0) {
charSections[charSections.length - 1].children.push(pop_char);
}
} else {
throw new Error(pop_tag + "标签没有闭合!!!");
}
// 指针步进为 结束标签长度加3( >)
index += endTag.length + 3;
} else {
// 默认情况下指针自增
index++;
}
}
// 由于事先布置好占位数组,故字符栈现在存有一项,即总的数据,因此返回该项 children
return charSections[0].children[0];
}
以上,我们的基础版 AST
模板解析已经完成,让我们看一下效果:
看起来好像是那么一回事了,以上的做法呢,可以说是简陋的实现了一下基本状况下的模板字符串吧。因为实际上的模板字符串可能还有这种结构:
123 456 789
…
…这时候可能有小伙伴会说了:
class
或 id
怎么办?就放弃了嘛!!!的确,我们接下来要继续来完善标签内属性
的计算情况,只是你应该会看到,在代码中,你的算法如果有半点差错, 那么导致的结果将是灾难级别的。。。Vue2.0 源码中是处理了很多种情况下的模板字符串的,而我们这里只实现了基本情况下的核心算法
而已。。可见源码有多么厉害。。。(捂脸)
假设现有模板字符串如下:
var templateString = `
你好
- 1
- 2
- 3
`
话不多说,继续写情况分析:
首先,我们先要截取到标签内,属性的字符串。
// 修改第三步的代码
while(index < templateString.length - 1) {
// 识别遍历到的这个字符,是不是一个开始标签
if (startRegExp.test(rest)) {
// ...
// 获取属性字符串
let attrsString = rest.match(startRegExp)[2];
// 将开始标记推入栈中
stack1.push(tag);
// 将空数组推入栈2中
stack2.push({ "tag": tag, children: [], "attrs": parseAttrsString(attrsString) });
// 得到attrs的总长度
const attrsStringLength = attrsString != null ? attrsString.length : 0;
// 指针移动标签的长度加2,因为<>也占两位,再加上属性字符串的长度
index += tag.length + 2 + attrsStringLength;
}
// ...
}
新建处理 属性字符串
的函数:
export default function parseAttrsString(attrsString) {
// 如果字符串不存在,变为数组返回
if (attrsString == undefined) return [];
// 判断检测到的空格是否包含在引号内
let isInside = false;
// 指针
let index = 0;
// 结果数组
let result = [];
// 遍历属性字符串
for(let i = 0; i < attrsString.length; i++) {
let char = attrsString[i];
// 如果当前字符是双引号
if (cahr == '"') {
// 设置变量,包含开始双引号和结束双引号
isInside = !isInside;
} else if (char == " " && !isInside) {
// 若当前字符是空格,但不在双引号之内,则截取剩余字符串
// 排除了属性之外的空格字符
let rest = attrsString.substring(index, i);
// 判断截取到的字符是否全为空
if (!/^\s*$/.test(rest)) {
// 如果不是,则并入结果数组
result.push(rest.trim());
}
// 移动指针
index = i;
}
}
// 循环结束之后,由于指针移动的比i慢,最后还会剩下一个属性未被并入
// 清除字符串前后空格之后,将其并入结果数组
result.push(attrsString.substring(index).trim());
// 现结果数组里的数据结构为 ["k=v", "k=v", "k=v"] 这种
// 但我们需要把它变为 [{name: k, value: v},{name: k, value: v},{name: k, value: v}]
// 因此我们在知道数据结构的情况下,可以采用递归,并且是映射递归
result = result.map(item => {
// 根据等号拆分字段
const path = item.match(/^(.+)="(.+)"$/);
return {
name: path[1],
value: path[2]
}
})
// 最后,返回结果数组
return result;
}
让我们,看一下效果呀:
怎么样,没有辜负你的一片期望吧?哈哈哈哈哈哈哈哈哈嗝~
这篇文章,我们目的是要实现 Vue2.0 源码中的 —— 《模板字符串解析为 AST 抽象语法树》
。即使实现的情况只能适应基础状况下的模板语法,但我觉得也够用了。(其实就是累了~)
其中,我们解释了,为什么有 AST
:
因为一切都是为了它的特性,突出性能,才会选择这种处理方案的。至于,为什么 AST 不直接 Diff ?好问题。
data
的数据,并没有修改 template 模板语句,所以此时你不可能再算一遍 AST
然后去比较吧?就算你去比较也 Diff 不出来任何变化的。data
数据时重新执行 render 函数,生成新的虚拟节点,然后 Diff 比对、渲染。patchVnode
)函数是一段可执行的代码,你能在函数内触发更多其他条件。例如 watch
,例如 computed
等等。最后,感谢你的阅读,希望我的文章对你有所帮助。完整代码我已上传至码云,有需要的小伙伴自行查阅 —— 《传送地址》。
参考文献:
《B站尚硅谷 AST 源码视频》