parser的实现原理与状态机
Vuejs模板编译器的基本结构和工作流程,主要由三个部分组成:
- 用来将模板字符串解析为模板AST的解析器(parser);
- 用来将模板AST转换为 JavaScript AST的转换器(transformer);
- 用来根据JavaScriptAST生成染函数代码的生成器(generator)。
先讨论解析器 parser 的实现原理
解析器的入参是字符串模板,解析器会逐个读取字符串模板中的字符,并根据一定的规则将整个字符串切割为一个个Token。这里的 Token 可以视作词法记号,后续我们将使用Token一词来代表词法记号进行讲解。举例来说,假设有这样一段模板:
<p>Vue</p>
解析器会把这段字符模板切割为三个Token
- 开始标签:
<p>
- 文本节点:
Vue
- 结束标签:
</p>
解析器是依据有限状态自动机对模板进行切割。所谓“有限状态”,就是指有限个状态,而自动机意味着随着字符的输入,解析器会自动地在不同状态间迁移。
有限状态自动机可以帮助我们完成对模板的标记化 (tokenized)、最终我们将得到一系列Token,状态机的实现如下:
// 定义状态机的状态
const State = {
initial: 1, // 初始状态
tagOpen: 2, //标签开始状态
tagName: 3, // 标签名称状态
text: 4, //文本状态
tagEnd: 5, //结束标签状态
tagEndName: 6 // 结束标签名称状态
}
//一个辅助函数,用于判断是否是字母
function isAlpha(char){
return char>='a' && char<='z' || char>='A' && char<='Z'
}
// 接收模板宇符串作为参数,并将模板切割为 Token 返回
function tokenize(str){
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chart = []
//生成的Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while(str){
//查看第一个字符,注意,这里只是查看,没有消费
const char = str[0]
// switch 语句匹配当前状态
switch(currentState){
case State.initial:
if (char === '<'){
//1.状态机切换到标签开始状态
currentState = State.tagOpen
//2.消费字符<
str = str.slice(1)
}else if(isAplha(char)){
//1.遇到字母,切换到文本状态
currentState = State.text
//2.将当前字母缓存到 chars 数组
chars.push(char)
//3.消费当前字符
str = str.slice(1)
}
break;
// 状态机当前处于标签开始状态
case State.tagOpen:
if (isAplha(char)){
currentState = State.tagName
chars.push(char)
str = str.slice(1)
}else if(char === '/'){
currentState = State.tagEnd
str = str.slice(1)
}
break;
// 状态机当前处于标签名称状态
case State.tagName:
if (isAplha(char)){
//1.遇到字母,由于当前处于标签名称状态,所以不需要切换状态
//但需要将当前字符缓存到 chars 数组
chars.push(char)
str = str.slice(1)
}else if(char === '>'){
//1.遇到字符>,切换到初始状态
currentState = State.initial
// 2,同时创建一个标签 Token,并添加到 tokens 数组中
// 注意,此时 chars 数组中缓存的字符就是标签名称
tokens.push({
type: 'tag',
name: chars.join('')
})
chars.length = 0
str = str.slice(1)
}
break;
case State.text:
if (isAplha(char)){
chars.push(char)
str = str.slice(1)
}else if(char === '<'){
// 1,遇到字符<,切换到标签开始状态
currentState = State.tagOpen
// 2.从文本状态-->标签开始状态,此时应该创建文本 Token,并添加到 tokens 数组中
// 注意,此时 chars 数组中的字符就是文本内容
tokens.push({
type: 'tag',
name: chars.join('')
})
chars.length = 0
str = str.slice(1)
}
break;
//状态机当前处于标签结束状态
case State.tagEnd:
if (isAplha(char)){
//遇到字母,切换到结束标签名称状态
currentState = State.tagEndName
chars.push(char)
str = str.slice(1)
}
break;
// 状态机当前处于结束标签名称状态
case State.tagEndName:
if (isAplha(char)){
// 1.遇到宇母,不需要切换状态,但需要将当前字符缓存到 chars 数组
chars.push(char)
// 2.消费当前字符
str = str.slice(1)
}else if(char === '>'){
// 1.遇到字符 >,切换到初始状态
currentState = State.initial
// 2.从 结束标签名称状态 --> 初始状态,应该保存结束标签名称 Token
// 注意,此时 chars 数组中缓存的内容就是标签名称
tokens.push({
type: 'tagEnd',
name: chars.join('')
})
// 3.chars数组的内容已经被消费,清空它
chars.length = 0
// 4.消费当前字符
str = str.slice(1)
}
break;
}
}
//最后,返回 tokens
return tokens
}
便用上面给出的 tokenzie 函数来解析模板 <p>Vue</p>
,我们将得到三个Token
const tokens = tokenzie('<p>Vue</p>')
//[
//{type: 'tag', name: 'p'}, // 开始标签
//{type:'text',content;'Vue'},//文本节点
//{type: 'tagEnd', name: p'} //结束节点
//]
总而言之,通过有限自动机,我们能够将模板解析为一个个Token,进而可以用它们构建一棵AST了。但在具体构建AST之前,我们需要思考能否简化 tokenzie 函数的代码。实际上,我们可以通过正则表达式来精简 tokenie 函数的代码。上文之所以没有从最开始就采用正则表达式来实现,是因为正则表达式的本质就是有限自动机,当你编写正则表达式的时候,其实就是在编写有限自动机