AST基础
介绍
AST(AbstractSyntaxTree,抽象语法树)是一种树状结构,用于表示代码的语法结构.它是源代码在编译或解释执行之前的中间表示,能够捕获代码的语法规则和层级关系.
在AST中,代码的每个语法元素(如变量、函数、运算符等)都会被解析成一个节点,节点之间的层次结构表现出代码的嵌套关系.
demo
1 | let obj = { |
解析成语法树如下https://astexplorer.net/
可以换成json来看
1 | { |
program
属性的body
属性是一个数组,该数组存放js代码的代码行
VariableDeclaration
是变量声明,意思是这段js代码是变量声明语句,kind
意思是变量声明使用的关键字,这里是let
这里的declarations
是个数组,数组元素的个数和声明变量的个数一致
关注declarations
下的id
和init
,id
里面的name
属性是变量名,init
内是变量初始化的值,init
的properties
是键值对,是属性名和属性值
关注a+b+1000
在AST中怎么表示的,按右边的+
分隔,分成a+b
和1000
,再把a+b
按+
分隔,分成a
和b
babel
库可以对AST语法进行解析,后面都是用babel
库进行操作的
代码的基本结构
demo
把原始代码保存成一个文件,名为demo.js,注意保存成utf-8编码的,另外新建一个文件,用来解析demo.js
这里的demo.js就是
1 | let obj = { |
安装babel库
1 | npm install @babel/core |
AST.js
1 | const fs = require('fs'); |
总的来说逻辑如下
1 | (1)fs用来读写本地文件,require之后赋值给了fs. |
parser和generator
这两个组件的作用刚好是相反的.parser
组件用来将JS代码转换成AST,generator
用来将AST转换成JS代码.
使用letast=parser.parse(jscode);
即可完成JS代码转换到AST的过程,这时候把ast输出来,就是跟网页中解析出来的一样的结构,输出前通常先使用JSON.stringify
把对象转json
数据,例如,console.log(JSON.stringify(ast,null,2))
.另外,parser
的parse
方法,其实是有第二个参数的
1 | letast=parser.parse(jscode,{ |
sourceType
默认是script
,但是当解析的JS代码中,含有import
、export
等关键字的时候,需指定sourceType
为module
.不然会报错
letcode=generator(ast).code;
,这里的generator其实也有其他参数
1 | //retainLines表示是否使用与源代码相同的行号,默认true |
traverse与visitor
demo
先看demo,需要把下面代码中的a
变量改成x
变量
1 | let obj = { |
把上面的代码拿到ast网站上解析一下,需要知道a变量在ast中长什么样,在ast语法树中,a变量是使用Identifier
节点表示的
1 | "params": [ |
traverse
用来遍历语法树中所有的节点的,节点可以理解为数组中的成员,visitor
则定义了遇到什么节点该做什么修改
1 | letvisitor={}; |
traverse
的第一个参数是ast语法树,visitor是一个自定义的对象,该对象可以自己定义一些方法,方法名和节点名一样,比如要处理的是Identifer
节点,那么方法名就是Identifer
,属性是一个函数,函数的参数是path对象,什么是path
对象后面会说,通过path
对象可以对节点进行修改
当然上面的代码是不严谨的,因为不可能只根据变量名去改,作用域怎么办?
两者的作用
traverse
组件用来遍历AST,简单地说就是把AST上的各个节点都走一遍,但是单纯的把节点都走一遍是没有意义的,所以taverse
需要配合visitor
使用.visitor
是一个对象,里面可以定义一些方法,用来过滤节点.概念是抽象的,接下来用个实际案例试一下traverse
和visitor
的效果:
1 | letvisitor={}; |
先是声明对象,对象的名字可以随意,然后给对象增加了一个名为FunctionExprcssion
的方法,这个名字是需要遍历的节点类型,注意大小写.
traverse
会遍历所有的节点,当节点类型为FunctionExpression
时,调用visitor
中相应的方法,如果想要处理其他节点类型例如Identifier
.可以在visitor
中继续定义方法,以Identifer
命名即可.
visitor
中的方法接收一个参数,traverse
在遍历的时候,会把当前节点的Path
对象传给它,传过来的是Path
对象而非节点(node
)
最后把visitor
作为第二个参数传到traverse
里面,传给traverse
的第一个参数是整个ast.这段代码的意思是,从头开始遍历ast中的所有节点,过滤出FunctionExpression
节点,执行相应的方法,在原始代码中有两个FunctionExpression
节点,因此会输出两次John.
visitor的三种定义方式
visitor
的定义方式还有三种
1 | constvisitor1={ |
在visitor3
中,存在一个重要的enter
,在遍历节点过程中,实际上有两次机会来访问一个节点,即进入节点时(enter
)与退出节点时(exit
),比如这样定义
1 | let visitor = {}; |
以原始代码中的add
函数为例,节点的遍历过程可描述如下:
1 | 进入FunctionExpression |
可以看出来是深度优先的搜索,正确的选择节点处理时机,有助于提高代码效率,可以看出traverse
是一个深度优先的遍历过程,因此,如果存在父子节点,那么enter
的处理时机是先处理父节点,再处理子节点,与此相反,exit
的处理时机是先处理子节点,再处理父节点,traverse
默认就是在 enter
时候处理,如果要在exit
时候处理,必须在visitor
中写明.
还可以把同一个函数用在多个节点上
1 | const visitor = { |
退出和进入的时候对同一个节点可以执行多个函数
1 | function test1(path) { |
现在还有一个问题,如果要修改变量的名有重名怎么办?我想修改指定作用域内的变量名,比如我只想修改函数内部的a
变量
1 | let a=1000; |
指定一下遍历的是FunctionExpression
节点内的子节点,并且是Identifier
子节点
1 | let visitor = {}; |
现在来解释一下上面的语法
1 | (1)为什么enter里又定义了myVisitor? |
path.traverse
还有第二个参数,是参数列表,比如现在想把代码里函数的第一个参数都修改为x,怎么办?
1 | let visitor = {}; |
现在对上面的代码进行解释
1 | (1)为什么不能直接用 path.node.params[0].name,而要提前存入 paramName 并通过 path.traverse(myVisitor, { paramName }) 传递 |
types组件
types判断节点类型
该组件主要用来判断节点类型,生成新的节点等,判断节点类型很简单,例如t.isldentifier(path.node)
,它等价于 path.node.type=="Identifier"
,还可以在判断类型的同时附加条件,演示案例如下
1 | const t = require("@babel/types"); |
types生成新节点
直接上代码,很好理解,也就是对着AST语法树的节点看需要添加什么节点,节点的属性是什么,子节点是什么
1 | let var_a = types.identifier('a'); |
valueToNode
方法也可以生成各种字面量,和上面的代码功能一样,但是不能生成FunctionExpression
节点
1 | console.log(types.valueToNode([1, "2", false, null, undefined, /\w\s/g, {x: '1000', y: 2000}])); |
path对象
先看这段代码
1 | let obj = { |
直接输出path
1 | const fs = require('fs'); |
结果可以看到path
的属性是NodePath
,NodePath
下有node
属性,从而得知node
其实是path
的一个属性
parentPath
是当前节点的父节点的path对象
container
,当container
的值是一个数组的时候,代表当前节点是存在兄弟节点的
scope
代表作用域
访问子节点属性
path对象提供了一系列api,可以方便的操作节点,还是以上面的demo为例,尝试访问BinaryExpression
的子节点,就有两种方法
1 | traverse(ast, { |
语法转代码
1 | console.log(generator(path.node).code); |
替换节点属性
比如要替换代码中的a和b为x和y
1 | traverse(ast, { |
替换节点,比如要把上面的函数return
改为返回字符串
1 | traverse(ast, { |
替换代码、删除和插入节点
替换return
语句为其他语句,使用replaceWithSourceString
方法
1 | traverse(ast, { |
父级path
前面输出的Path对象,可以看到有 parentPath
和parent
两个属性。其中parentPath
类型为NodePah
,所以它是父级 Path
。parent
类型为 Node
,所以它是父节点,因而只要获取到父级Path
,就可以调用Path
对象的各种方法去操作父节点了,父级Path
的获取可以使用 path.parentPath
.
常用方法
path.findParent
,向上遍历语法树,直到满足相应的条件返回
1 | traverse(ast, { |
Path
对象的 findParent
接收一个回调函数,在向上遍历每一个父级 Path
时,会调用该回调函数,并传入对应的父级Path
对象作为参数。当该回调函数返回真值时,则将对应的父级 Path
返回。上述代码会遍历 ReturnStatement
,然后向上找父级 Path
,当找到 Path
对象类型为 ObjectExpression
的情况时,就返回该Path
对象.
path.getFunctionParent
,向上查找与当前节点最接近的父函数 path.getFunctionParent
返回的也是 Path
对象
path.getStatementParent
,向上遍历语法树,直到找到语句父节点,Statement
语句包含很多,例如,声明语句、return
语句、if
语句、switch
语句、while
语句等等,返回的也是Path
对象,该方法从当前节点开始找起,如果想要找到return
语句的父语句,就需要从parentPath
中去调用,代码如下:
1 | console.log(path.parentPath.getStatementParent()); |
同级path
container
在介绍同级path之前,先介绍一下container
对于这段代码
1 | let obj = { |
运行
1 | traverse(ast, { |
这段解析的代码作用就是在 AST 遍历过程中,每当遇到 ReturnStatement
节点时,打印当前节点的 path
对于这一段函数代码
1 | add:function(a,b){ |
其container
如下
1 | container: [ |
可以得出:
1 | container是一个数组,它存放了一组Node节点(AST 节点)。 |
几个问题
1 | (1)key是什么? |
所以container
存储的是当前节点的同级节点
当然container
不一定都是数组,也有不是数组的情况,对于这种情况,可以理解为当前节点没有兄弟节点
同级节点有关方法
1 | traverse(ast, { |
1 | path.inList 判断是否有同级节点 |
在container
中插入节点,unshiftContainer
是在容器头部插入节点,pushContainer
是在容器后面插入节点
1 | traverse(ast, { |
scope对象
介绍
scope
提供了一些属性和方法,可以方便的查找标识符的作用域,获取标识符的所有引用,修改标识符的所有引用,以及知道标识符是否参数,标识符是否为常量,如果不是常量也可以知道在哪里修改了它,以下面的代码为例:
1 | const a = 1000; |
ast.js
1 | // 获取e变量的作用域范围,打印出作用域内的代码 |
几个问题
1 | (1)什么是绑定 |
referencePaths与constantViolations
在 Babel 的 AST 处理过程中,referencePaths
和 constantViolations
是 Binding
对象的属性,主要用于追踪变量的引用情况和是否被修改。它们是在 Babel 的作用域分析(Scope Analysis)阶段被定义的。
referencePaths
,存储当前绑定(binding
)的所有引用位置。定义在 Binding
对象中,包含所有引用了该变量的 Identifier
节点(但不包括声明)和不会包含对该变量的赋值位置(赋值的位置属于 constantViolations
)
比如
1 | function test() { |
x
的 binding
可能类似于
1 | { |
referencePaths
包含 console.log(x)
和 return x + 5
的 Identifier
节点,但不包括 let x = 10;
,因为 let
语句是变量的定义,不是引用。
constantViolations
,存储所有可能修改变量值的位置。定义同样在 Binding
对象中,包含变量的重新赋值位置(如 x = 20;
)、通过 ++
, --
, +=
等操作修改变量的地方、如果是 const
声明,理论上 constantViolations
应该为空
比如
1 | function test() { |
x
的 binding
可能类似于
1 | { |
什么时候使用 referencePaths
和 constantViolations
?
1 | 如果你想找到变量在代码中的所有引用(如 console.log(x)),用referencePaths。 |
查看绑定的referencePaths
?
1 | traverse(ast, { |
输出大概长这样
1 | [ |
先说明几个问题
1 | (1)为什么输出有两个Node? |
constantViolations
也同理了,所以可以根据标识符的binding
去修改代码
1 | traverse(ast, { |
遍历作用域
前面说过,使用path.scope.block
可以获取作用域,但是针对函数的话,需要path.scope.parent.block
获取作用域,而binding.scope
可以直接获取,不需要考虑是变量还是函数
1 | traverse(ast, { |
标识符重命名
scope.generateUidIdentifier
可以返回不容易重名的变量名
1 | traverse(ast, { |