译:手把手教你如何写自定义babel代码转换

译:手把手教你如何写自定义babel代码转换

今天,我将分享如何一步步写一个自定义babel转换工具。你可以利用这项技术来自动化代码的修改,重构以及生成。

什么是babel?

Babel 是一个Javascript 编译器,它主要被用于将ECMA Script 2015以上的代码转换成目前或者更老版本浏览器或者环境可以兼容的版本。Babel的代码变换启用了插件系统,这个插件系统可以让任何人基于babel写自己的代码转换插件。

在你开始书写babel转换插件之前,你还需要了解什么是抽象语法树(AST)。

什么是抽象语法树(AST)?

我不太确信自己能比以下文章解释得更好:
articles out there on the web:
* Leveling Up One’s Parsing Game With ASTs by Vaidehi Joshi * (强烈推荐! ?)
* Wikipedia 的 Abstract syntax tree
* What is an Abstract Syntax Tree by Chidume Nnamdi
总的来说,AST是描述代码的树。在Javascript中,Javascript AST遵循了estree标准。

AST代表了你的代码,你的代码结构和意义。因此它可以让babel这类编译器理解代码,并对它做一些有意义的变换。

现在,你理解了什么是AST。让我们一起开始用AST来写一个更改你带嘛的自定义babel变换工具吧!

如何利用babal转换代码

以下展示了一个用babel做代码转换的通用样本:

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

const code = 'const n = 1';

// 将源代码转换为AST
const ast = parse(code);

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

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

这段代码需要安装@babe/core才能运行。@babel/parser@babel/traverse@babel/generator都是@babel/core的依赖项,因此只需安装@babel/core就可以了。

总的来说就是将你的代码转成AST,再转换AST,然后从转换过后的AST生成代码。

源代码 -> AST -> 转换过的AST -> 转换过的代码

然而我们也可以用另一个babel提供的接口一步完成以上过程:

import babel from '@babel/core';

const code = 'const n = 1';

const output = babel.transformSync(code, {
  plugins: [
    // 你的第一个插件 ??
    function myCustomPlugin() {
      return {
        visitor: {
          Identifier(path) {
            // 在这个例子里我们将所有变量 `n` 变为 `x`
            if (path.isIdentifier({ name: 'n' })) {
              path.node.name = 'x';
            }
          },
        },
      };
    },
  ],
});

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

现在,你完成了第一个将所有n的变量转换成x的babel插件,酷不酷?

将myCustomPlugin抽取出来放到一个新文件中,并export。然后将这个文件打包发布位一个npm包,你就可以骄傲地说你发布了一个babel插件!??

到这里,你也许会想:“对,我刚写了一个babel插件,但我完全不造它怎么回事……“。不要担心,我们一起来看看你是如何为自己写babel转换插件的。以下是详细步骤:

1. 想好你要把什么转换为什么

在示例里,我想创建一个babel插件对同事恶作剧,这个插件会:

  • 反转所有变量和方法名称
  • 把字符串分割为多个字符
function greet(name) {
  return 'Hello ' + name;
}

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

变成

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

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

这里我们保留了console.log,这样即使代码不太可读,但它仍旧可以正常工作。(我可不想破坏线上代码!)

2. 了解你的AST目标

打开看看babel AST explorer,点击不同部分的代码,再看看右侧AST中在什么位置,和怎样呈现了这段代码:
译:手把手教你如何写自定义babel代码转换_第1张图片
假如这是你第一次看到AST,多玩一会,感受它大概的样子,并了解AST上跟你的代码相对应的节点的名字。

现在我们明白目标是什么了:

  • 变量和方法名字的标识
  • 字符串的StringLiteral

3. 了解转换过后的AST长啥样

再看下babel AST explorer,但这次我们看你想要最终生成的代码。译:手把手教你如何写自定义babel代码转换_第2张图片

思考并尝试下如何将之前的AST转换成现在的AST吧。

比如说,你可以看到’H’ + ‘e’ + ‘l’ + ‘l’ + ‘o’ + ‘ ‘ + name是由BinaryExpression嵌套了StringLiteral的形式出现。

4. 写代码

现在我们再来看看代码:

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

这里的转换使用了访问者模式。

在遍历阶段,babel会先进行深度优先遍历来访问AST的每一个节点。你可以为访问指定一个回调函数,然后每当访问某个节点的时候,babel会调用这个函数,并给函数传入当前访问的节点。

在visitor对象里,你可以为回调指定特定名字的节点:

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

运行它,你会看到”string literal”和”identifier“在babel每次遇到它们的时候被调用:

identifier
identifier
string literal
identifier
identifier
identifier
identifier
string literal

在我们继续之前,我们可以看到Identifer(path){}的参数叫path而不是nodepathnode有什么区别呢?

在babel里,path是基于node的一层抽象,它提供了node之间的联系,即父级节点,并提供了领域(scope)上下文(context)等信息。此外,path还提供了replaceWithinsertBefore之类用于更新AST节点的函数。

你可以在 Jamie Kyle 的 babel handbook 中获得关于path的更多详细信息。

好了,让我们继续写我们的babel插件。

转换变量名

我们可以从AST explorer中看到,Identifier的名字被存储在name中。因此,我们需要做的便是反转这个name

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

运行它你就能看到:

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

我们就快完成了,除了一点,我们不小心把console.log也给反转了。怎样可以避免它呢?

我们再看看AST:
译:手把手教你如何写自定义babel代码转换_第3张图片
console.logMemberExpression的一部分,拥有一个叫"console"object,和叫"log"property

那让我们在当前节点是MemberExpression中的Identifier时跳过反转这步:

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

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

那为什么我们需要看Identifier的父级节点是不是console.logMemberExpression呢?为啥我们不直接比较Identifier.name === ‘console’ || Identifier.name === ‘log‘呢?

你当然可以这么做,不过它就不会反转叫consolelog的变量名了:

const log = 1;

那我怎么知道isMemberExpressionisIdentifier的呢?其实所有在@babel/types中声明的节点类型都拥有一个对应的isXxxx验证函数。如:anyTypeAnnotation函数会有一个isAnyTypeAnnotation验证函数。如果你想知道验证函数的完整列表,你可以查看源代码。

转换字符串

下一步是在StringLiteral外嵌套一个BinaryExpression

你可以使用@babel/types提供的一个工具函数来创建一个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的内容分割开,然后把每个字符变成一个StringLiteral加上BinaryExpression。最后,我们把原来的StringLiteral替换成了新建的节点。

这就完成啦!除了……碰到如下栈溢出的问题?:

RangeError: Maximum call stack size exceeded

为什么呢??

那是因为对于每遇到一个StringLiteral我们都创建更多StringLiteral,并且在每个StringLiteral中,我们都在“创建”更多StringLiteral。虽然我们是将StringLiteral替换为另一个StringLiteral,但babel会因为将它当作一个新的节点去访问这个StringLiteral,因此产生了死循环和栈溢出。

那我们怎么告诉babel一旦已经把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转换代码样例的最好的地方。

进入https://github.com/babel/babel ,找到babel-plugin-transform-*或是babel-plugin-proposal-*文件夹,它们是所有babel提供的转换插件,你可以从这里找到babel如何转换可为空操作符 , 可选链 等等。

参考

* 	 [Babel docs](https://babeljs.io/docs/en/)  &  [Github repo](https://github.com/babel/babel) 
* 	 [Babel Handbook](https://github.com/jamiebuilds/babel-handbook)  by  [Jamie Kyle](https://jamie.build/) 
* 	 [Leveling Up One’s Parsing Game With ASTs](https://medium.com/basecs/leveling-up-ones-parsing-game-with-asts-d7a6fc2400ff)  by  [Vaidehi Joshi](https://twitter.com/vaidehijoshi) 

翻译自朋友的博文:https://lihautan.com/step-by-step-guide-for-writing-a-babel-transformation/

你可能感兴趣的:(实验室)