本文最后更新于:2021年1月21日 下午
信息 抽象语法树 AST
维基百科-抽象语法树:https://zh.wikipedia.org/wiki/%E6%8A%……
抽象语法树(Abstract Syntax Tree,AST)是源代码语法结构的一种抽象表示 它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构 之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节
例:代码转抽象语法树
while b ≠ 0 if a > b a := a − b else b := b − a return a
需要注意的是,Babel
并非操作的基础的ESTree
,它 修改/扩充 了一些节点类型 (自行记录:Babel parser
与ESTree
的不同之处 )
链接信息
安装/使用环境 Node.js 安装 Node.js 与Babel 首先你要有Node.js
环境
Post not found: Node.js
全局安装 babel
的各个库
npm install @babel/core -g npm install @babel/parser -g npm install @babel/traverse -g npm install @babel/generator -g npm install @babel/types -g
简单描述一下刚刚安装的库的作用:
@babel/core
是 Babel
的核心代码
@babel/parser
常用于解析代码语法树的模块
@babel/traverse
常用于操作语法树的模块
@babel/generator
常用于将语法树生成代码的模块
@babel/types
包含类型信息,在生成节点等与类型相关的操作会用到
安装测试 启动 Node.js
环境
引入babel库,测试是否成功
const parser = require ("@babel/parser" );
这一步可能会出现说没有库的问题,这是由于环境变量导致的 一般而言,安装Node.js
会默认配置上环境变量,但这个配置不一定有用 此时你可以手动设置环境变量,或者用绝对路径来引入
const parser = require ("E:/Environment/Nodejs/node_global/node_modules/@babel/parser" );
在线解析网站 链接: https://astexplorer.net/ 这个是一个解析代码语法树的网站 这里是需要用它的Babel
解析语法树,所以要做对应设置 打开以后将解析方式改为@babel/parser
在左边的文本框里输入一些代码,就能在右边看到对应解析出来的语法树了
认识AST 思路 如果将代码中各类的语句都看成节点,那么整个程序的代码就像是由一个个节点组成的树状结构,代码的运行就像是不完全遍历这颗树一样
AST解析源代码实际上就是这样干的 在代码解析完成后,你会得到一颗语法树,这颗语法树包含了代码中的所有内容
你可以对它进行树(数据结构)可以进行的所有操作(从复杂的 有序树转二叉树
到简单的 替换节点
,计算树的深度
,剪枝
,拼接
都可以)
最后想象一下,程序运行 就是在这棵树里进行 深度优先遍历
实际上,它的结果更接近图(数据结构),因为可能存在调用循环/递归(树中的节点不允许连接自身或有多个父级)
常见节点信息解析
节点属性
记录的信息
type
当前节点的类型
start
当前节点的起始位
end
当前节点的末尾
loc
当前节点所在的行列位置 起始于结束的行列信息
errors
File节点所持有的特有属性,可以不用理会
program
包含整个源代码,不包含注释节点
comments
源代码中所有的注释会显示在这里
就这么直接列出来可能还不太容易理解 可以利用 在线解析网站 解析一小段javascript
代码来帮助理解/记忆
输入仪式性的语句 console.log("Hello World")
的变种代码
function test ( ) { var i = ['H' , 'e' , 'l' , 'l' , 'o' , ' ' , 'W' , 'o' , 'r' , 'l' , 'd' ]; i = i.join('' ); return i }console .log(test())
最外层File节点
先将节点收得差不多,观察最外面的File节点
的信息
type
start
end
loc
你能在绝大多数节点里看到
常用的类型判断方法t.is****(node)
就是判断当前节点type是否为某个类型
层级结构分析 实际上,可以通过观察 type
知道一些信息
层级分析例1: 比如说这整个文件包含了了代码块主要有两部分: 一个函数声明 和 一句语句
层级分析例2: 又比如说,这个函数里包含了三个部分:一个声明 ,一个语句 ,一个返回
解析代码 Code→AST @babel/parser
能将javascript
代码解析成语法树 这个库在前文中已经安装好了,现在用它来解析语法树吧
例:用babel解析语法树
const parser = require ("@babel/parser" );var jscode = "var a = 123;" ;let ast = parser.parse(jscode);console .log(JSON .stringify(ast, null , '\t' ));
此处会输出和网页解析出来的内容一样的结果 做简单分析时,用网页解析比较快捷方便,很少写代码来解析
AST寻路 在解析出语法树后,就可以进行各类的操作了 但和想要对树(数据结构)进行任何操作一样,在对AST进行各类操作之前,你要先会 寻路 (到达(遍历到)指定的节点)才能进行操作
寻路这个词是我自己瞎编的
特征 想要约朋友来家里玩,可以向别人描述 家的附近 或者 家本身 有什么特征。比如说:
“我家门口有一颗歪脖子树” 或者 “我家房顶有一个大洞”
这样子能很方便得让别人找到位置
寻路的过程也一样。想要到达 某个/某些 特定节点上,可以根据这个节点的 类型
与 路径
给出判断依据来到达特定的 某个/某些 节点
遍历整个语法树 绝大多数情况下,对AST进行寻路比上面描述的情况更糟糕一些
在AST中寻路的情况更像是: 你的朋友坐在一个城市观光车上,观光车会穿过城市里的每一个街道。你的朋友无法控制车前进的方向,你只能让车在你描述的情况下停下,用这种方式来到达你家
默认情况下,@babel/traverse
能让你遍历整个AST 如果想要寻路到达某个节点,就需要给出你想要寻路的 那个/那些 节点的特征进行判断操作
使用 Path 进行寻路 在使用 enter
遍历所有节点的时候,参数 path
会传入当前的路径 可以根据path
进行各种判断,继而进行各类操作
Path基本信息
获取 Path
对应节点的 源代码 path.toString()
获取 Path
对应节点的 类型 path.type
例: 寻找代码中包含 字符a 的节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;var jscode = ` var b = 123; a = b + 1; ` ;const visitor = { enter(path) { if (path.toString().indexOf('a' ) > -1 ){ console .log('当前路径类型' , path.type); console .log('当前路径源码:' , path.toString()); console .log('这是一个变量声明节点:\t' , path.isVariableDeclaration()) console .log('--------------------' ) } } }let ast = parser.parse(jscode); traverse(ast, visitor);
会得到如下的结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 当前路径类型 Program 当前路径源码: var b = 123; a = b + 1; 这是一个变量声明节点: false -------------------- 当前路径类型 VariableDeclaration 当前路径源码: var b = 123; 这是一个变量声明节点: true -------------------- 当前路径类型 ExpressionStatement 当前路径源码: a = b + 1; 这是一个变量声明节点: false -------------------- 当前路径类型 AssignmentExpression 当前路径源码: a = b + 1 这是一个变量声明节点: false -------------------- 当前路径类型 Identifier 当前路径源码: a 这是一个变量声明节点: false --------------------
此处是对源代码进行判断,所以var
这种关键词也是会被认为是包含a
的
使用 节点 进行寻路
例子:根据特点寻找语法树中的节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;var jscode = "var a = 123;" ;const visitor = { VariableDeclaration(path) { console .log('当前路径 源码:\n' , path.toString()); console .log('当前路径 节点:\n' , path.node.toString()) console .log('当前路径 父级节点:\n' , path.parent.toString()); console .log('当前路径 父级路径:\n' , path.parentPath.toString()) console .log('当前路径 类型:\n' , path.type) } }let ast = parser.parse(jscode); traverse(ast, visitor);
由于直接输出Node
会显示非常多的信息,此处用toString()
意思意思 得到的输出结果:
当前路径 源码: var a = 123; 当前路径 节点: [object Object] 当前路径 父级节点: [object Object] 当前路径 父级路径: var a = 123; 当前路径 类型: VariableDeclaration
这里用了一个 VariableDeclaration
(变量声明节点)来表述特点,更多得节点类型可以查看 Github-Babel-节点类型文档
注意:所有符合你描述的特点的节点都会进行声明中的操作 (此处为输出信息) 由于此处只有一句声明语句的代码,所以随意给出一个合适的特点就能轻松到达目标节点 如果代码量大,又希望只改动部分,那么最好给出更多的语句以精确修改目标
用AST生成代码 AST→Code 尽管你现在可能还并不知道如何对语法树节点进行操作 但根据AST生成代码的方法还是要学会的,要不然最终结果长什么样都不知道
可以使用@babel/generator
来实现 根据AST生成代码的这个过程
例:根据AST生成代码以查看修改结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;const generator = require ("@babel/generator" ).default;const jscode = 'function squire(){var n = 3; return n*n;}' ;let ast = parser.parse(jscode);const visitor = { BinaryExpression (path ) { if (path.node.operator == '*' ) { path.node.operator = '+' ; } } } traverse(ast, visitor); console .log(generator(ast)['code' ]);
运行后会输出结果
function squire () { var n = 3; return n + n; }
此处将所有的表达式乘法转为加法,并且将改变后的AST利用@babel/generator
生成出新的代码
常用基础操作 创建节点 @babel/types
包含了各个节点的定义 可以通过使用@babel/types
的类型名,查阅@babel/types
官方文档 ,获取对应类型的构造函数,创建对应类型的节点
例:利用 @babel/types 提供的类来直接创建节点,编写ast内容
const t = require ("@babel/types" );const generator = require ("@babel/generator" ).default;var callee = t.memberExpression(t.identifier('console' ), t.identifier('log' )), args = [t.NumericLiteral(666 )], call_exp = t.callExpression(callee, args), exp_statement = t.ExpressionStatement(call_exp)console .log(generator(exp_statement)['code' ])
输出结果:
插入节点 NodePath.insertAfter()
方法用于在当前path
前面插入节点NodePath.insertBefore()
方法用于在当前path
后面插入节点
例:向语法树中插入节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const t = require ("@babel/types" );const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;const generator = require ("@babel/generator" ).default;const jscode = `function square(n) { var a = 2; }` ;const ast = parser.parse(jscode);const visitor = { VariableDeclaration (path ) { var node = t.NumericLiteral(1 ) path.insertAfter(node) node = t.NumericLiteral(3 ) path.insertBefore(node) } } traverse(ast, visitor);console .log(generator(ast)['code' ])
结果:
function square (n ) { 3 var a = 2 ; 1 }
替换节点 NodePath.replaceInline
方法用于替换对应path的节点
例:寻找计算节点,计算好了以后,生成新的数字节点,替换原本的节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const t = require ("@babel/types" );const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;const generator = require ("@babel/generator" ).default;const jscode = `function square(n) { return 1 + 1; }` ;const ast = parser.parse(jscode);const visitor = { BinaryExpression (path ) { var result = eval (path.toString()) var node = t.NumericLiteral(result) path.replaceInline(node); } } traverse(ast, visitor);console .log(generator(ast)['code' ])
得到结果:
function square (n ) { return 2 ; }
删除节点 NodePath.remove()
用于删除路径对应的节点 由于是对path
操作,所以务必注意不要误删
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;const generator = require ("@babel/generator" ).default;const jscode = `function square(n) { var a = 1; return 1 + 1; }` ;const ast = parser.parse(jscode);const visitor = { VariableDeclaration (path ) { path.remove() } } traverse(ast, visitor);console .log(generator(ast)['code' ])
得到结果:
function square (n ) { return 1 + 1 ; }
作用域Scope 与 被绑定量Binding 词汇描述
作用域scope - 有效范围 作用域(scope
,或译作有效范围)是名字(name
)与实体(entity
)的绑定(binding
)保持有效的那部分计算机程序 不同的编程语言可能有不同的作用域和名字解析。而同一语言内也可能存在多种作用域,随实体的类型变化而不同 作用域类别影响变量的绑定方式,根据语言使用静态作用域还是动态作用域变量的取值可能会有不同的结果
摘自:维基百科-作用域
名字绑定binding 名字绑定(简称绑定)是把实体(数据 或/且 代码)关联到标识符 标识符绑定到实体被称为引用该对象 机器语言没有内建的标识符表示方法,但程序设计语言实现了名字与对象的绑定 绑定最初是与作用域相关,因为作用域确定了哪个名字绑定到哪个对象——在程序代码中的哪个位置与哪条执行路径
摘自:维基百科-名字与绑定
最简易理解
对于复杂程度不高的代码,可以可简单的理解为:
一个函数 就是一个作用域
一个变量 就是一个绑定 ,依附在作用域 上
这对于复杂的代码可能并不会成立,但用于学习最基本的内容已经足够
作用域 Scope @Babel
解析出来的语法树节点对象会包含作用域信息,这个信息会作为节点Node
对象的一个属性保存 这个属性本身是一个Scope
对象,其定义位于node_modules/@babel/traverse/lib/scope/index.js
中
例: 查看基本的 作用域与绑定 信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;const jscode = ` function squire(i){ return i * i * i; } function i() { var i = 123; i += 2; return 123; } ` ;let ast = parser.parse(jscode);const visitor = { "FunctionDeclaration" (path){ console .log("\n\n这里是函数 " , path.node.id.name + '()' ) path.scope.dump(); } } traverse(ast, visitor);
执行 Scope.dump()
,会得到自底向上的 作用域与变量信息 得到结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 这里是函数 squire() ------------------------------------------------------------ # FunctionDeclaration - i { constant: true, references: 3, violations: 0, kind: 'param' } # Program - squire { constant: true, references: 0, violations: 0, kind: 'hoisted' } - i { constant: true, references: 0, violations: 0, kind: 'hoisted' } ------------------------------------------------------------ 这里是函数 i() ------------------------------------------------------------ # FunctionDeclaration - i { constant: false, references: 0, violations: 1, kind: 'var' } # Program - squire { constant: true, references: 0, violations: 0, kind: 'hoisted' } - i { constant: true, references: 0, violations: 0, kind: 'hoisted' } ------------------------------------------------------------
输出查看方法
描述 此处从两个函数节点输出了其作用域的信息
这两个函数都是定义在同一级下的,所以都会输出相同的父级作用域Program
的信息
你会发现,代码中有非常多个i
,有的是函数定义,有的是参数,有的是变量。仔细观察它们的不同之处 解释器就是通过 不同层级的作用域 与 绑定定义信息 来区分不同的名称的量的
绑定 Binding Binding
对象用于存储 绑定 的信息 这个对象会作为Scope
对象的一个属性存在 同一个作用域可以包含多个 Binding
你可以在 @babel/traverse/lib/scope/binding.js
中查看到它的定义
显示 Binding 的信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;const jscode = ` function a(){ var a = 1; a = a + 1; return a; } function b(){ var b = 1; var c = 2; b = b - c; return b; } ` ;let ast = parser.parse(jscode);const visitor = { BlockStatement (path ) { console .log("\n此块节点源码:\n" , path.toString()) console .log('----------------------------------------' ) var bindings = path.scope.bindings console .log('作用域内 被绑定量 数量:' , Object .keys(bindings).length) for (var binding_ in bindings){ console .log('名字:' , binding_) binding_ = bindings[binding_]; console .log('类型:' , binding_.kind) console .log('定义:' , binding_.identifier) console .log('是否会被修改:' , binding_.constant) console .log('被修改信息信息记录' , binding_.constantViolations) console .log('是否会被引用:' , binding_.referenced) console .log('被引用次数' , binding_.references) console .log('被引用信息NodePath记录' , binding_.referencePaths) } } } traverse(ast, visitor);
会输出一大堆信息。其对应的意义已经写在代码中,可以自行查看
作用 在解混淆中,作用域与绑定 主要用来处理边界的问题 即:某个量哪里引用了,在哪里定义
例:删除所有定义了, 却从未使用的变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 const parser = require ("@babel/parser" );const traverse = require ("@babel/traverse" ).default;const generator = require ("@babel/generator" ).default;const jscode = ` var a = 1; var b = 2; function squire(){ var c = 3; var d = 4; return a * d; var e = 5; } var f = 6; ` ;let ast = parser.parse(jscode);const visitor = { VariableDeclarator(path) { const func_name = path.node.id.name; const binding = path.scope.getBinding(func_name); if (binding && !binding.referenced){ path.remove(); } }, } traverse(ast, visitor);console .log(generator(ast)['code' ]);
得到输出
var a = 1 ;function squire () { var d = 4 ; return a * d; }
这里使用了Scope.getBinding()
方法来获取Binding
对象, 判断其引用情况来对语法树进行修改
相关链接