开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第10天,点击查看活动详情
要实现一个 AST 我们首先要了解以下 AST 是什么? 作为什么级别的研发才有应该去了解这方面的知识呢?
- AST 不能算作是一门技术,而可以说 是一种 JavaScript 中的 语法,词法分析, 转换的一种工具,其也不是用一个函数就能够实现的,作为一个前端开发工程师,对 AST 并不是必要了解的,而当你作为一个高级研发 及以上级别, 肯定是要对自己所使用的 语言,或者是框架要有很深的理解的, 而我们使用的 很多框架, 或者是 TS 很多语法其实都不是 JavaScript 的语法, 那么他们又是如何工作的呢? 其实他们就是利用了诸如 babel 这类语法转换工具来实现。
- 语法的转换,词法的解析,都是有响应的工具函数的,其也可以应用在不同的场景中,我们可以举一反三,这里我就使用 vue 模板的解析,作为一个 demo, 从下面的导图中我们可以了解以下大致的流程:
从上面我们可以看到, 我们从 模板 到 我们渲染函数,需要经过几次编译的过程,这几次过程就是 对 AST 树进行不同的转换过程
- 我们下面就来看一下, 这几个方法 ,在编译当中起了什么样的作用
最关键的三个方法 其实就是
parser
,transform
,generate
, 用过词法的解析和标记,以及有限状态自动机的运转,对节点进行深度遍历后进行 上下文对象的注入,然后转换成 js 的抽象语法树
- 下面是一个 简单的 demo 实现, 里面包含了状态机, token解析,
parser
函数 及transform
的简单实现, 大家可以配合注释一起食用
// 有限状态机的简易实现 tokenize
// 定义状态机的状态
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 chars = []
// 生成的 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(isAlpha(char)){
//1. 遇到字母,切换到文本状态
currentState = State.text
//2. 将当前字母缓存到 chars 数组中
chars.push(char)
//3. 消费当前字符
str = str.slice(1)
}
break
// 状态机当前状态处于标签开始状态
case State.tagOpen:
if(isAlpha(char)){
//1. 遇到字母,切换到标签名称状态
currentState = State.tagName
//2. 将当前字符缓存到 chars 数组中
chars.push(char)
//3. 消费当前字符
str = str.slice(1)
}else if(char === '/'){
//1. 遇到字符 / , 切换到结束标签状态 </div> /
currentState = State.tagEnd
//2. 消费字符
str = str.slice(1)
}
break
case State.tagName:
if(isAlpha(char)){
// 遇到字母, 由于当前处于标签名称状态,所以不需要切换状态,需要将字符缓存到 chars 数组中 <div> div
chars.push(char)
str = str.slice(1)
}else if(char === '>'){
// 切换到标签初始状态
currentState = State.initial
// 同时创建一个标签 Token, 并添加到 tokens 数组中, 此时 chars 数组中缓存的字符 就是标签名称
tokens.push({
type:'tag',
name:chars.join('')
})
// chars 数组内容已经被消费,清空
chars.length = 0
// 同时消费当前字符 >
str = str.slice(1)
}
break
case State.text:
if(isAlpha(char)){
// 遇到字母,保持状态不变,当应该将当前字符缓存到 chars 数组
chars.push(char)
str = str.slice(1)
}else if(char === '<'){
currentState = State.tagOpen
// 从文本状态 ===> 标签开始状态,此时应该创建文本 Token, 并添加到 tokens 数组中, 注意此时 chars 数组中的字符就是 文本内容
tokens.push({
type:'text',
content:chars.join('')
})
chars.length = 0
str= str.slice(1)
}
break
case State.tagEnd:
if(isAlpha(char)){
// 遇到字母 切换到结束名称状态 如 </div> 开始走 div
currentState = State.tagEndName
chars.push(char)
str = str.slice(1)
}
break
case State.tagEndName:
if(isAlpha(char)){
// 遇到字母 不切换状态
chars.push(char)
str = str.slice(1)
}else if(char === '>'){
// 切换会初始状态
currentState = State.initial
// 从结束标签名称状态 ===> 初始状态, 应该保存结束标签名称 Token, 注意 此时 chars 数组中 缓存的内容就是 标签名称
tokens.push({
type:'tagEnd',
name:chars.join('')
})
chars.length = 0
str = str.slice(1)
}
break
}
}
// 最后 返回 tokens
return tokens
}
// 上面的函数 没有处理 数字的情况
console.log(tokenize(`<p>vue</p>`)); // 正常来说这里得到的结果应该是 [{type:'tag',name:'p'},{type:'text',name:'vue'},{type:tagEnd,name:'p'}]
// 优化后的 parser 增加了 栈队列, 当前 无法处理 自闭 和 标签 以及 数值
function parser(res){
// 首先对模板进行表计划,得到 tokens
const tokens = tokenize(res)
// 创建 Root 根节点
const root = {
type:'Root',
children:[]
}
// 创建 elementStack 栈, 期初只有 Root 根节点
const elementStack = [root]
// 开启一个 while 循环扫描 tokens, 知道所有 token 都被 扫描完为止
while(tokens.length){
// 获取当前栈顶节点 作为父节点 parent
const parent = elementStack[elementStack.length -1]
// 当前扫描的 token
const t = tokens[0]
switch(t.type){
case 'tag':
// 如果当前 Token 是开始标签,则创建 Element 类型的 AST 节点
const elementNode = {
type:'Element',
tag:t.name,
children:[]
}
// 将其添加到父级节点的 children 中
parent.children.push(elementNode)
// 将当前节点压入栈
elementStack.push(elementNode)
break;
case 'text':
// 如果当前 token 是文本, 则创建 text 类型的 AST 节点
const textNode = {
type:'Text',
content:t.content
}
// 将其添加到父节点的 children 中
parent.children.push(textNode)
break;
case 'tagEnd':
// 遇到结束标签, 将栈顶节点弹出
elementStack.pop()
break
}
// 消费已经通过扫描的 token
tokens.shift()
}
// 最后返回 AST
return root
}
const ASTSTR = `<div><p>vue</p><div>react</div></div>`
console.log(dump(parser(ASTSTR)));
// dump 工具函数, 用来进行 AST 节点的深度遍历访问 及打印
function dump(node,indent = 0){
// 节点类型
const type = node.type
// 如果是根节点,则没有描述,如果是 Element 类型的节点,则使用 node.tag 作为节点的描述,如果是 Text 类型的节点,则使用 node.context 作为节点的描述
const desc = node.type === 'Root' ? "" : node.type === 'Element' ? node.tag : node.content
// 打印节点的类型和 描述信息
console.log(`${'-'.repeat(indent)}${type}: ${desc}`);
// 递归的打印子节点
if(node.children){
node.children.forEach(n => dump(n,indent + 2))
}
}
// 转换函数的 简单原理
function transform(ast){
// 存储上下文信息的对象
const context = {
// 用来存储当前正在转换的节点
currentNode:null,
// 用来存储当前节点在父节点的 chilren 中的位置索引
childIndex:0,
// 用来存储当前转换节点的父节点
parent:null,
// 节点替换 用来替换当前正在转换的节点 context 这个对象会 通过递归透传, 所有子节点中 都可以通过 context对象访问这个方法
replaceNode(node){
context.currentNode = node
context.parent.children[context.childIndex] = node
},
// 删除节点
removeNode(){
if(context.parent){
// 调用数组的 splice 方法, 根据当前节点的索引 删除当前节点
context.parent.children.splice(context.childIndex,1)
// 将 context.currentNode 置空
context.currentNode = null
}
},
nodeTransforms:[
transformElement,// 转换节点类型函数
transformText,// 转换文本类型的函数
]
}
traverseNode(ast,context)
}
// 工作流的实现 只是包含了 进入阶段的工作流, 实际上还要包括退出阶段的工作流, 转换函数返回另一个函数,该函数即作为退出阶段的回调函数
function traverseNode(ast,context){
// 设置当前转换的节点信息
context.currentNode = ast
const transforms = context.nodeTransforms
for(let i =0;i<transforms.length;i++){
transforms[i](context.currentNode,context)
}
const children = context.currentNode.children
if(children){
for(let i = 0; i < children.length ; i++){
// 递归调用 traverseNode 转换子节点之前, 将当前节点设置为父节点
context.parent = context.currentNode
// 设置位置索引
context.childIndex = i
// 递归调用时, 将 context 透传
traverseNode(children[i],context)
}
}
}