Vue2.0 —— 运用算法实现 AST 抽象语法树
《工欲善其事,必先利其器》
既然点进来了,麻烦你看下去,希望你有不一样的收获。
一、什么是 AST ?
AST
,全称 Abstract Syntax Trees,中文名称为 抽象语法树
。它是源代码语法结构的一种抽象表示,以树状的形式表现编程语言的语法结构,树上的每一个节点都表示源代码中的一种结构。诸如,编辑器(IDE)和 模板语法的模板编译,又或是 Babel 的源代码编译,都应用到抽象语法树的思想。
例如:我们熟知的 Vue
的 template 模板语法,当我们在里面书写 HTML 代码的时候,就会经历以下几个步骤:
- 通过
Vue-Loader
将 template 里面的语法转化为字符串; - 利用算法,将 template 模板字符串解析为
AST
抽象语法树; - 通过
patchVnode
和diff
算法将AST
转化为虚拟节点继而是实现最小量更新。
这里我们通过一张图就可以很清晰的了解整个 Vue2.0 的模板编译机制
和响应式原理
:
这里可能会有人要问:
- 我都有 HTML 模板字符串了,不能直接
innerHTML
嘛?它不香嘛?
这就说明你并不了解 Vue
框架的特性。
Vue 它是一个渐进式的 SPA(单页面)框架,视图无感更新和数据实时响应以及页面性能是必须要重点优化的地方。尤其是视图更新,想要做到以最小的性能损耗达到实时更新的目的,就必须借助 虚拟节点
和 Diff
的手段,而不是每次改变数据就操作节点。
因此,为了实现虚拟节点,我们就需要先实现 AST
。
二、如何实现 Vue2.0 的 AST 解析?
AST
的解析,如果不考虑 Vue-Loader
的转化步骤,归根结底是一个模板字符串转化为树状结构的算法问题。那么是算法问题,就跟我们昨天刚学习的《栈数据结构》一样,我们先要分析怎么计算:
- 首先,它是一个字符串,但是结果的结构变了,那么势必要用到指针的思想;
- 其次,字符串有配对,且,有嵌套的结构,那么相比递归,我会更加倾向与栈;
- 指针方面,我选择用 while 而不是 for,因为 while 可以更加灵活的操控指针;
- 最后,还要分析标签中属性的字符和空格,难度上升,对正则表达式有要求。
确定好算法之后,我会再分析一下计算的思路:
- 定义指针;
- 遍历模板字符串;
- 正则遇见开始标签,标签进栈;
- 正则遇见结束标签,标签弹栈;
- 正则遇见标签之间的文字,文字进栈。
那么话不多说,我们开始~
三、实现 AST 解析的基础过程
以下代码均在 node 环境下编写,需要的小伙伴们自行配置环境,或拉取文末的 git。
假设现有模板字符串如下:
var templateString = `
<div>
<h3>你好</h3>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
</div>
`
解析模板字符串的代码,可能会有点长,但是需要你仔细阅读:
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
模板解析已经完成,让我们看一下效果:
看起来好像是那么一回事了,以上的做法呢,可以说是简陋的实现了一下基本状况下的模板字符串吧。因为实际上的模板字符串可能还有这种结构:
<div>123</div> 456 <div>789</div>
…<input type="text" value="val" />
…
这时候可能有小伙伴会说了:
- 你这也太敷衍了啊,都说不上是源码解析啊!那我加一个
class
或id
怎么办?就放弃了嘛!!!
的确,我们接下来要继续来完善标签内属性
的计算情况,只是你应该会看到,在代码中,你的算法如果有半点差错, 那么导致的结果将是灾难级别的。。。Vue2.0 源码中是处理了很多种情况下的模板字符串的,而我们这里只实现了基本情况下的核心算法
而已。。可见源码有多么厉害。。。(捂脸)
四、完善标签内属性的 AST 解析
假设现有模板字符串如下:
var templateString = `
<div class="container">
<h3 id="title" class="title pt5">你好</h3>
<ul id="list">
<li class="item">1</li>
<li class="item">2</li>
<li class="item">3</li>
</ul>
</div>
`
话不多说,继续写情况分析:
首先,我们先要截取到标签内,属性的字符串。
// 修改第三步的代码
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 不出来任何变化的。 - 其次,虚拟节点搭配 Diff 所产生的性能损耗是最小的,这就是为什么每次改变
data
数据时重新执行 render 函数,生成新的虚拟节点,然后 Diff 比对、渲染。 - 最后,虚拟节点就是一个数据结构,render(
patchVnode
)函数是一段可执行的代码,你能在函数内触发更多其他条件。例如watch
,例如computed
等等。
最后,感谢你的阅读,希望我的文章对你有所帮助。完整代码我已上传至码云,有需要的小伙伴自行查阅 —— 《传送地址》。
参考文献: