Babel是一系列模块的结合,本文会介绍其中主要模块的使用方法。
注意:本文并不能代替API文档,详细的文档可以查阅这里。
babel-parser
babel-parser从acorn fork出来的项目,跟acorn一样执行快速并且易用性高。使用插件模式的架构,对当前非标准的特性进行扩展支持。
- 安装包
$ npm install --save @babel/parser
先从一段简单的代码解析开始
import parser from "@babel/parser";
const code = `function square(n) {
return n * n;
}`;
console.log(parser.parse(code));
相应的输出为
Node {
type: 'File',
start: 0,
end: 40,
loc: SourceLocation {
start: Position { line: 1, column: 0, index: 0 },
end: Position { line: 3, column: 1, index: 40 },
filename: undefined,
identifierName: undefined
},
errors: [],
program: Node {
type: 'Program',
start: 0,
end: 40,
loc: SourceLocation {
start: [Position],
end: [Position],
filename: undefined,
identifierName: undefined
},
sourceType: 'script',
interpreter: null,
body: [ [Node] ],
directives: []
},
comments: []
}
只传入code
的话表示使用默认的配置进行代码parse
。也可以指定配置,比如下面的代码:
parser.parse(code, {
sourceType: "module", // default: "script"
plugins: ["jsx"] // default: []
});
相应的输出为
Node {
type: 'File',
start: 0,
end: 40,
loc: SourceLocation {
start: Position { line: 1, column: 0, index: 0 },
end: Position { line: 3, column: 1, index: 40 },
filename: undefined,
identifierName: undefined
},
errors: [],
program: Node {
type: 'Program',
start: 0,
end: 40,
loc: SourceLocation {
start: [Position],
end: [Position],
filename: undefined,
identifierName: undefined
},
sourceType: 'module',
interpreter: null,
body: [ [Node] ],
directives: []
},
comments: []
}
可以看到输出的AST
中,program
节点的sourceType
变为module
。
sourceType
的值可以是module
也可以是script
,这个值代表着以哪种模式对代码进行parse
。module
模式将使用严格模式并允许模块定义,但script
不会。
注意:
sourceType
默认值为script
,这种模式下如果代码中有import
和export
在parse
时会报错。这种情况下,将sourceType
设置为module
即可避免报错。
babel-traversal
开发者可以使用该模块管理AST的状态,包括 替换
/删除
和新增
节点。
安装包
$ npm install --save @babel/traverse
使用babel-traverse
更新部分节点
import parser from "@babel/parser";
import traverse from "@babel/traverse";
const code = `function square(n) {
return n * n;
}`;
const ast = parser.parse(code);
traverse(ast, {
enter(path) {
if (
path.node.type === "Identifier" &&
path.node.name === "n"
) {
path.node.name = "x";
}
}
});
babel-types
babel-types
是lodash
风格的操作AST
节点的工具库。提供AST节点的创建
,校验
和转换
。使用精心设计的工具方法可以帮助更加单纯地把精力放在AST
操作逻辑上,而不是节点操作的细节。
安装包
$ npm install --save @babel/types
简单的使用示例
import traverse from "@babel/traverse";
import * as t from "@babel/types";
traverse(ast, {
enter(path) {
if (t.isIdentifier(path.node, { name: "n" })) {
path.node.name = "x";
}
}
});
Definitions
babel-types为每一类节点都提供了相关的定义,每个定义包含如下信息:
- 属性属于哪里???
- 合法的值域
- 创建该类节点的方法
- 访问节点的方法
- 节点的别名
一个简单的示例如下
defineType("BinaryExpression", {
builder: ["operator", "left", "right"],
fields: {
operator: {
validate: assertValueType("string")
},
left: {
validate: assertNodeType("Expression")
},
right: {
validate: assertNodeType("Expression")
}
},
visitor: ["left", "right"],
aliases: ["Binary", "Expression"]
});
Builders
上面BinaryExpression
的定义中,有一个字段是builder
。
builder: ["operator", "left", "right"]
每个节点都有一个builder方法,比如创建二元计算表达式
t.binaryExpression("*", t.identifier("a"), t.identifier("b"));
上面的代码将会创建一个AST节点
{
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "a"
},
right: {
type: "Identifier",
name: "b"
}
}
相应的代码为
a * b
Builder还会验证它创建的节点,如果使用不当,会引发描述性错误。这也引出了下面会提到的方法。
Validators
BinaryExpression
的定义中还有关于fields
的相关信息,其中就有这些field
的验证。
fields: {
operator: {
validate: assertValueType("string")
},
left: {
validate: assertNodeType("Expression")
},
right: {
validate: assertNodeType("Expression")
}
}
验证方法通常有两类
-
-
isX
。判断是否为X
。
-
t.isBinaryExpression(maybeBinaryExpressionNode);
// !!!
// 传递第二个参数,对节点的 属性/属性值,进行进一步判断
t.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
-
- 断言。
t.assertBinaryExpression(maybeBinaryExpressionNode);
t.assertBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
// Error: Expected type "BinaryExpression" with option { "operator": "*" }
第1种方法,返回bool
值,表示是否为判定的X
,并不会中断程序执行。第2种方法,抛出错误,将会中断后续执行。
Converters
制作中
babel-generator
babel-generator
包用于生成代码。接收AST输出带有sourcemap
信息的代码。
安装
$ npm install --save @babel/generator
简单使用示例
import parser from "@babel/parser";
import generate from "@babel/generator";
const code = `function square(n) {
return n * n;
}`;
const ast = parser.parse(code);
generate(ast, {}, code);
// {
// code: "...",
// map: "..."
// }
第二个参数是生成代码时的配置,默认是空对象,也可以根据说明文档传入指定的配置
generate(ast, {
retainLines: false,
compact: "auto",
concise: false,
quotes: "double",
// ...
}, code);
babel-template
babel-template
是另外一个小而美的包(前一个是babel-types
)。引入这个包可以让开发者使用模板字符串的方式替代大量的AST
操作方案来生成AST
节点。在计算机领域这个模式被称为quasiquotes
。
$ npm install --save @babel/template
国际惯例,简单的示例
import template from "@babel/template";
import generate from "@babel/generator";
import * as t from "@babel/types";
const buildRequire = template(`
var IMPORT_NAME = require(SOURCE);
`);
const ast = buildRequire({
IMPORT_NAME: t.identifier("myModule"),
SOURCE: t.stringLiteral("my-module")
});
console.log(generate(ast).code);
// var myModule = require("my-module");
开发Babel插件
前面介绍了Babel相关的基础和包的使用,下面通过开发一个Babel的插件将这些知识串联起来。
插件本质是一个方法
export default function(babel) {
// plugin contents
}
方法返回一个包含visitor
属性的对象
export default function({ types: t }) {
return {
visitor: {
// visitor contents
}
};
};
visitor上使用相应的访问器(特定的节点类型)操作节点,每个访问器有2个参数path
和state
export default function({ types: t }) {
return {
visitor: {
Identifier(path, state) {},
ASTNodeTypeHere(path, state) {}
}
};
};
介绍完插件的基本知识点之后,我们开发一个将代码中==
替换为===
的插件。
插件的逻辑非常简单
- 使用访问器
hook
节点 - 如果是
BinaryExpression
节点,则检查operator
字段,是==
则替换为===
,否则不处理。
export default function({ types: t }) {
return {
visitor: {
BinaryExpression: (path) => {
if (path.node.operator === '==') {
path.node.operator = '==='
}
}
}
};
};
如你所见,使用babel
操作代码就是这么简单!
常用操作
Visiting
获取子节点的path
访问节点的属性通常是先获取节点实例,然后通过点运算符获取相应属性的值,path.node.property
// the BinaryExpression AST node has properties: `left`, `right`, `operator`
BinaryExpression(path) {
path.node.left;
path.node.right;
path.node.operator;
}
如果获取相应属性的path
,则需要使用get
方法,参数为属性名的字符串值
BinaryExpression(path) {
path.get('left');
}
Program(path) {
path.get('body.0');
}
上面的代码中对body
的子元素访问相对特殊,不能直接get('body')
,因为body
下是BlockStatement
的数组。使用.
连接访问路径。比如对于下面的代码
export default function f() {
return bar;
}
获取其return
相应的path
,相应的代码如下所示
ExportDefaultDeclaration(path) {
path.get("declaration.body.body.0");
}
判断节点类型(babel-types)
判断二元表达式的左节点是否为名称是n
的变量
BinaryExpression(path) {
if (t.isIdentifier(path.node.left, { name: "n" })) {
// ...
}
}
判断path
的类型
BinaryExpression(path) {
if (path.get('left').isIdentifier({ name: "n" })) {
// ...
}
}
检查变量是否被引用
Identifier(path) {
if (path.isReferencedIdentifier()) {
// ...
}
}
// or
Identifier(path) {
if (t.isReferenced(path.node, path.parent)) {
// ...
}
}
查找祖先路径
有的场景下需要自当前节点向上遍历,以找到符合条件的祖先节点path
。
findParent
回调函数执行返回值为true
时,返回对应的NodePath
,遍历结束。
path.findParent((path) => path.isObjectExpression());
如果要包含当前节点,则使用find
方法
path.find((path) => path.isObjectExpression());
获取最近的祖先函数节点path
或者program
的path
也有相应的快捷方法
path.getFunctionParent();
查找兄弟路径
如果一个path
是在Function
或program
节点的body
中(数组型),那么这个path就会有兄弟路径
。
-
path.inList
检查path
是否在list
中 -
path.getSibling(index)
获取相邻指定步长的兄弟path
-
path.key
当前path在列表中的位置 -
path.container
节点的容器 -
path.listKey
列表容器的名称
var a = 1; // pathA, path.key = 0
var b = 2; // pathB, path.key = 1
var c = 3; // pathC, path.key = 2
export default function({ types: t }) {
return {
visitor: {
VariableDeclaration(path) {
// if the current path is pathA
path.inList // true
path.listKey // "body"
path.key // 0
path.getSibling(0) // pathA
path.getSibling(path.key + 1) // pathB
path.container // [pathA, pathB, pathC]
path.getPrevSibling() // path(undefined) *
path.getNextSibling() // pathB
path.getAllPrevSiblings() // []
path.getAllNextSiblings() // [pathB, pathC]
}
}
};
}
path(undefined)
是一个NodePath
,path.node === undefined
中止遍历
- 特定的条件下不执行,使用
return
。
BinaryExpression(path) {
if (path.node.operator !== '**') return;
}
- 使用
path
的api
。path.skip()
忽略对当前path
的子节点的遍历,path.stop()
停止所有未执行的遍历。
outerPath.traverse({
Function(innerPath) {
innerPath.skip(); // if checking the children is irrelevant
},
ReferencedIdentifier(innerPath, state) {
state.iife = true;
innerPath.stop(); // if you want to save some state and then stop traversal, or deopt
}
});
操作AST
替换节点
BinaryExpression(path) {
path.replaceWith(
t.binaryExpression("**", path.node.left, t.numberLiteral(2))
);
}
将同一个变量的相乘替换为该变量的2次方
function square(n) {
- return n * n;
+ return n ** 2;
}
替换单个节点为多个节点
ReturnStatement(path) {
path.replaceWithMultiple([
t.expressionStatement(t.stringLiteral("Is this the real life?")),
t.expressionStatement(t.stringLiteral("Is this just fantasy?")),
t.expressionStatement(t.stringLiteral("(Enjoy singing the rest of the song in your head)")),
]);
}
function square(n) {
- return n * n;
+ "Is this the real life?";
+ "Is this just fantasy?";
+ "(Enjoy singing the rest of the song in your head)";
}
使用源码串替换节点
FunctionDeclaration(path) {
path.replaceWithSourceString(`function add(a, b) {
return a + b;
}`);
}
原来的函数代码被直接替换为新的代码
- function square(n) {
- return n * n;
+ function add(a, b) {
+ return a + b;
}
除非处理的是动态代码,否则这种方案是不推荐的,更好的做法是在外部先将待替换的源码转成节点,然后完成替换。
插入兄弟节点
FunctionDeclaration(path) {
path.insertBefore(t.expressionStatement(t.stringLiteral("Because I'm easy come, easy go.")));
path.insertAfter(t.expressionStatement(t.stringLiteral("A little high, little low.")));
}
+ "Because I'm easy come, easy go.";
function square(n) {
return n * n;
}
+ "A little high, little low.";
往容器中插入节点
ClassMethod(path) {
path.get('body').unshiftContainer('body', t.expressionStatement(t.stringLiteral('before')));
path.get('body').pushContainer('body', t.expressionStatement(t.stringLiteral('after')));
}
class A {
constructor() {
+ "before"
var a = 'middle';
+ "after"
}
}
删除节点
FunctionDeclaration(path) {
// 把当前的函数申明节点删除
path.remove();
}
- function square(n) {
- return n * n;
- }
替换父节点
BinaryExpression(path) {
path.parentPath.replaceWith(
t.expressionStatement(t.stringLiteral("Anyway the wind blows, doesn't really matter to me, to me."))
);
}
function square(n) {
- return n * n;
+ "Anyway the wind blows, doesn't really matter to me, to me.";
}
删除父节点
BinaryExpression(path) {
path.parentPath.remove();
}
function square(n) {
- return n * n;
}
Scope
判断变量是否绑定
FunctionDeclaration(path) {
if (path.scope.hasBinding("n")) {
// ...
}
}
path.scope.hasBinding
方法将会自底向顶地遍历scope树,判断是否绑定变量n
。
如果只想在当前scope上查找,则使用path.scope. hasOwnBinding
FunctionDeclaration(path) {
if (path.scope.hasOwnBinding("n")) {
// ...
}
}
生成UID
path.scope.generateUidIdentifier
会生成一个不与给定作用域下其他变量冲突的标识符
FunctionDeclaration(path) {
path.scope.generateUidIdentifier("uid");
// Node { type: "Identifier", name: "_uid" }
path.scope.generateUidIdentifier("uid");
// Node { type: "Identifier", name: "_uid2" }
}
将变量定义提升到当前作用域的父级作用域
FunctionDeclaration(path) {
const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);
path.remove();
path.scope.parent.push({ id, init: path.node });
}
产生的变化如下
- function square(n) {
+ var _square = function square(n) {
return n * n;
- }
+ };
修改binding
及引用
FunctionDeclaration(path) {
path.scope.rename("n", "x");
}
- function square(n) {
- return n * n;
+ function square(x) {
+ return x * x;
}
也可以使用生成的UID
对binding
进行变量命名
FunctionDeclaration(path) {
// 第二个参数不传,则会自动生成uid
path.scope.rename("n");
}
- function square(n) {
- return n * n;
+ function square(_n) {
+ return _n * _n;
}
最佳实践
使用工具函数
使用工具函数能够大大减少节点操作的复杂度,减少相应的操作错误概率。如
function buildAssignment(left, right) {
return t.assignmentExpression("=", left, right);
}
按需遍历,尽早退出
遍历AST
节点开销较大,而且很容易在遍历时对不必要的节点进行访问。Babel中可以将同样操作的访问器合并到一起进行处理。
比如下面的代码
path.traverse({
Identifier(path) {
// ...
}
});
path.traverse({
BinaryExpression(path) {
// ...
}
});
从逻辑上看并没有任何问题,在需要的地方遍历AST
,但这种写法会造成对同样的树进行多次遍历,这显然是一种浪费。可以对其中的访问器进行合并,一次遍历就能完成相关的逻辑处理,如下:
path.traverse({
Identifier(path) {
// ...
},
BinaryExpression(path) {
// ...
}
});
明确规则代替遍历
在一些场景下,节点的查找规则是确定的,这种情况使用明确的查找规则代替遍历会是更优的做法。
比如查找函数参数
const nestedVisitor = {
Identifier(path) {
// ...
}
};
const MyVisitor = {
FunctionDeclaration(path) {
path.get('params').traverse(nestedVisitor);
}
};
可以换成
const MyVisitor = {
FunctionDeclaration(path) {
// 直接通过点运算符获取params列表
path.node.params.forEach(function() {
// ...
});
}
};
优化嵌套访问器
在访问器中有嵌套逻辑时,编写嵌套逻辑的代码是有其意义的
// 外层 访问器
const MyVisitor = {
FunctionDeclaration(path) {
path.traverse({
// 内层访问器
Identifier(path) {
// ...
}
});
}
};
它的问题在于,每次命中FunctionDeclaration
时都会新创建一个访问器对象,这个开销是巨大的。更好的做法是定义一个访问器对象,每次将该对象传入。
const nestedVisitor = {
Identifier(path) {
// ...
}
};
const MyVisitor = {
FunctionDeclaration(path) {
path.traverse(nestedVisitor);
}
};
如果被嵌套的访问器需要使用内部的状态,可以将state传入travese
,通过this
获取相应的状态,如下
const nestedVisitor = {
Identifier(path) {
// 通过this使用相应的状态值
if (path.node.name === this.exampleState) {
// ...
}
}
};
const MyVisitor = {
FunctionDeclaration(path) {
var exampleState = path.node.params[0].name;
// 第二个参数是传入的状态
path.traverse(nestedVisitor, { exampleState });
}
};
小心嵌套的结构
在做AST
转换时,通常会被忽视掉的是拿到的是一个嵌套结构。比如对一个类的构造函数进行处理时,类代码如下
class Foo {
constructor() {
// ...
}
}
访问其构造函数
const constructorVisitor = {
ClassMethod(path) {
if (path.node.name === 'constructor') {
// ...
}
}
}
const MyVisitor = {
ClassDeclaration(path) {
if (path.node.id.name === 'Foo') {
path.traverse(constructorVisitor);
}
}
}
上面的构造函数的访问器明显忽略了一个事实,类是可以继承的,它的构造函数可能是一个链条
class Foo {
constructor() {
class Bar {
constructor() {
// ...
}
}
}
}
假定它是一个单独的构造函数,往往会出错。
单元测试
对Babel插件的测试有几种主要的方案:快照测试/AST
测试和执行测试。
快照测试
jest
提供了比较方便的快照测试能力。针对具体的测试用例,事先准备好相应的快照文件,然后将测试用例的结果跟快照文件的内容进行比对,如果相同则测试通过,如果不同则测试失败,并抛出不同的内容。
测试用例
// src/__tests__/index-test.js
const babel = require('babel-core');
const plugin = require('../');
var example = `
var foo = 1;
if (foo) console.log(foo);
`;
it('works', () => {
const {code} = babel.transform(example, {plugins: [plugin]});
expect(code).toMatchSnapshot();
});
快照文件
exports[`test works 1`] = `
"
var bar = 1;
if (bar) console.log(bar);"
`;
如果把上面快照文件中的bar
改成baz
,重新运行jest命令,会抛出如下错误提示
Received value does not match stored snapshot 1.
- Snapshot
+ Received
@@ -1,3 +1,3 @@
"
-var bar = 1;
-if (bar) console.log(bar);"
+var baz = 1;
+if (baz) console.log(baz);"
对于内容较多的快照文件,可以使用jest -u
更新快照内容,避免手动创建。
AST
测试
除了上述的快照测试,还可以对AST
进行审查。下面是一个简单的示例
it('contains baz', () => {
const {ast} = babel.transform(example, {plugins: [plugin]});
const program = ast.program;
const declaration = program.body[0].declarations[0];
assert.equal(declaration.id.name, 'baz');
// or babelTraverse(program, {visitor: ...})
});
通过校验AST
的结构(上面的示例是特定节点),来断言插件功能是否正确。
执行测试
将AST
转换为代码,执行转换后的代码,借此判断插件是否正确。示例代码如下
it('foo is an alias to baz', () => {
var input = `
var foo = 1;
// test that foo was renamed to baz
var res = baz;
`;
var {code} = babel.transform(input, {plugins: [plugin]});
var f = new Function(`
${code};
return res;
`);
var res = f();
assert(res === 1, 'res is 1');
});
babel-plugin-tester
使用babel-plugin-tester会简化babel插件的测试,具体使用可以参考相关的文档,下面是一个简单的示例
import pluginTester from 'babel-plugin-tester';
import identifierReversePlugin from '../identifier-reverse-plugin';
pluginTester({
plugin: identifierReversePlugin,
fixtures: path.join(__dirname, '__fixtures__'),
tests: {
'does not change code with no identifiers': '"hello";',
'changes this code': {
code: 'var hello = "hi";',
output: 'var olleh = "hi";',
},
'using fixtures files': {
fixture: 'changed.js',
outputFixture: 'changed-output.js',
},
'using jest snapshots': {
code: `
function sayHi(person) {
return 'Hello ' + person + '!'
}
`,
snapshot: true,
},
},
});
至此,Babel handbook基本翻译结束,里面可能会有一些错漏,感兴趣的读者可以点击这里查看原文。