跳到主内容

Babel 插件手册 - 转换篇

· 18分钟阅读

访问

获取子节点的 Path

为了得到一个 AST 节点的属性值,我们一般先访问到该节点,然后利用 path.node.property 方法即可。

// the BinaryExpression AST node has properties: `left`, `right`, `operator`
BinaryExpression(path) {
path.node.left;
path.node.right;
path.node.operator;
}

如果你想访问到该属性内部的path,使用 path 对象的get方法,传递该属性的字符串形式作为参数。

BinaryExpression(path) {
path.get('left');
}
Program(path) {
path.get('body.0');
}

检查节点的类型

如果你想检查节点的类型,最好的方式是:

BinaryExpression(path) {
if (t.isIdentifier(path.node.left)) {
// ...
}
}

你同样可以对节点的属性们做浅层检查:

BinaryExpression(path) {
if (t.isIdentifier(path.node.left, { name: "n" })) {
// ...
}
}

功能上等价于:

BinaryExpression(path) {
if (
path.node.left != null &&
path.node.left.type === "Identifier" &&
path.node.left.name === "n"
) {
// ...
}
}

检查路径(Path)类型

一个路径具有相同的方法检查节点的类型:

BinaryExpression(path) {
if (path.get('left').isIdentifier({ name: "n" })) {
// ...
}
}

就相当于:

BinaryExpression(path) {
if (t.isIdentifier(path.node.left, { name: "n" })) {
// ...
}
}

检查标识符(Identifier)是否被引用

Identifier(path) {
if (path.isReferencedIdentifier()) {
// ...
}
}

或者:

Identifier(path) {
if (t.isReferenced(path.node, path.parent)) {
// ...
}
}

找到特定的父路径

有时你需要从一个路径向上遍历语法树,直到满足相应的条件。

对于每一个父路径调用callback并将其NodePath当作参数,当callback返回真值时,则将其NodePath返回。.

path.findParent((path) => path.isObjectExpression());

如果也需要遍历当前节点:

path.find((path) => path.isObjectExpression());

查找最接近的父函数或程序:

path.getFunctionParent();

向上遍历语法树,直到找到在列表中的父节点路径

path.getStatementParent();

获取同级路径

如果一个路径是在一个 FunctionProgram中的列表里面,它就有同级节点。

  • 使用path.inList来判断路径是否有同级节点,
  • 使用path.getSibling(index)来获得同级路径,
  • 使用 path.key获取路径所在容器的索引,
  • 使用 path.container获取路径的容器(包含所有同级节点的数组)
  • 使用 path.listKey获取容器的 key

这些 API 用于 babel-minify 中使用的 transform-merge-sibling-variables 插件.

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]
},
},
};
}

停止遍历

如果你的插件需要在某种情况下不运行,最简单的做法是尽早写回。

BinaryExpression(path) {
if (path.node.operator !== '**') return;
}

如果您在顶级路径中进行子遍历,则可以使用 2 个提供的 API 方法:

path.skip() skips traversing the children of the current path. path.stop() stops traversal entirely.

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
},
});

Manipulation

Replacing a node

BinaryExpression(path) {
path.replaceWith(
t.binaryExpression("**", path.node.left, t.numberLiteral(2))
);
}
  function square(n) {
- return n * n;
+ return n ** 2;
}

Replacing a node with multiple nodes

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)";
}

Note: When replacing an expression with multiple nodes, they must be statements. This is because Babel uses heuristics extensively when replacing nodes which means that you can do some pretty crazy transformations that would be extremely verbose otherwise.

Replacing a node with a source string

FunctionDeclaration(path) {
path.replaceWithSourceString(`function add(a, b) {
return a + b;
}`);
}
- function square(n) {
- return n * n;
+ function add(a, b) {
+ return a + b;
}

Note: It's not recommended to use this API unless you're dealing with dynamic source strings, otherwise it's more efficient to parse the code outside of the visitor.

Inserting a sibling node

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.";

Note: This should always be a statement or an array of statements. This uses the same heuristics mentioned in Replacing a node with multiple nodes.

Inserting into a container

If you want to insert into an AST node that is an array like body. Similar to insertBefore/insertAfter, except that you have to specify the listKey, which is usually body.

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"
}
}

Removing a node

FunctionDeclaration(path) {
path.remove();
}
- function square(n) {
- return n * n;
- }

Replacing a parent

Just call replaceWith with the parentPath: path.parentPath

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.";
}

Removing a parent

BinaryExpression(path) {
path.parentPath.remove();
}
  function square(n) {
- return n * n;
}

Scope

Checking if a local variable is bound

FunctionDeclaration(path) {
if (path.scope.hasBinding("n")) {
// ...
}
}

This will walk up the scope tree and check for that particular binding.

You can also check if a scope has its own binding:

FunctionDeclaration(path) {
if (path.scope.hasOwnBinding("n")) {
// ...
}
}

Generating a UID

This will generate an identifier that doesn't collide with any locally defined variables.

FunctionDeclaration(path) {
path.scope.generateUidIdentifier("uid");
// Node { type: "Identifier", name: "_uid" }
path.scope.generateUidIdentifier("uid");
// Node { type: "Identifier", name: "_uid2" }
}

Pushing a variable declaration to a parent scope

Sometimes you may want to push a VariableDeclaration so you can assign to it.

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;
- }
+ };

Rename a binding and its references

FunctionDeclaration(path) {
path.scope.rename("n", "x");
}
- function square(n) {
- return n * n;
+ function square(x) {
+ return x * x;
}

Alternatively, you can rename a binding to a generated unique identifier:

FunctionDeclaration(path) {
path.scope.rename("n");
}
- function square(n) {
- return n * n;
+ function square(_n) {
+ return _n * _n;
}

Plugin Options

If you would like to let your users customize the behavior of your Babel plugin you can accept plugin specific options which users can specify like this:

{
plugins: [
[
"my-plugin",
{
option1: true,
option2: false,
},
],
];
}

These options then get passed into plugin visitors through the state object:

export default function ({ types: t }) {
return {
visitor: {
FunctionDeclaration(path, state) {
console.log(state.opts);
// { option1: true, option2: false }
},
},
};
}

These options are plugin-specific and you cannot access options from other plugins.

Pre and Post in Plugins

Plugins can have functions that are run before or after plugins. They can be used for setup or cleanup/analysis purposes.

export default function ({ types: t }) {
return {
pre(state) {
this.cache = new Map();
},
visitor: {
StringLiteral(path) {
this.cache.set(path.node.value, 1);
},
},
post(state) {
console.log(this.cache);
},
};
}

Enabling Syntax in Plugins

Babel plugins themselves can enable parser plugins so that users don't need to install/enable them. This prevents a parsing error without inheriting the syntax plugin.

export default function ({ types: t }) {
return {
inherits: require("babel-plugin-syntax-jsx"),
};
}

Throwing a Syntax Error

If you want to throw an error with babel-code-frame and a message:

export default function ({ types: t }) {
return {
visitor: {
StringLiteral(path) {
throw path.buildCodeFrameError("Error message here");
},
},
};
}

The error looks like:

file.js: Error message here
7 |
8 | let tips = [
> 9 | "Click on any AST node with a '+' to expand it",
| ^
10 |
11 | "Hovering over a node highlights the \
12 | corresponding part in the source code",

Building Nodes

When writing transformations you'll often want to build up some nodes to insert into the AST. As mentioned previously, you can do this using the builder methods in the babel-types package.

The method name for a builder is simply the name of the node type you want to build except with the first letter lowercased. For example if you wanted to build a MemberExpression you would use t.memberExpression(...).

The arguments of these builders are decided by the node definition. There's some work that's being done to generate easy-to-read documentation on the definitions, but for now they can all be found here.

A node definition looks like the following:

defineType("MemberExpression", {
builder: ["object", "property", "computed"],
visitor: ["object", "property"],
aliases: ["Expression", "LVal"],
fields: {
object: {
validate: assertNodeType("Expression"),
},
property: {
validate(node, key, val) {
let expectedType = node.computed ? "Expression" : "Identifier";
assertNodeType(expectedType)(node, key, val);
},
},
computed: {
default: false,
},
},
});

Here you can see all the information about this particular node type, including how to build it, traverse it, and validate it.

By looking at the builder property, you can see the 3 arguments that will be needed to call the builder method (t.memberExpression).

builder: ["object", "property", "computed"],

Note that sometimes there are more properties that you can customize on the node than the builder array contains. This is to keep the builder from having too many arguments. In these cases you need to set the properties manually. An example of this is ClassMethod.

// Example
// because the builder doesn't contain `async` as a property
var node = t.classMethod("constructor", t.identifier("constructor"), params, body);
// set it manually after creation
node.async = true;

You can see the validation for the builder arguments with the fields object.

fields: {
object: {
validate: assertNodeType("Expression")
},
property: {
validate(node, key, val) {
let expectedType = node.computed ? "Expression" : "Identifier";
assertNodeType(expectedType)(node, key, val);
}
},
computed: {
default: false
}
}

You can see that object needs to be an Expression, property either needs to be an Expression or an Identifier depending on if the member expression is computed or not and computed is simply a boolean that defaults to false.

So we can construct a MemberExpression by doing the following:

t.memberExpression(
t.identifier("object"),
t.identifier("property")
// `computed` is optional
);

Which will result in:

object.property;

However, we said that object needed to be an Expression so why is Identifier valid?

Well if we look at the definition of Identifier we can see that it has an aliases property which states that it is also an expression.

aliases: ["Expression", "LVal"],

So since MemberExpression is a type of Expression, we could set it as the object of another MemberExpression:

t.memberExpression(t.memberExpression(t.identifier("member"), t.identifier("expression")), t.identifier("property"));

Which will result in:

member.expression.property;

It's very unlikely that you will ever memorize the builder method signatures for every node type. So you should take some time and understand how they are generated from the node definitions.

You can find all of the actual definitions here and you can see them documented here

Best Practices

Create Helper Builders and Checkers

It's pretty simple to extract certain checks (if a node is a certain type) into their own helper functions as well as extracting out helpers for specific node types.

function isAssignment(node) {
return node && node.operator === opts.operator + "=";
}

function buildAssignment(left, right) {
return t.assignmentExpression("=", left, right);
}

Avoid traversing the AST as much as possible

Traversing the AST is expensive, and it's easy to accidentally traverse the AST more than necessary. This could be thousands if not tens of thousands of extra operations.

Babel optimizes this as much as possible, merging visitors together if it can in order to do everything in a single traversal.

Merge visitors whenever possible

When writing visitors, it may be tempting to call path.traverse in multiple places where they are logically necessary.

path.traverse({
Identifier(path) {
// ...
},
});

path.traverse({
BinaryExpression(path) {
// ...
},
});

However, it is far better to write these as a single visitor that only gets run once. Otherwise you are traversing the same tree multiple times for no reason.

path.traverse({
Identifier(path) {
// ...
},
BinaryExpression(path) {
// ...
},
});

Do not traverse when manual lookup will do

It may also be tempting to call path.traverse when looking for a particular node type.

const nestedVisitor = {
Identifier(path) {
// ...
},
};

const MyVisitor = {
FunctionDeclaration(path) {
path.get("params").traverse(nestedVisitor);
},
};

However, if you are looking for something specific and shallow, there is a good chance you can manually lookup the nodes you need without performing a costly traversal.

const MyVisitor = {
FunctionDeclaration(path) {
path.node.params.forEach(function () {
// ...
});
},
};

Optimizing nested visitors

When you are nesting visitors, it might make sense to write them nested in your code.

const MyVisitor = {
FunctionDeclaration(path) {
path.traverse({
Identifier(path) {
// ...
},
});
},
};

However, this creates a new visitor object every time FunctionDeclaration() is called. That can be costly, because Babel does some processing each time a new visitor object is passed in (such as exploding keys containing multiple types, performing validation, and adjusting the object structure). Because Babel stores flags on visitor objects indicating that it's already performed that processing, it's better to store the visitor in a variable and pass the same object each time.

const nestedVisitor = {
Identifier(path) {
// ...
},
};

const MyVisitor = {
FunctionDeclaration(path) {
path.traverse(nestedVisitor);
},
};

If you need some state within the nested visitor, like so:

const MyVisitor = {
FunctionDeclaration(path) {
var exampleState = path.node.params[0].name;

path.traverse({
Identifier(path) {
if (path.node.name === exampleState) {
// ...
}
},
});
},
};

You can pass it in as state to the traverse() method and have access to it on this in the visitor.

const nestedVisitor = {
Identifier(path) {
if (path.node.name === this.exampleState) {
// ...
}
},
};

const MyVisitor = {
FunctionDeclaration(path) {
var exampleState = path.node.params[0].name;
path.traverse(nestedVisitor, { exampleState });
},
};

Being aware of nested structures

Sometimes when thinking about a given transform, you might forget that the given structure can be nested.

For example, imagine we want to lookup the constructor ClassMethod from the Foo ClassDeclaration.

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);
}
},
};

We are ignoring the fact that classes can be nested and using the traversal above we will hit a nested constructor as well:

class Foo {
constructor() {
class Bar {
constructor() {
// ...
}
}
}
}

Unit Testing

There are a few primary ways to test babel plugins: snapshot tests, AST tests, and exec tests. We'll use jest for this example because it supports snapshot testing out of the box. The example we're creating here is hosted in this repo.

First we need a babel plugin, we'll put this in src/index.js.

module.exports = function testPlugin(babel) {
return {
visitor: {
Identifier(path) {
if (path.node.name === "foo") {
path.node.name = "bar";
}
},
},
};
};

Snapshot Tests

Next, install our dependencies with npm install --save-dev babel-core jest, and then we can begin writing our first test: the snapshot. Snapshot tests allow us to visually inspect the output of our babel plugin. We give it an input, tell it to make a snapshot, and it saves it to a file. We check in the snapshots into git. This allows us to see when we've affected the output of any of our test cases. It also gives use a diff in pull requests. Of course you could do this with any test framework, but with jest updating the snapshots is as easy as jest -u.

// 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();
});

This gives us a snapshot file in src/__tests__/__snapshots__/index-test.js.snap.

exports[`test works 1`] = `
"
var bar = 1;
if (bar) console.log(bar);"
`;

If we change 'bar' to 'baz' in our plugin and run jest again, we get this:

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);"

We see how our change to the plugin code affected the output of our plugin, and if the output looks good to us, we can run jest -u to update the snapshot.

AST Tests

In addition to snapshot testing, we can manually inspect the AST. This is a simple but brittle example. For more involved situations you may wish to leverage babel-traverse. It allows you to specify an object with a visitor key, exactly like you use for the plugin itself.

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: ...})
});

Exec Tests

Here we'll be transforming the code, and then evaluating that it behaves correctly. Note that we're not using assert in the test. This ensures that if our plugin does weird stuff like removing the assert line by accident, the test will still fail.

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 core uses a similar approach to snapshot and exec tests.

babel-plugin-tester

This package makes testing plugins easier. If you're familiar with ESLint's RuleTester this should be familiar. You can look at the docs to get a full sense of what's possible, but here's a simple example:

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,
},
},
});