如果你能熟练地操作 AST ,那么你真的可以为所欲为。

前言

说到 babel 你肯定会先想到 babel 可以将还未被浏览器实现的 ES6 规范转换成能够运行 ES5 规范,或者可以将 JSX 转换为浏览器能识别的 HTML 结构,那么 babel 是如何进行这个转换的步骤呢,下面我将通过开发一个简单的 babel 插件来解释这整个过程,希望你对 Babel 插件原理与 AST 有新的认知。

Babel 运行阶段

从上面的分析,我们大概能猜出 Babel 的运行过程是:原始代码 -> 修改代码,那么在这个转换的过程中,我们需要知道以下三个重要的步骤。

解析

首先需要将 JavaScript 字符串经过词法分析、语法分析后,转换为计算机更易处理的表现形式,称之为“抽象语法树(AST)”,这个步骤我们使用了 Babylon 解析器。

转换

当 JavaScript 从字符串转换为 AST 后,我们就能更方便地对其进行浏览、分析和有规律的修改,根据我们的需求,将其转换为新的 AST,babel-traverse 是一个很好的转换工具,使得我们能够很便利的操作 AST 。

生成

最后,我们将修改完的 AST 进行反向处理,生成 JavaScript 字符串,整个转换过程也就完成了,这一步当中,我们使用到了 babel-generator 模块。

什么是 AST

之前听过一句话:“如果你能熟练地操作 AST ,那么你真的可以为所欲为。”,当时并不理解其含义,直到真正了解 AST 后,才发现 AST 对编程语言的重要性是不可估量的。

在计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。

之所以说语法是「抽象」的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

JavaScript 程序一般是由一系列字符组成的,我们可以使用匹配的字符([], {}, ()),成对的字符(’’, “”)和缩进让程序解析起来更加简单,但是对计算机来说,这些字符在内存中仅仅是个数值,并不能处理这些高级问题,所以我们需要找到一种方式,将其转换成计算机能理解的结构。

我们简单看下面的代码:

1
2
let a = 2;
a * 8

将其转换为 AST 会是怎样的呢,我们使用 astexplorer 在线 AST 转换工具,可以得到以下树结构:

image

为了更形象表述,我们将其转换为更直观的结构图形:

image

AST 的根节点都是 Program ,这个例子中包含了两部分:

  1. 一个变量申明(VariableDeclarator),将标识符(Identifier) a 赋值为数值(NumericLiteral) 3。

  2. 一个二元表达式语句(BinaryExpression),描述为标志符(Identifier)为 a,操作符(operator) + 和数值(NumericLiteral) 5。

这只是一个简单的例子,在实际开发中,AST 将会是一个巨型节点树,将字符串形式的源代码转换成树状的结构,计算机便能更方便地处理,我们使用的 Babel 插件,也就是对 AST 进行插入/移动/替换/删除节点,创建成新的 AST ,再将 AST 转换为字符串源代码,这便是 Babel 插件的原理,之所以能够“为所欲为”,其原因就是可以将原始代码按照指定逻辑转换为你想要的代码。

开发 Babel 插件 Demo

基础概念

一个典型的 Babel 插件结构,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default function(babel) {
var t = babel.types;
return {
visitor: {
ArrayExpression(path, state) {
path.replaceWith(
t.callExpression(
t.memberExpression(t.identifier('mori'), t.identifier('vector')),
path.node.elements
)
);
},
ASTNodeTypeHere(path, state) {}
}
};
};

我们要关注的几个点为:

  • babel.types: 用来操作 AST 节点,如创建、转换、校验等。
  • vistor: Babel 采用递归的方式访问 AST 的每个节点,之所以叫做visitor,只是因为有个类似的设计模式叫做访问者模式,如上述代码中的 ArrayExpression ,当遍历到 ArrayExpression 节点时,即触发对应函数。
  • path: path 是指 AST 节点的对象,可以用来获取节点的属性、节点之间的关联。
  • state: 指插件的状态,可以用过 state 来获取插件中的配置项。
  • ArrayExpression、ASTNodeTypeHere: 指 AST 中的节点类型。

需求分析

因为是 Demo ,我们需求很简单,我们开发的 Bable 插件名称叫 vincePlugin,在使用的时候,能配置插件的参数,使得插件能按照我们配置的参数进行转换。

1
2
3
4
5
6
7
// babel 参数配置

plugins: [
[vincePlugin, {
name: 'vince'
}]
]

转换效果:

1
2
3
var fool = [1,2,3];
// translate to =>
var fool = vince.init(1,2,3)

初始化项目

为了大家更方便的阅读代码,源码已经上传到GitHub: babel-plugin-demo

了解了以上概念与需求后,我们就可以开始进行 Babel 插件开发,开始之前先创建一个项目目录,初始化 npm ,并安装 babel-core :

1
2
3
mkdir babel-plugin-demo && cd babel-plugin-demo
npm init -y
npm install --save-dev babel-core

创建 plugin.js babel 插件文件,我们将会在这里写转换的逻辑代码:

1
2
3
4
5
6
7
8
9
// plugin.js
module.exports = function(babel) {
var t = babel.types;
return {
visitor: {
// ...
}
};
};

创建原始代码 index.js

1
var fool = [1,2,3];

创建 test.js 测试函数,这里我们进行对插件的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// test.js
var fs = require('fs');
var babel = require('babel-core');
var vincePlugin = require('./plugin');

// read the code from this file
fs.readFile('index.js', function(err, data) {
if(err) throw err;

// convert from a buffer to a string
var src = data.toString();

// use our plugin to transform the source
var out = babel.transform(src, {
plugins: [
[vincePlugin, {
name: 'vince'
}]
]
});

// print the generated code to screen
console.log(out.code);
});

我们通过 node test.js,来测试 babel 插件的转换输出。

节点对比

  • 原始代码 var fool = [1,2,3]; 通过 AST 分析出来的节点如图:

image

  • 转换后代码 var bar = vince.init(1, 2, 3);,通过 AST 分析出来的节点如图:

image

我们通过用红色标注来区分原始与转换后的 AST 结构图,现在我们可以很清晰的看到我们需要替换的节点,将 ArrayExpression 替换为 CallExpression ,在 CallExpression 节点中中增加一个 MemberExpression,并且保留原始的三个 NumericLiteral。

plugin 编写

首先,我们需要替换的是 ArrayExpression ,所以给 vistor 添加 ArrayExpression 方法。

1
2
3
4
5
6
7
8
9
10
11
// plugin.js
module.exports = function(babel) {
var t = babel.types;
return {
visitor: {
ArrayExpression: function(path, state) {
// ...
}
}
};
};

当 Babel 遍历 AST 时,当发现含有 visitor 上有对呀节点方法时,即会触发这个方法,并且将上下文传入(path, state),在函数里面我们进行节点的分析和替换操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// plugin.js
module.exports = function(babel) {
var t = babel.types;
return {
visitor: {
ArrayExpression: function(path, state) {
// 替换该节点
path.replaceWith(
// 创建一个 callExpression
t.callExpression(
t.memberExpression(t.identifier(state.opts.name), t.identifier('init')),
path.node.elements
)
);
}
}
};
};

我们需要将 ArrayExpression 替换为 CallExpression,可以通过 t.callExpression(callee, arguments) 来生成 CallExpression,第一个参数是 MemberExpression,通过t.memberExpression(object, property) 来生成,然后再将原有的三个 NumericLiteral 设置为第二个参数,于是就完成了我们的需求。

这里我们要注意 state.opts.name 中指的是配置 plugin 时,设置的 config 参数。

更多的转换方式和节点属性,可以查阅 babel-types 的文档

测试plugin

我们回到test.js,运行node test.js,便会得出:

1
2
3
node test.js

=> var bar = vince.init(1, 2, 3);

到这里,我们简易的 Babel 插件便完成好了,实际上的开发需求要复杂的多,但是主要的逻辑还是离不开上面的几个概念。

总结

还是回到开始那句话“如果你能熟练地操作 AST ,那么你真的可以为所欲为。”,我们能够通过 AST 将原始代码转换成我们所需要的任何代码,甚至你能创建一个私人的 ESXXX,添加你创造的新规范。AST 并不是一个很复杂的技术活,很大一部分可以视为“苦力活”,因为遇到复杂的转换需求可能需要编写写很多逻辑代码。

通过阅读这篇文章,我们了解了 Babel 插件的实现原理,并且实践了一个 Plugin,除此之外,我们也理解了 AST 的概念,认识到了其强大之处。

引用:

Babel 用户手册

Babel 插件手册