让我们一步一步得自定义 Babel 插件

1,491 阅读5分钟

简介

Babel 是一款 javascript 的编译器,其主要工作是把 ECMAScript 2015+ 标准以上的代码向下兼容到当前的浏览器或环境。这直接带来的好处是可以采用更高版本的标准语法去编写代码,而无需考虑过多的环境兼容因素。

Babel 提供了插件系统,任何人都可以基于 babel 编写插件来实现自定义语法转换,这对于开发者来说是个福音。

而这一切的基础需要了解的一个概念:语法树(Abstract Syntax Tree),简称:AST

AST 表示的你的代码,对于 AST 的编辑等同于对代码的编辑,传统的编译器也有做同样工作的结构被叫做具体语法解析树(CST),而 AST 是 CST 的简化版本。

如何使用 Babel 转换代码

下面是个简单的转换例子:

import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';

const code = 'const n = 1';

// parse the code -> ast
const ast = parse(code);

// transform the ast
traverse(ast, {
  enter(path) {
    // in this example change all the variable `n` to `x`
    if (path.isIdentifier({ name: 'n' })) {
      path.node.name = 'x';
    }
  },
});

// generate code <- ast
const output = generate(ast, code);
console.log(output.code); // 'const x = 1;'

解析(parse)-> 转换(transform)-> 生成(generate),三个明确的步骤完成代码转换操作。

你可以直接安装@babel/core完成以上操作,@babel/parser@babel/traverse@babel/generator 都是 @babel/core 的依赖,所以直接安装 @babel/core 即可。

通过插件来实现转换

除了上面的方式,更为通用的做法是通过插件来实现:

import babel from '@babel/core';

const code = 'const n = 1';

const output = babel.transformSync(code, {
  plugins: [
    // your first babel plugin 😎😎
    function myCustomPlugin() {
      return {
        visitor: {
          Identifier(path) {
            // in this example change all the variable `n` to `x`
            if (path.isIdentifier({ name: 'n' })) {
              path.node.name = 'x';
            }
          },
        },
      };
    },
  ],
});

console.log(output.code); // 'const x = 1;'

提取myCustomPlugin 函数到单独的文件,然后导出它作为npm 包发布,你就可以很自豪得说我发布一个 Babel 插件了,😁。

Babel的AST如何工作的?

1. 想做一些转换的任务

我们做一次 code 的混淆转换,把变量名和函数名倒转,并把字符串做拆解相加,目的是降低代码可读性。

同时要求保持原有的功能,源码如下:

function greet(name) {
  return 'Hello ' + name;
}

console.log(greet('lumin'));

转换成:

function teerg(eman) {
  return 'H' + 'e' + 'l' + 'l' + 'o' + ' ' + eman;
}

console.log(teerg('l' + 'u' + 'm' + 'i' + 'n'));

这里我们依然需要保持console.log函数不变,因为要保持功能正常。

2. 源码是如何表示成 AST

你可以使用babel-ast-explorer工具来查看 AST 树,它表示成下面这样:

ast

现在我们需要知道两个关键词:

  • Identifier 用于记录函数名变量名
  • StringLiteral 用于记录字符串

3. 转换后的 AST 又是如何呢

通过babel-ast-explorer工具,我们可以看到转换后的 AST 结构:

ast

4. coding now !

我们的代码会是长这样:

function myCustomPlugin() {
  return {
    visitor: {
      Identifier(path) {
        // ...
      },
    },
  };
}

AST 遍历方式使用的是访问者模式

在遍历阶段,babel 会采用深度优先搜索来访问每个 AST 的节点(node),你可以在visitor里上指定一个回调方法,当遍历到当前节点时会调用该回调方法。

visitor对象上,指定一个 node 名来得到你想要的回调:

function myCustomPlugin() {
  return {
    visitor: {
      Identifier(path) {
        console.log('identifier');
      },
      StringLiteral(path) {
        console.log('string literal');
      },
    },
  };
}

运行之后,我们会得到一下日志输出:

identifier
identifier
string literal
identifier
identifier
identifier
identifier
string literal

继续往下前,我们先了解Identifer(path) {}的参数path

path 表示两个节点之间连接的对象,包含了域(scope)、上下文(context)等属性,也提供了insertBeforereplaceWithremove等方法来添加、更新、移动和删除节点。

5. 转换变量名

参考babel-ast-explorer工具,我们可以发现变量名存储在Identifername的里,所以我们可以直接反转 name 并重新赋值:

Identifier(path) {
  path.node.name = path.node.name.split('').reverse().join('');
}

运行之后,我们得到以下代码:

function teerg(eman) {
  return 'Hello ' + eman;
}

elosnoc.gol(teerg('lumin'));

显然我们不希望console.log发生改变,那如何保持它不变呢?

我们再次回到源码中 console 的 AST 表示方式:

ast

可以看到console.logMemberExpression的一部分,console 为对象(object),而 log 为属性(property)。

于是我们做一些前置校验:

Identifier(path) {
  if (!(
      path.parentPath.isMemberExpression() &&
      path.parentPath.get('object').isIdentifier({ name: 'console' }) &&
      path.parentPath.get('property').isIdentifier({ name: 'log' })
    )
  ) { path.node.name = path.node.name.split('').reverse().join('');
 }
}

结果:

function teerg(eman) {
  return 'Hello ' + eman;
}

console.log(teerg('lumin'));

ok,看起来还不错。

Q&A

Q:我们如何知道一个方法是isMemberExpressionisIdentifier呢?

A:OK,Babel 的所有节点类型定义在被@babel/types里,通过isXxxx验证函数来匹配。例如:anyTypeAnnotation 函数会有对应的isAnyTypeAnnotation 验证器,如果你想查看更多详细的验证器,可以查看babel 源码部分

6. 转换字符串

接下来做的是从StringLiteral里生成嵌套的二元表达式(BinaryExpression)。

创建 AST 节点,你可以使用@babel/types里的通用函数,@babel/core里的babel.types也可以是一样的:

// ❌代码尚不完整
StringLiteral(path) {
  const newNode = path.node.value
    .split('')
    .map(c => babel.types.stringLiteral(c))
    .reduce((prev, curr) => {
      return babel.types.binaryExpression('+', prev, curr);
    });

  path.replaceWith(newNode);
}

上面我们把节点的值(path.node.value)拆分成字节数组,并遍历创建StringLiteral,然后通过二元表达式(BinaryExpression)串联StringLiteral,最后把当前StringLiteral替换成新的我们建立的 AST 节点。

一切视乎没问题,但是我们却得到一个错误:

RangeError: Maximum call stack size exceeded

为什么🤷‍?

A:因为我们创建StringLiteral之后,Babel 会去访问(visit)它,最后无限循环的执行导致栈溢出(stack overflow)。

我们可以通过path.skip()来告诉 babel 跳过对当前节点子节点的遍历:

// ✅修改后的代码
StringLiteral(path) {
  const newNode = path.node.value
    .split('')
    .map(c => babel.types.stringLiteral(c))
    .reduce((prev, curr) => {
      return babel.types.binaryExpression('+', prev, curr);
    });
  path.replaceWith(newNode);
  path.skip();
}

7. 最后完整代码

const babel = require('@babel/core');
const code = `
function greet(name) {
  return 'Hello ' + name;
}
console.log(greet('lumin'));
`;
const output = babel.transformSync(code, {
  plugins: [
    function myCustomPlugin() {
      return {
        visitor: {
          StringLiteral(path) {
            const concat = path.node.value
              .split('')
              .map(c => babel.types.stringLiteral(c))
              .reduce((prev, curr) => {
                return babel.types.binaryExpression('+', prev, curr);
              });
            path.replaceWith(concat);
            path.skip();
          },
          Identifier(path) {
            if (
              !(
                path.parentPath.isMemberExpression() &&
                path.parentPath.get('object').isIdentifier({ name: 'console' }) &&
                path.parentPath.get('property').isIdentifier({ name: 'log' })
              )
            ) {
              path.node.name = path.node.name.split('').reverse().join('');
            }
          },
        },
      };
    },
  ],
});
console.log(output.code);

ok,这就是全部了!

深入的探索

如果你意犹未尽,Babel 仓库提供了更多转换代码的例子,它会是个好地方。

查找github.com/babel/babel里的babel-plugin-transform-*babel-plugin-proposal-*目录,可以看到空值合并运算符(Nullish coalescing operator:??)和可选链操作符(Optional chaining operator:?.) 等提议阶段的转换源码。

还有一个babel 的插件手册,强烈建议大家去看看。

扩展阅读:

> github.com/jamiebuilds…

> lihautan.com/step-by-ste…

> zh.wikipedia.org/wiki/访问者模式

> zh.wikipedia.org/wiki/深度优先搜索

> babeljs.io/docs/en/