Babel 101 - 02 - Babel进阶使用指南

Saturday, Dec 05, 2020

ESTree

大家应该都已经知道了Babel处理JS代码的方式,就是 解析 => 转换 => 生成 三个步骤,然后解析里面又包括了词法分析和语法分析,和这个过程息息相关的就是AST(抽象语法树),Babel的核心工具库都是和AST相关的。 在讲Babel的核心库之前有必要介绍一下Babel AST的标准,babel的AST标准是以ESTree作为基准的,只不过部分属性有些偏差,官方文档是这么说的:

The Babel parser generates AST according to Babel AST format. It is based on ESTree spec with the following deviations:

  • Literal token is replaced with StringLiteral, NumericLiteral, BigIntLiteral, BooleanLiteral, NullLiteral, RegExpLiteral
  • Property token is replaced with ObjectProperty and ObjectMethod
  • MethodDefinition is replaced with ClassMethod
  • Program and BlockStatement contain additional directives field with Directive and DirectiveLiteral
  • ClassMethod, ObjectProperty, and ObjectMethod value property's properties in FunctionExpression is coerced/brought into the main method node.
  • ChainExpression is replaced with OptionalMemberExpression and OptionalCallExpression
  • ImportExpression is replaced with a CallExpression whose callee is an Import node.

ESTree主要提供了Identifier、Literal、Statements、Declarations、Expressions 几种类型的节点,每种节点可能会细分成更多的子节点,下面来看一下这些节点的结构:

Node

Node结构可以理解为是所有节点的基类,Node节点中的属性在其他AST节点中都存在。Node节点的结构如下:

interface Node {
    type: string;
    loc: SourceLocation | null;
}
interface SourceLocation {
    source: string | null;
    start: Position;
    end: Position;
}
interface Position {
    line: number; // >= 1
    column: number; // >= 0
}

Identifier

标识,函数名、变量名、方法名都属于Identifier,Identifier基本结构如下(不包含Node的基本节点):

interface Identifier {
    type: "Identifier";
    name: string;
}

Literal

文字类型,变量的值(字符串、数字、bool值、null等)、正则表达式,在Babel中对应的类型就是stringLiteral、numericLiteral等等,Literal基本结构如下:

interface Literal {
    type: "Literal";
    value: string | boolean | null | number | RegExp;
}

Statements

Statement就是我们代码的语句,Statement有非常多的子类型,比如ExpressionStatement、BlockStatement、ReturnStatement、IfStatement、WhileStatement等等,以ExpressionStatement举例,其结构如下:

interface ExpressionStatement <: Statement {
    type: "ExpressionStatement";
    expression: Expression;
}

Declarations

Declaration代表声明、定义,和Statement一样在AST中出现的是他的子类型,主要有:FunctionDeclaration、VariableDeclaration:

interface FunctionDeclaration {
    type: "FunctionDeclaration";
    id: Identifier;
}
interface VariableDeclaration {
    type: "VariableDeclaration";
    declarations: [ VariableDeclarator ];
    kind: "var";
}

Expressions

Expressions就是表达式,主要有FunctionExpression、ObjectExpression、ArrayExpression、ThisExpression等等,以BinaryExpression为例:

interface BinaryExpression {
    type: "BinaryExpression";
    operator: BinaryOperator;
    left: Expression;
    right: Expression;
}

Babel核心工具库

上面介绍了Babel AST的基本结构,接下来就可以进入主题来讲讲Babel提供给我们的核心的工具库。 Babel主要提供了以下几个核心工具库:

  • @babel/parser
  • @babel/core
  • @babel/types
  • @babel/traverse
  • @babel/generator
  • @babel/template

@babel/parser

parser 是 Babel 的JS解析器,可以将JS代码转换成符合 estree 规范的AST结构。 parser 功能强大,本身就支持最新的ES规范、JSX、Flow、TS,也支持在提案中的JS语法。 @babel/parser 主要提供两个API:parse 和 parseExpression,两个API功能类似,区别就在于parse会把代码段当做一整个程序代码,而 parseExpression 则代码段当做一个表达式来解析,如果你想使用 parseExpression 来解析一个Declaration ,那会将得到一个 SyntaxError: Unexpected token 的错误。 下面以一个简单的例子介绍下parser的使用,并对比两个api的差异: 首先写一个我们准备parse的代码段:

const parser = require("@babel/parser")
const code = 'functin foo(a, b) {return a + b}'
console.log(parser.parse(code))
console.log(parser.parseExpression(code))

parse API得到的结果是:

{
  "type": "File",
  // ...
  "program": {
    "type": "Program",
    // ...
    "sourceType": "script",
    "interpreter": null,
    "body": [
      {
        "type": "FunctionDeclaration",
        "id": {
          "type": "Identifier",
           "identifierName": "foo"
         },
         "name": "foo"
        },
        "generator": false,
        "async": false,
        "params": [
         // ...
        ],
        "body": {
         // ...
        }
      }
    ],
    "directives": []
  },
  "comments": []
}

可以看到,使用type为File的节点开始的。 再来看看 parseExpression:

{
  "type": "FunctionExpression",
  "id": {
    "type": "Identifier",
    "name": "foo"
  },
  "generator": false,
  "async": false,
  "params": [
    // ...  
  ],
  "body": {
    "type": "BlockStatement",
    "body": [
      {
        "type": "ReturnStatement",
        // ...
      }
    ],
    "directives": []
  }
}

如果我的代码里有jsx或ts语法parser不能直接解析怎么办呢?如下例:

const reactCode = '<div><p>Hello Babel</p></div>'
const reactCodeRes = parser.parseExpression(reactCode);

直接解析jsx语法将会报下面的错误:

SyntaxError: This experimental syntax requires enabling one of the following parser plugin(s): 'jsx, flow, typescript' (1:0)

告诉我们需要添加plugins,parser是如何添加plugins的呢?和之前babel基础使用中讲到的是一样的plugins吗?这里的答案是否定的,parser的plugins比较特别,上面也提到了,parser功能非常强大支持了各种语法,所以在parse或parseExpression方法中传入第二个参数,第二个参数是一个对象,这个对象有一个plugins属性,plugins属性是一个可枚举的数组, plugins支持很多值,比如bigInt、decorators等等。这里我们要兼容jsx语法,所以plugins中补充了jsx这个plugin,然后再次进行parse可以看到成功解析了

{
  "type": "JSXElement",
  "openingElement": {
    "type": "JSXOpeningElement",
    // ...
  },
  // ...
  "children": [
    {
      "type": "JSXElement",
      // ...
    }
  ]
}

@babel/traverse

得到了AST之后,我们就要对AST中的节点进行特定的处理。在处理对应节点之前我们必须拿到对应的节点,有了AST一层一层的遍历下去获取到想要的节点是可以的,但是这样手写总归过于繁琐而且容易出bug,babel提供了 traverse 这个库来帮助我们更好的遍历整个AST。 @Babel/traverse 会以深度优先遍历的形式遍历整棵AST树,通过一个 visitor 对象,遍历到对应类型的节点,从 visitor 中找到对应的处理方法来进行处理,举个简单的例子: 首先解析得到一棵非常简单的代码的AST树,

const parser = require("@babel/parser")
const traverse = require('@babel/traverse').default

const code = 'if(a === 1){} else {}'
const codeAST = parser.parse(declarationCode, {});

创建一个Visitor,用来处理节点:

const visitor = {
    BlockStatement: (path) => {
        console.log(path.node)    
    },
    BinaryExpression: (path) => {
        console.log(path.node)
    }
}
traverse(declarationCodeAST, declarationVisitor)

打印的结果如下(省去了很多字段):可以看到打印出了 BlockStatement 和 BinaryExpression 两种类型的节点。

BinaryExpression Node {
  type: 'BinaryExpression',
  extra: undefined,
  left:
   Node {
     type: 'Identifier',
     name: 'a' },
  operator: '===',
  right:
   Node {
     type: 'NumericLiteral',
     extra: { rawValue: 1, raw: '1' },
     value: 1 } }

BlockStatement Node {
  type: 'BlockStatement',
  body: [],
  directives: [] }

BlockStatement Node {
  type: 'BlockStatement',
  body: [],
  directives: [] 
}

visitor内的字段还可以这样定义:

const visitor = {
    BinaryExpression: {
        enter(path) {
            console.log('BinaryExpression enter')
        },
        exit(path) {
            console.log('BinaryExpression exit')
        }
    }
}

这样当遍历到 BinaryExpression 节点的时候回调用enter方法,当处理完该节点,准备退出当前节点处理下一个节点的时候调用exit方法。 这个时候长的比较帅的读者可能会问了,visitor 中每个节点的处理方法中都有一个 path 参数,这个 path 参数具体是个什么呢?path 这个参数一听名字就知道它表示一条路径,具体就是表示两个节点间的路径,这个路径信息包含了该节点和父节点的信息,也包含了一些处理节点的方法,比如增、删一个节点,path 属性有:

  • parent:父节点
  • key:当前节点在父节点对应的key
  • node:当前节点的具体内容
  • scope:作用域
  • context:上下文信息
  • ...

在使用traverse的时候一般都是处理node节点,就像上面的例子一样,我们打印了path.node,打印出来的结果就是当前这个AST节点的值。

最佳实践

关于traverse有几条最佳实践可以分享一下:

traverse过程是比较消耗性能的,所以我们要尽可能的把我们的visitor合并起来减少traverse次数

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

合并为一次traverse访问:

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

优化嵌套的visitor,避免重复创建子visitor,例:

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

上面这个例子,每次当我们遍历到FunctionDeclaration节点,都会创建一个新的包含Identifier的visitor,这里就造成了不必要的资源浪费,完全可以把子visitor抽离出去成为一个常量:

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

这样抽离出去之后可能会有一种case:我们的子visitor如果需要依赖父节点的一些信息进行判断处理,我们将子visitor抽离出去之后怎么将这些信息传递给子visitor呢?例子如下:

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

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

traverse提供了第二个参数帮助我们解决这个问题,第二个参数是一个对象,可以将需要的状态值传入,在子visitor中可以在this上访问到这些值:

const visitorOne = {
  Identifier(path) {
    if (path.node.name === this.exampleState) {
      // ...
    }
  }  
};
const MyVisitor = {
  FunctionDeclaration(path) {
    var exampleState = path.node.params[0].name;
    path.traverse(visitorOne, { exampleState });
  }
};

@babel/types

Babel types就厉害了,它可以帮助我们构建AST的节点,或者用来判断一个节点是不是我们要的类型的节点,举个例子: 我们想要生成一个 下面这个import react代码的AST结构

import React from 'react'

使用 @babel/types 可以这么写:

const t = require('@babel/types');
t.importDeclaration([t.importDefaultSpecifier(t.identifier('React'))], t.stringLiteral('react'))

得到的结果如下:

{
    "type":"ImportDeclaration",
    "specifiers":[{
        "type":"ImportDefaultSpecifier",
        "local":{
            "type":"Identifier",
            "name":"React"
        }
    }],
    "source":{
        "type":"StringLiteral",
        "value":"react"
    }
}

可以看到得到的AST节点的结构,少了location相关的属性,其他的属性都是比较完全的了 当我们有一个AST树的时候想判断一个节点是不是import节点时,可以调用:

t.isImportDeclaration(node, opts)

会返回一个 boolean 值告诉我们这个节点是不是一个ImportDeclaration 具体有哪些类型可以查看官方文档

有了 @babel/types 我们可以生成我们想要的代码的AST,通过下面即将介绍的 generator来生成代码。

@babel/generator

generator从名字我们就可以猜出来是用来生成代码的。没错,@babel/generator 可以通过传入的AST生成代码,就拿我们上面 @babel/types 生成的import react的AST来实验:

const t = require('@babel/types')
const generator = require('@babel/generator').default
const importReactAST= t.importDeclaration([t.importDefaultSpecifier(t.identifier('React'))], t.stringLiteral('react'))

console.log(generator(importReactAST))

最终生成的结果如下:

{ 
    code: 'import React from "react";',
    map: null,
    rawMappings: undefined
}

里面的code就是我们想要的真正的代码。 generator函数当然不只有这一个参数,第二个参数是可选的,接收一个对象,可以配置关于输出内容的格式相关和sourcemap相关的属性,如果有将多个来源的代码生成的AST重新构建JS文件并希望sourcemap能够正确的提供必要的信息,则可以传递第三个参数,也是一个对象,key值应该为源文件名称,value对应为源内容。 例:(来自babel官网)

const parse = require('@babel/parser').parse;
const generate = require('@babel/generator').default
const a = "var a = 1;";
const b = "var b = 2;";
const astA = parse(a, { sourceFilename: "a.js" });
const astB = parse(b, { sourceFilename: "b.js" });
const ast = {
  type: "Program",
  body: [].concat(astA.program.body, astB.program.body),
};
const { code, map } = generate(
  ast,
  { sourceMaps: true },
  {
    "a.js": a,
    "b.js": b,
  }
);

总结

本文介绍了Babel提供的核心工具库,主要是@babel/parser、@babel/traverse、@babel/types和@babel/generator,通过这些库可以帮助我们非常方便的获取、操作AST树或者生成我们需要的代码。