玩转Babel

2022-08-24 16:31:30 浏览数 (2)

什么是Babel

Babel 是 JavaScript 编译器,更确切地说是源码到源码的编译器,通常也叫做“转换编译器(transpiler)”。 意思是说你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。

image.pngimage.png

Babel 是一个将高级语法转成低级语法的工具。这个过程是在发布之前就完成,js引擎解析运行的是转化后的代码。

Babel 主要用来做以下几件事情:

  • 转换语法(例如jsx)
  • 目标环境中缺少的 Polyfill 功能(例如core-js)
  • 源代码转换 (codemods)

Babel的处理流程

从代码的输入到最终输出结果,Babel 进行了以下几个主要的流程。

词法分析

Babel在拿到代码后首先会进行词法分析。词法分析就是遍历代码,找到对应的关键词并标注,最终得到一个 toke 数组。

image.pngimage.png

可以看到,在输入 console.log('hello world'); 词法分析的结果会将 console 整体定义为 Identifier 类型。对于用单引号扩起来的 hello world ,则解析为 String 类型。

语法分析

词法分析仅仅只是拿到了一个 token 数组,而代码的具体意义还无法知晓。语法分析就是在token列表的基础上赋予实际的语法含义,最终得到一颗语法树(AST)。AST 遵循 estree 规范,可以使用 astesplorer 网站清晰的看出源代码解析成 AST 的结果

image.pngimage.png

可以看到,整行代码作为了一个节点,被解析为CallExpression类型,表示这行代码是一个调用语句。其中MemberExpression类型表示被调用的主体,而参数部分放在了arguments中。

image.pngimage.png

当我们增加一条 if 语句的时候,可以发现刚刚的整颗树被放进了一个 BlockStatement 类型的节点中。

每一个节点都有一些共有属性和特有属性,共有属性有:描述类型的type、描述词语所在文件中的开始结束位置 start、end 方便与后续生成 sourceMap 。特有属性不同的节点会不一样。

无论代码如何变化,一颗AST树可以完美的描述我们的代码。

遍历并修改AST树

AST 实际上是一个非常复杂的对象。Babel 在遍历 AST 树的每一个节点的过程中还会根据需要执行对应的转换器,例如:@babel/plugin-transform-runtime@babel/plugin-transform-typescript等。

而转换器则会去对 AST 进行增删改等操作。

生成最终产物

上一步我们根据需要将 AST 树进行了修改,最终我们还是需要 Javascript 代码,所以最后还需要把 AST 树转换成最终代码。

生成代码的过程中会遍历 AST 树,遍历过程中根据节点的 type 类型调用不同的 generate 函数从而输出对应节点的源代码。

image.pngimage.png

例如当我们写一个 class 时,对应的 AST 节点是 ClassDeclaration 以及 ClassBody 。

image.pngimage.png

对应的构造函数可以在 @babel/generator 包里面找到。

image.pngimage.png

在 generator 的过程中还可以通过参数配置是否生成sourceMap 。sourceMap中记录了源代码与目标代码的映射关系,方便从目标代码中定位问题。

编写自己的Babel插件

虽然大多数情况下,第三方插件都能满足我们的日常需求。但对于一些比较特殊的定制功能,则需要自己去开发插件。

这不,情人节快到了吗。这就写一个为项目中所有 console.log 增加一个xxx520的参数吧。

如果你的对象也是一个前端工程师,我想这可能比玫瑰花更能打动她。

访问者模式

当我们谈及“进入”一个节点,实际上是说我们在访问它们, 之所以使用这样的术语是因为有一个访问者模式(visitor)的概念。.

访问者是一个用于 AST 遍历的跨语言的模式。 简单的说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。 这么说有些抽象所以让我们来看一个例子。

代码语言:javascript复制
const MyVisitor = {
  Identifier() {
    console.log("Called!");
  }
};

// 你也可以先创建一个访问者对象,并在稍后给它添加方法。
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}

这是一个简单的访问者,把它用于遍历中时,每当在树中遇见一个 Identifier 的时候会调用 Identifier() 方法。

Paths(路径)

AST 通常会有许多节点,那么节点之间如何相互关联呢? 我们可以使用一个可操作和访问的巨大可变对象表示节点之间的关联关系,或者也可以用Paths(路径)来简化这件事情。

Path 是表示两个节点之间连接的对象。

例如,如果有下面这样一个节点及其子节点︰

代码语言:txt复制
{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  ...
}

将子节点 Identifier 表示为一个路径(Path)的话,看起来是这样的:

代码语言:txt复制
{
  "parent": {
    "type": "FunctionDeclaration",
    "id": {...},
    ....
  },
  "node": {
    "type": "Identifier",
    "name": "square"
  }
}

同时它还包含关于该路径的其他元数据:

代码语言:txt复制
{
  "parent": {...},
  "node": {...},
  "hub": {...},
  "contexts": [],
  "data": {},
  "shouldSkip": false,
  "shouldStop": false,
  "removed": false,
  "state": null,
  "opts": null,
  "skipKeys": null,
  "parentPath": null,
  "context": null,
  "container": null,
  "listKey": null,
  "inList": false,
  "parentKey": null,
  "key": null,
  "scope": null,
  "type": null,
  "typeAnnotation": null
}

当然路径对象还包含添加、更新、移动和删除节点有关的其他很多方法。

编写插件

首选需要准备好需要转换的代码。

代码语言:txt复制
const code = `
console.log('hello world');
if (true) {
    console.error('if true');
}
`

有了代码,我们就可以调用transform方法进行转换。该方法可以配置插件。

代码语言:txt复制
babel.transform(code, {
    plugins: ['./plugin']
}, (err, result) => {
    console.log('转换前:', code);
    console.log('-----------------------');
    console.log('转换后:', result.code);
})

完整代码如下:

代码语言:txt复制
const babel = require('@babel/core')

const code = `
console.log('hello world');
if (true) {
    console.error('if true');
}
`

babel.transform(code, {
    plugins: ['./plugin']
}, (err, result) => {
    console.log('转换前:', code);
    console.log('-----------------------');
    console.log('转换后:', result.code);
})

准备工作做完了,剩下的就是要新建一个plugin.js文件,在里面实现转换逻辑。

代码语言:javascript复制
function plugin (babel) {
  const { types: t } = babel;
  return {
    visitor: {
      //...
    },
  };
}

module.exports = plugin

一个简单插件结构就完成了,接下来我们只需要在visitor对象中添加需要访问的节点对应的函数就行了,函数名为节点的类型。先不着急写代码,可以看一下代码的AST结构。

image.pngimage.png

可以看到,我们要改的cosole.log代码就在MemberExpression节点中。因此可以在visitor中写一个MemberExpression函数。

代码语言:txt复制
function plugin (babel) {
  const { types: t } = babel;
  return {
    visitor: {
      MemberExpression(path) {
        //...
      }
    },
  };
}

module.exports = plugin

Babel 在遍历 AST 过程中,只要遇到MemberExpression节点都会执行这个方法。AST 中的MemberExpression节点可能很多。实际上我们只想修改console.log,所以还需要增加一些条件进一步筛选。

代码语言:txt复制
function plugin (babel) {
  const { types: t } = babel;
  return {
    visitor: {
      MemberExpression(path) {
        if (
            t.isIdentifier(path.node.object, { name: 'console' }) &&
            t.isIdentifier(path.node.property, { name: 'log' })
        ) {
            //...
        }
      }
    },
  };
}

module.exports = plugin

这里用到了isIdentifier方法,用来判定节点对应的属性是否与我们预想的一致。而MemberExpression节点有两个属性object以及property。只要这两属性的nameconsole以及log,那就是我们需要改变的console.log语句了。

筛选好目标语句后,我们还需要修改它。因为我们需要为console.log语句增加一个入参,所以要先创建一个Literal节点。然后将该节点插入到arguments属性中去就可以了。

代码语言:txt复制
function plugin (babel) {
  const { types: t } = babel;
  return {
    visitor: {
      MemberExpression(path) {
        if (
            t.isIdentifier(path.node.object, { name: 'console' }) &&
            t.isIdentifier(path.node.property, { name: 'log' })
        ) {
            let firstArgument = t.stringLiteral('xxx 520')
            path.parent.arguments.unshift(firstArgument)
        }
      }
    },
  };
}

module.exports = plugin

可以看到转换之后的AST结构如下。

代码语言:txt复制
Node {
  type: 'CallExpression',
  start: 1,
  end: 27,
  loc: SourceLocation {
    start: Position { line: 2, column: 0, index: 1 },
    end: Position { line: 2, column: 26, index: 27 },
    filename: undefined,
    identifierName: undefined
  },
  callee: Node {
    type: 'MemberExpression',
    start: 1,
    end: 12,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    object: Node {
      type: 'Identifier',
      start: 1,
      end: 8,
      loc: [SourceLocation],
      name: 'console',
      leadingComments: undefined,
      innerComments: undefined,
      trailingComments: undefined
    },
    computed: false,
    property: Node {
      type: 'Identifier',
      start: 9,
      end: 12,
      loc: [SourceLocation],
      name: 'log',
      leadingComments: undefined,
      innerComments: undefined,
      trailingComments: undefined
    },
    leadingComments: undefined,
    innerComments: undefined,
    trailingComments: undefined
  },
  arguments: [
    { type: 'StringLiteral', value: 'xxx 520' },
    Node {
      type: 'StringLiteral',
      start: 13,
      end: 26,
      loc: [SourceLocation],
      extra: [Object],
      value: 'hello world',
      leadingComments: undefined,
      innerComments: undefined,
      trailingComments: undefined
    }
  ],
  leadingComments: undefined,
  innerComments: undefined,
  trailingComments: undefined
}

目标代码也是符合预期的。

image.pngimage.png

因为我们只改了console.log所以console.error没有变化。

案例

下面我们来看一个官方插件的案例:@babel/plugin-proposal-decorators

在 Typescript 中是可以使用装饰器写法的,但是在 Javascript 中目前这一语法还处于提案阶段(tc39/proposal-decorators@d6c056fa06)。但是可以使用 Babel 提前使用到这一新特性。

代码语言:txt复制
const babel = require('@babel/core')

const code = `
@logger
class MyClass {}

function logger(target) {
  target.log = function(params) {
    console.log(params)
  };
}
`

babel.transform(code, {
    plugins: [
        [
            '@babel/plugin-proposal-decorators',
            { version: 'legacy' }
        ],
    ]
}, (err, result) => {
    console.log('转换前:', code);
    console.log('-----------------------');
    console.log('转换后:', result.code);
})

将之前的代码部分稍作修改,并引入插件。这里使用的是legacy版本,也就是最早的一版提案。

image.pngimage.png

可以看到转化后会直接执行装饰器函数并将之前的类传进去。那么 Bable 是怎么转换的呢?

代码语言:txt复制
import syntaxDecorators from "@babel/plugin-syntax-decorators";
import legacyVisitor from "./transformer-legacy";

  if (
    process.env.BABEL_8_BREAKING
      ? version === "legacy"
      : legacy || version === "legacy"
  ) {
    return {
      name: "proposal-decorators",
      inherits: syntaxDecorators,
      visitor: legacyVisitor,
    };
  }

以上是@babel/plugin-proposal-decorators的入口文件代码节选,可以看到继承了@babel/plugin-syntax-decorators插件,具体实现部分在legacyVisitor中。因为代码中的@logger这种写法并不在 ES 规范中,所以想要将这部分代码转换成 AST 节点就需要进行一些操作,而@babel/plugin-syntax-decorators插件就是来做这个事情的。

代码语言:txt复制
	manipulateOptions({ generatorOpts }, parserOpts) {
      if (version === "legacy") {
        parserOpts.plugins.push("decorators-legacy");
      }
    },

在生成 AST 的过程中增加了对装饰的写法解析。

image.pngimage.png

在 visitor 中调用 decoratedClassToExpression 函数将装饰类转换成表达式。

image.pngimage.png

decoratedClassToExpression 函数中声明了一个 let 类型的变量并且以类名命名,然后将类的 AST 节点转换成表达式赋值给这个变量。也就是我们运行后看到的结果。

最后

因为篇幅有限,还有很多内容无法呈现。对于Babel的理解以及插件的编写也都是冰山一角,希望能为大家起到抛砖引玉的作用。

参考资料:

Babel插件手册

Babel官方文档

Babel 原理与演进

0 人点赞