Vue 生成 AST 算法的解析

用户头像
djknight
关注
发布于: 2020 年 06 月 02 日
Vue生成AST算法的解析

在看vue源码的过程中,不只加深了对vue本身的理解,也理解了正则,以及各种设计模式。

博客大部分是从代码开始讲起,所以,我打算更细致的讲一讲这部分的思想。



首先我们要知道什么是AST,以及为什么要用AST生成虚拟dom。

AST是指抽象语法树(abstract syntax tree),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式。Vue在mount过程中,template会被编译成AST语法树。



简单来说,在js中AST的表现形式是像下面的结构



AST Object



它是怎么来的呢? 是通过js中的类html字符串转换而来的,所以我们的任务就变成了把类html转换成这种结构啦。 下面我先给出一串模板

const template = `
<div id="refresh-container" class="refresh-container">
<div id="icon" class="refresh-icon">
<canvas width="60" height="60" id="canvas"></canvas>
</div>
</div>
`




#1

首先我们对这个字符串进行trim操作,去除两端的空格。然后我们要用 let textStart = html.indexOf('<')判断这个字符串<的位置。这时候就分成了三个部分



我们从最简单的textStart = 0的情况开始

1.1

这时候又会有两种可能性



这一部分的结构讲完了, 再继续看看<0的情况




1.2

当textStart<0的时候,只有一种可能,这段html字符串已经没有标签或者注释了,即是纯文本。

1.3

当textStart > 0的时候,说明在标签或者注释之前含有文本(这里我们暂时不考虑文本中有<的情况,大家可以自己去尝试一下)




2

知道了所有的情况,我们来了解一下怎么处理这些情况。

2.1

1.的情况,当匹配到标签的时候,比如<div id="refresh-container" class="refresh-container">我们要把它的tagName, id, class等等属性得到。设置两个栈。一个叫checkStack,用于与之后匹配到的结束标签进行比对。一个叫AstStack,用于辅助AST的建立。

checkStack



AST Stack

checkStack中单纯存放了tagName和attr,而AstStack中存放的是他的父元素和子元素,这些可以用于建立一个AST。

所以流程如下:

匹配到开始标签 --> 获得各种属性,创建一个match对象--> 推入checkStack中-->推入AstStack中 (这里先不讨论自闭和标签的情况,等会会用代码来详细讨论)




如果匹配到注释,不推入checkStack,具体流程如下:



匹配到注释开始 --> 获得注释所有的内容,并匹配到注释结束标签 --> 不推入checkStack --> 从AstStack中获取最后一个元素, 把该注释对象推入AstStack栈顶的children数组中




2.2

没有标签或注释,则会把该段纯文本推入AstStack栈顶元素的children中, 并不再遍历该段html,如果没有栈顶元素,则会报错



2.3

与2.2情况类似,只是还会继续遍历html






3



知道了流程和怎么处理,我们可以愉快的开始看代码啦!我们来看看精简版的,然后大家熟悉了流程可以再去看vue的源码



先来定义几个正则

const attribute = /^\s*([^\s"'<>\\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 匹配开始标签开始部分
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配开始标签结束部分
const startTagClose = /^\s*(\/?)>/
// 匹配结束标签
const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`)
// 匹配注释
const comment = /^<!--/

具体解析,可以看我之前的文章。

我们使用parse函数作为一个主入口函数,

function parse(template) {
let root // 根节点, 只设置一次
let currentParent // 父节点, 一开始为空
let AstStack = []
parseHtml(template, {
/* 处理开始标签 */
start (tagName, attrs, isUnary) {
// 创建一个AST上的叶子元素
let element = createASTElement(tagName, attrs, currentParent)
if (!root) {
root = element
}
if (currentParent) {
currentParent.children.push(element)
element.parent = currentParent
}
// 是非闭合的,把它入栈并作为currentParent
if (!isUnary) {
AstStack.push(element)
currentParent = element
}
console.log(AstStack)
// 否则是闭合的, 既然已经把它于父元素联系起来,则不做处理就行
},
/* 处理结束的标签, 把它出栈就好 */
end () {
AstStack.pop() // 还会把长度减一
currentParent = AstStack[AstStack.length - 1]
},
char (text) {
if (!currentParent) { // char的情况都没有currentParent
console.error('你没有根元素!')
} else {
const children = currentParent.children
children.push({
type: 3,
text
})
}
},
comment (text) {
console.log(text)
if (currentParent) {
currentParent.children.push({
type: 3,
text,
isComment: true
})
}
}
})
return root
}



在parse函数中定义root,即根节点,所有的dom元素会挂载到root上。currentParent可以理解成AstStack的栈顶指针。

在parseHtml中传入三个函数,分别处理上面说的那三种情况。我们先看parseHtml。

function parseHtml(template, options) {
let html = template //剩下的html
let opt = options
let index = 0 //解析的索引
let lastTag // 相当于栈顶指针,不过是用tagName来做栈顶指针的
let checkStack = []
while (html) {
let textStart = html.indexOf('<')
if (textStart === 0) {
// 以<开头
if (html.match(comment)) { // /^<!--/
// 说明是注释开头
let commentEnd = html.indexOf('-->')
console.log(commentEnd)
if (commentEnd >= 0) {
if (opt.comment) {
opt.comment(html.substring(4, commentEnd)) // 保存注释 <!--四个字符
}
}
advance(commentEnd + 3)
continue
}
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch)
}
/* 把带空格和纯文本放一起讨论 把空格和文本都设成text 传入char */
// 如果匹配到结尾标签
const endTagMatch = html.match(endTag) // 匹配到的是</div>之类的 有可能匹配到</br>
if (endTagMatch) {
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1])
}
}
let text
if (textStart >= 0) {
// 说明标签和标签中间有文本
text = html.slice(0, textStart) // 从<位置截取剩下的
advance(textStart)
}
if (textStart < 0) {
// 没有<, 说明整个html都是纯文本
text = html
html = ''
}
if (opt.char) {
opt.char(text)
}
}
}

对传入的字符串进行遍历,当以<开头的时候,如果匹配到comment,则再次查找注释结尾,把注释内容保留传入opt.commet()函数处理,然后再调用advance()截除注释部分。

来看看advance()

function advance (n) {
index += n
html = html.substring(n)
}

只是简单的增加index,并截除html

如果匹配到startTag, 即<div之类的标签, 则调用parseStartTag(),他会返回一个match对象并push进checkStack中.

function parseStartTag() {
// const ncname = '[a-zA-Z_][\\w\\-\\.]*'
// const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 匹配开始标签开始部分
// const startTagOpen = new RegExp(`^<${qnameCapture}`)
const start = html.match(startTagOpen)
// start 是个数组 start[0]存放了匹配到的字符串 start[1]存放正则的捕获
if (start) {
let match = {
tagName: start[1],
attrs: [],
start: index
}
// 这时候如果<div id="refresh-container" class="refresh-container">类似的html会变成
// id="refresh-container" class="refresh-container">
advance(start[0].length)
let end, attr
// startTagClose: /^\s*(\/?)>/
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
// 如果匹配不到/>或> 但能匹配到属性, 说明它有属性,把属性push进attrs
match.attrs.push(attr)
advance(attr[0].length)
}
if (end) {
match.isUnary = end[1] // 是自闭合的就是/ 非自闭和的就是空
match.end = index
advance(end[0].length)
return match
}
}
}

返回match对象后,再传入handleStartTag(),把该match对象推入checkStack中,并准备推入AstStack。

function handleStartTag(match) {
// 分成三部分
/*
* 1: 格式化attr
* 2: 把标签入栈,方便匹配
* 3: 调用start函数,创建AST
* */
let tagName = match.tagName
let unaryTag = match.isUnary
let attrs = []
attrs.length = match.attrs.length //进行深拷贝
for (let i = 0; i < attrs.length; i++) {
attrs[i] = {
name: match.attrs[i][1],
// attrs3,4,5都有可能有值 但是不可能一起有。因为可能是双引号或单引号或什么都没有
value: match.attrs[i][3] || match.attrs[i][4] || match.attrs[i][5]
}
}
// 判断是否闭合标签, 如果是自闭和的, 那么不入checkStack栈
let isUnary = isUnaryTag(tagName) || !!unaryTag
if (!isUnary) {
checkStack.push({
tag: tagName,
attrs,
lowerCaseTag: tagName.toLowerCase()
})
console.log(checkStack)
}
if (opt.start) {
opt.start(tagName, attrs, isUnary)
}
}

如果是自闭合标签,那就不推入checkStack栈,因为不用于之后的标签进行比对。然后再调用start()对AST进行处理






如果匹配到endTag,如</div>等。

调用parseEndTag(),传入tagName,即div

// const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`)
function parseEndTag(tagName) {
/*
* 对endTag通过checkStack进行匹配, 如果匹配到了则出栈, 维护栈顶指针,并调用end方法
* */
if (tagName) {
// 开始再checkStack中匹配
tagName = tagName.toLowerCase()
let pos
for (pos = checkStack.length - 1; pos>=0; pos--) {
if (checkStack[pos].tagName === tagName) {
break
}
}
// 匹配成功, 但是不一定匹配正确, 只有栈顶元素和你匹配成功才算正确
if (pos >= 0) {
let i = checkStack.length - 1
if (i > pos) {
// 匹配错误!
console.error(`tag<${checkStack[i - 1].tagName}没有匹配的end tag`)
}
// 否则, 匹配成功, 调用end函数把它从AST栈中弹出
opt.end()
// 还要从checkStack中弹出
checkStack.length = pos
lastTag = checkStack[pos - 1].tagName
}
}
}

再parseEnd中做做匹配。如果匹配到了标签,有可能匹配错误,所以就要再判断匹配的成功性

因为这些很简单,所以我就不再多讲了,大家可以看代码注释,或者直接在下面跟我讨论,我们直接讨论textStart>0textStart<0的情况




我们可以将这两种情况统一起来考虑,因为本质上都是处理字符串.

let text
if (textStart >= 0) {
// 说明标签和标签中间有文本
text = html.slice(0, textStart) // 从<位置截取剩下的
advance(textStart)
}
if (textStart < 0) {
// 没有<, 说明整个html都是纯文本
text = html
html = ''
}
if (opt.char) {
opt.char(text)
}

核心思想都是将文本提取出来传入opt.char()处理。这里要注意,我们代码的缩进也是属于一个个空白字符,所以在碰到缩进的时候也是会调用这些方法的。




其实说到这里大部分都说完了,剩下的细节大家可以去看看源码。



发布于: 2020 年 06 月 02 日 阅读数: 853
用户头像

djknight

关注

还未添加个人签名 2020.05.31 加入

还未添加个人简介

评论 (2 条评论)

发布
用户头像
感谢分享,首页推荐这篇啦。
2020 年 06 月 02 日 14:07
回复
感谢推荐🙏
2020 年 06 月 05 日 00:08
回复
没有更多了
Vue生成AST算法的解析