使用 Babel 进行抽象语法树操作

5,496 阅读5分钟

什么是抽象语法树

wiki: 抽象语法树( Abstract Syntax Tree,AST ),或简称语法树( Syntax tree ),是源代码语法结构的一种抽象表示。 它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。 之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

在我们前端,可以通过 Javascript 解析器将我们程序的源代码映射成为一棵语法树,而树的每个节点对应着代码里的一种结构;比如表达式,声明语句,赋值语句等都会被映射为语法树上的一个节点,进而我们就可以通过操作语法树上的节点来控制我们的源代码;初步了解时可能感觉这个概念本身就比较抽象,但是基于它的应用我们却一点都不陌生,比如:单文件组件 .vue 文件的解析、为我们转码 ES6 语法的 babeljs、为我们压缩混淆代码的 UglifyJS 等等;

亮剑 Babeljs

Babel is a JavaScript compiler. Use next generation JavaScript, today.

Babel是一个Javascript编译器,他能让你现在就开始使用未来版的Javascript。曾经的Javascript由于语言本身的设计缺陷,饱受程序员们的诟病,如今随着ES语言规范的制定与发展,加上Typescript的横空出世,Javascript开始逐年霸占最流行语言的榜首;Babel是推动这一进程的重要推手,他能将ES6/7/8/9/10转换为浏览器能够兼容的Javascript,从现在开始,您就可以使用最新的ES语法规范,甚至处于草案阶段的规范;而这都是一切都是前所未有的!

接下来我们将使用Babel提供的相关AST操作的模块来“改头换面”我们的代码;

  • @babel/parser通过该模块来解析我们的代码生成AST抽象语法树;

  • @babel/traverse通过该模块对AST节点进行递归遍历;

  • @babel/types通过该模块对具体的AST节点进行进行增、删、改、查;

  • @babel/generator通过该模块可以将修改后的AST生成新的代码;

无中生有

Talk is cheap, show me your code!

现在假设我们在代码里使用了一个叫log的方法,如:log('Hello, world!');,但是我们并没有声明或定义该方法,一般情况下我们的代码将会报一个log is not defined的错误,现在我们通过操作ASTlog修改为console.log,这样我们的代码就不会报错了;

astexplorer.net网站里我们可以在线将代码转为AST,如下图:log('Hello, world!')AST树形结构图;

一般情况下,我们从body层级看起,其下的每一层级都有一个type字段,该字段非常重要,直接影响我们该如何对该语句或表达式进行操作,具体请看后面讲到的@babel/types

拿到代码,我们首先通过@babel/parser生成如上图所示结构的AST

const {parse} = require('@babel/parser');
const codes = "log('Hello, world!');";

const ast = parse(codes, {
  sourceType: "module"
});

对照AST分析我们需要做什么,新手强烈推荐使用astexplorer.net,比如在这里,我们的需求是将log函数转换为console.log,通过在线AST解析,我们清楚的看到了log对应的AST节点类型为Identifier,同样的,我们换成console.log('Hello, world!');,可以看到console.log对应的AST节点类型为MemberExpression,所以,我们的需求变为将此处的Identifier变为MemberExpression,不要想当然的以为直接把type属性改个值就 ok 了,接下来看看,如何将Identifier类型的节点修改为MemberExpression类型的节点;

轮到@babel/types上场了,前面两步,我们的焦点主要在AST节点的type字段上,事实上,每一个type@babel/types里都有一个同名的方法(首字母小写)用来创建该类型的节点,比如创建Identifier类型的节点,我们可以使用t.identifier方法;

好了,先来创建MemberExpression类型的console.log,此处对于新手会比较棘手,推荐好好观察在线的AST树进行反推,找到目标节点的type,接着在@babel/types文档里搜相应type的方法;如图,我们在文档里搜到memberExpression方法的定义,接着开始创建console.log节点;

const t = require('@babel/types');

function createMemberExpression() {
  return t.memberExpression(
    t.identifier('console'),
    t.identifier('log')
  );
}

如上,我们就可以通过createMemberExpression方法来生成console.log来替换log了,问题来了,如何替换?

当然,替换之前,我们需要在AST树上找到对应的节点,通过@babel/traverse我们可以对AST树的节点进行遍历;

const {default: traverse} = require('@babel/traverse');

traverse(ast, visitor);

visitor是一个由各种type或者是enterexit组成的对象,由此确定在遍历的过程中匹配到某种类型的节点后该如何操作,如我们的需求是将Identifier类型的log节点替换为MemberExpression类型的console.log,我们可以这样定义visitor

const visitor = {
  Identifier(path) {
    const {node} = path;
    if(node && node.name === 'log') {
      path.replaceWith(createMemberExpression());
      path.stop();
    }
  }
}

通过traverse方法我们可以定义各种类型节点的操作方式,回调函数的path参数提供了丰富的增、删、改、查以及类型断言的方法,比如replaceWith/remove/find/isMemberExpression

最后,我们将修改后的AST转换为Javascript代码:

const {code} = generate(ast, { /* options */ }, codes);

到这一步,我们就已经可以将代码里的log方法替换为console.log了;举一反三,我们是不是可以放开一下想象力:自己定义某种有意思或者创造性的语法规范,然后通过AST操作变换成常规的Javascript

以上例子的代码汇总为:

const t = require('@babel/types');
const {parse} = require('@babel/parser');
const {default: traverse} = require('@babel/traverse');
const {default: generate} = require('@babel/generator');

const codes = "log('Hello, world!');";

const ast = parse(codes, {
  sourceType: "module"
});

const visitor = {
  Identifier(path) {
    const {node} = path;
    if(node && node.name === 'log') {
      path.replaceWith(createMemberExpression());
      path.stop();
    }
  }
}

traverse(ast, visitor);

const {code} = generate(ast, { /* options */ }, codes);

console.log(code);
// console.log('Hello, world!');

function createMemberExpression() {
  return t.memberExpression(
    t.identifier('console'),
    t.identifier('log')
  );
}

总结

以上, 通过babeljs提供的相关模块对AST操作进行了初步的实践; 2019 年已过一半,今年无疑跨端应用火了,比如,Taro、uni-app 等,而这些框架的成功统统离不开的就是对AST的熟练掌握;所以呢,还等什么,现在上车还来得及,如果你对前端报有天马行空的想象,那么我想了解AST将会助你一臂之力!