[译] 一步步教你如何编写自定义的 Babel 转换

1,507 阅读8分钟

原文地址:Step-by-step guide for writing a custom babel transformation,2019.12.12,by Tan Li Hau

今天,我将分享一份逐步指南(step-by-step guide),教你如何编写自定义的 Babel 转换。你可以使用这种技术来编写自己的自动化代码修改、重构和代码生成工具。

什么是 Babel?

Babel 是一个 JavaScript 编译器,主要用于将 ECMAScript 2015+ 代码转换为当前或与旧版浏览器环境(向后)兼容的 JavaScript 代码。Babel 使用一套 插件系统 进行代码转换,因此任何人都可以为 Babel 编写自己的转换插件。

在开始为 Babel 编写转换插件之前,你需要了解什么是抽象语法树(Abstract Syntax Tree,简称 AST)。

什么是抽象语法树?

我不确定我能比网络上那些精彩的文章更好地解释这个问题:

总结下来,AST 就是你代码的树形表示。对 JavaScript 而言,AST 遵循 estree 规范

AST 代表你的代码、结构以及含义。因此,它能让 Babel 这样的编译器理解代码并对其进行特定的有意义的转换。

现在你知道了什么是 AST,让我们编写一个自定义的 Babel 转换来借助 AST 修改你的代码吧。

如何使用 Babel 转换代码?

以下是使用 Babel 进行代码转换的通用模板:

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

const code = 'const n = 1';

// 解析 code -> AST
const ast = parse(code);

// 转换 AST
traverse(ast, {
    enter(path) {
        // 本例中,将所有变量 `n` 改为 `x`
        if (path.isIdentifier({ name: 'n' })) {
            path.node.name = 'x';
        }
    }
})

// 生成 code <- AST
const output = generator(ast, code);
console.log(output.code); // 'const x = 1;'

TIP: 你需要安装 @babel/core 才能运行此程序。@babel/parser@babel/traverse@babel/generator 都是 @babel/core 的依赖项,因此只需安装 @babel/core 即可。

因此,一般的办法是将你的代码解析为 AST,再转换 AST,然后从转换后的 AST 生成代码。

code -> AST -> 转换后的 AST -> 转换后的 code

然而,我们可以使用 Babel 的另一个 API 来完成上述所有操作:

import babel from '@babel/core';

const code = 'const n = 1';

const output = babel.transformSync(code, {
    plugins: [
        // 你的第一个 Babel 插件 😎😎
        function myCustomPlugin() {
            return {
                visitor: {
                    Identifier(path) {
                        // 本例中,将所有变量 `n` 改为 `x`
                        if (path.isIdentifier({ name: 'n' })) {
                            path.node.name = 'x';
                        }
                    }
                }
            }
        }
    ]
});

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

现在,你已经编写了第一个 Babel 转换插件,将所有名为 n 的变量替换为 x,这是多酷啊!

myCustomPlugin 函数提取到一个新文件中并导出它。将该文件 打包并发布为 npm 包,你就可以自豪地说你已经发布了一个 Babel 插件!🎉🎉

此时,你一定会想:“是,我刚刚写了一个 Babel 插件,但我不知道它是如何工作的...”。别担心,让我们深入探讨一下如何编写自己的 Babel 转换插件吧!

你可以参照下列步骤开始编写:

1、首先你要知道想转换成什么样的代码

在这个例子中,我想通过创建一个 Babel 插件来恶作剧我的同事,该插件将会:

  • 反转所有变量和函数的名称

  • 将字符串拆分为单个字符

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

console.log(greet('tanhauhau')); // Hello tanhauhau

转换成

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

console.log(teerg('t' + 'a' + 'n' + 'h' + 'a' + 'u' + 'h' + 'a' + 'u')); // Hello tanhauhau

当然,我们必须要保留 console.log。这样,即使代码难以阅读,仍然可以正常工作。(我不想破坏生产代码!)

2、知道在 AST 上要定位什么

前往 babel AST explorer(译注:类似的工具还有 AST Explorer),点击代码的不同部分,查看在 AST 中是如何表示的:

image.png

如果这是你第一次接触 AST,请花点时间感受一下,了解它的样子,并参照代码了解 AST 上的节点名称。

所以,现在我们知道需要针对以下内容进行定位:

  • Identifier:表示变量和函数名称

  • StringLiteral:表示字符串字面量

3、知道转换后的 AST 长什么样子

再次前往 babel AST Explorer,但这一次使用你想要生成的结果代码。

image 1.png

玩一下,思考如何将之前的 AST 转换为当前的 AST。

比如,你可以看到 'H' + 'e' + 'l' + 'l' + 'o' + ' ' + eman 是由嵌套的 BinaryExpressionStringLiteral 构成的。

4、编写代码

现在再看一下我们的代码:

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

转换使用了 访问者模式(visitor pattern)

在遍历阶段,Babel 将进行 深度优先搜索遍历 并访问 AST 中的每个节点。你可以在访问器(visitor)中指定回调方法,当访问节点时,Babel 将使用当前正在访问的节点调用回调方法。

在访问者对象中,您可以指定要回调的节点名称:

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

运行上述代码,你会看到每当 Babel 遇到"字符串字面量(string literal)"和"标识符(identifier)"时都会被调用:

identifier
identifier
string literal
identifier
identifier
identifier
identifier
string literal

在我们继续之前,让我们看一下 Identifer(path) {} 的参数。它使用了 path 而不是 nodepathnode 有什么区别呢?🤷‍

在 Babel 中, pathnode 的抽象,它提供了节点之间的关系链接。比如节点的父级(parent)以及作用域(scope)、上下文(context)等信息。此外,path 还提供诸如 replaceWithinsertBeforeremove 等方法,这些方法将更新并反映在底层 AST 节点上。

TIP: 你可以在 Jamie KyleBabel 手册 中阅读有关 path 的更多详细信息。

那么让我们继续编写我们的 Babel 插件。

变量名转换

AST explorer 中可以看出,Identifier 的名称存储在名为 name 的属性中,因此我们要做的就是反转名称。

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

执行会看到:

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

elosnoc.gol(teerg('tanhauhau')); // Hello tanhauhau

快成功了,只是我们不小心把 console.log 也反转了。我们该如何防止这种情况发生?

请再次查看 AST:

image 2.png

console.logMemberExpression 的一部分,由一个 object(即 "console") 和 一个 property(即 "log") 组成。

因此,我们检查下当前 Identifier 是否在 MemberExpression 中,如果是,就不做反转操作:

Identifier(path) {
  if (
    // 排除 console.log
    !(
      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('tanhauhau')); // Hello tanhauhau

那么,为什么我们要检查 Identifier 的父级是否不是 console.log MemberExpression?而不能只比较当前 Identifier.name === 'console' || Identifier.name === 'log'

也可以这样做,但如果有名为 consolelog 的变量,则无法反转这种变量名了。

const log = 1;

那么我是如何知道 isMemberExpressionisIdentifier 方法的呢?因为在 @babel/types 中指定的所有节点类型都有相应的 isXxxx 校验函数,例如:anyTypeAnnotation 函数就有一个对应的 isAnyTypeAnnotation 校验函数。如果您想了解验证器函数的详尽列表,可以前往 翻阅源代码

字符串字面量转换

下一步是将 StringLiteral 生成嵌套的 BinaryExpression

要创建 AST 节点,你可以使用 @babel/types 中的工具函数。 @babel/types 也可通过 @babel/corebabel.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 内容拆分为每个字符一个 StringLiteral,并使用 BinaryExpression 组合起来。最后,我们用新创建的节点替换 StringLiteral

...就是这样!但我们遇到了堆栈溢出(Stack Overflow) 😅:

RangeError: Maximum call stack size exceeded

为什么 🤷‍?

这是因为对于每个 StringLiteral,我们创建了更多的 StringLiteral。在每个 StringLiteral 中,我们又“创建”了更多的 StringLiteral。虽然,我们将一个 StringLiteral 替换为另一个 StringLiteral,但 Babel 会将其视为新节点,继续访问新创建的 StringLiteral,从而导致无限递归和堆栈溢出。

那么,我们如何告诉 Babel 一旦用 newNode 替换了 StringLiteral 后就可以停止,不必继续向下访问新创建的节点呢?

使用 path.skip() 就行,它会跳过遍历当前路径的子节点:

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

...现在代码可以正常工作,而且没有堆栈溢出错误了!

总结

以下,就是我们用 Babel 进行的第一个代码转换:

const babel = require('@babel/core');
const code = `
function greet(name) {
  return 'Hello ' + name;
}
console.log(greet('tanhauhau')); // Hello tanhauhau
`;
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);

到这里,我们再总结一下转换步骤:

  1. 首先你要知道想转换成什么样的代码

  2. 知道在 AST 上要定位什么

  3. 知道转换后的 AST 长什么样子

  4. 编写代码

更多资源

如果你有兴趣想要了解更多,Babel 的 Github 仓库 始终是查找编写 Babel 转换代码示例的最佳位置。

前往 github.com/babel/babel,查找 babel-plugin-transform-*babel-plugin-proposal-* 文件夹,它们都是 Babel 转换插件。在这里,你可以找到关于如何使用 Babel 转换 链判断运算符(optional chaining operator)?.Null 判断运算符(optional chaining)?? 等更多内容的代码。

使用 Babel 和 JavaScript 操作 AST

如果你喜欢迄今为止所读的内容,并想学习如何使用 Babel 实现它。我创建了一个 视频课程,逐步展示如何编写 Babel 插件和 codemod。

在视频课程中,我详细介绍了一些技巧和窍门。例如:如何处理作用域、如何使用状态以及嵌套遍历等。

听起来很有趣,看一下这个视频课程。

参考