前置知识

AST

开发者所书写的源码文件里的代码,最终会被表现为一颗树结构

function square(n) {  
  return n * n;  
}

上面的代码会被转为如下的树结构:

- FunctionDeclaration:
  - id:
    - Identifier:
      - name: square
  - params [1]
    - Identifier
      - name: n
  - body:
    - BlockStatement
      - body [1]
        - ReturnStatement
          - argument
            - BinaryExpression
              - operator: *
              - left
                - Identifier
                  - name: n
              - right
                - Identifier
                  - name: n

可以在 https://astexplorer.net/ 看到一段源码转换为的 AST。

在上面的树结构中,会发现每一层有一些相同的结构:

{
  type: "FunctionDeclaration",
  id: {...},
  params: [...],
  body: {...}
}
 
{
  type: "Identifier",
  name: ...
}
 
{
  type: "BinaryExpression",
  operator: ...,
  left: {...},
  right: {...}
}

每一个拥有 type 属性的对象,可以将其称之为一个节点,一颗 AST 树实际上就是由成千上万个节点构成的。不同的节点有不同的类型。

除了 type 以外,还会有一些额外的属性,这些属性提供了该节点额外的信息。

Babel 处理代码流程

Babel 对代码进行处理时,核心的流程就分为三步:

  1. 解析(parse)
  2. 转换(transform)
  3. 生成(generate)

解析(parse)

将接收到的源代码转为抽象语法树,这个步骤又分为两个小阶段:

  1. 词法分析
  2. 语法分析

所谓词法分析,就是将源码转为 token,如:

n * n;

会形成如下的 token:

[
  { type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
  { type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
  { type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
]

每一个 token 里面专门有一个 type 属性来描述这个 token:

{
  type: {
    label: 'name',
    keyword: undefined,
    beforeExpr: false,
    startsExpr: true,
    rightAssociative: false,
    isLoop: false,
    isAssign: false,
    prefix: false,
    postfix: false,
    binop: null,
    updateContext: null
  },
  ...
}

形成一个一个 token 后,就会进入到语法分析阶段,该阶段就是将所得到的 token 转为 AST 树结构,便于后续的操作。

转换(transform)

对得到的 AST 树进行遍历操作,在遍历的过程中,就可以对树里的节点进行一些添加、删除、更新等操作,这就是 Babel 转换代码的核心。

例如插件,就是在转换阶段介入并进行工作的。

生成(generate)

经历过转换之后,现在的树结构已经和之前不一样,接下来就是将这颗 AST 重新转为代码(字符串)

遍历

在对 AST 进行遍历的时候,采用的是深度优先遍历。

访问者

所谓访问者就是一个对象,该对象上会有一些特殊的方法,这些特殊的方法会在到达特定的节点时触发。

如下面的访问者对象会在遍历这颗树的时候,当遇见 Identifier 节点时被调用:

const MyVisitor = {
  Identifier() {
    console.log("Called!");
  }
};

可以针对特定的节点定义进入时要调用的方法和退出时要调用的方法:

const MyVisitor = {
  Identifier: {
    enter() {
      console.log("Entered!");
    },
    exit() {
      console.log("Exited!");
    }
  }
};

一般来讲,不同的节点类型就有节点 type 所对应的方法,例如:

  • Identifier(path, state):这个方法在遍历到标识符节点时会被调用。
  • FunctionDeclaration(path, state):这个方法在遍历到函数声明节点时会被调用。

至于节点究竟有哪些类型,可以参阅 estree

路径

AST 是由一个一个节点组成,这些节点之间并非孤立的,而是彼此之间有一些联系。因此有一个 path 对象,该对象主要就是记录节点和节点之间的关系。path 对象里不仅仅包含了节点本身的信息,还包含了节点和父节点、子节点、兄弟节点之间的关系。

这样做的好处是使用了一个相对简单的对象来表示节点之间的复杂关系,不需要在每个节点里面来保存节点之间关系的信息。

在实际编写插件时,就经常会利用 path 对象来获取节点的相关信息。

状态

在遍历和修改抽象语法树时,应该尽量避免修改全局状态。

例如,有一个需求,重命名一个函数的参数:

let paramName; // 存储函数参数名
 
const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0]; // 从 path 对象拿到当前节点的参数
    paramName = param.name; // 将参数的名称存储到 paramName 里(全局变量)
    param.name = "x";
  },
 
  Identifier(path) {
    // 之后,进入到每一个 Identifier 类型的节点的时候
    // 判断当前节点的名称是否等于 paramName(之前的函数参数名称)
    if (path.node.name === paramName) {
      // 进行修改
      path.node.name = "x";
    }
  }
};

上面的代码看上去没有问题,但是实际可能在某些情况下不能正常工作。

为了解决这样的问题,就需要避免全局状态,可以在一个访问者对象里再定义一个访问者对象专门拿来存储状态:

const updateParamNameVisitor = {
  Identifier(path) {
    if (path.node.name === this.paramName) {
      path.node.name = "x";
    }
  }
};
 
const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    const paramName = param.name;
    param.name = "x";
 
    path.traverse(updateParamNameVisitor, { paramName });
  }
};
 
path.traverse(MyVisitor);

自定义插件

关于 Babel 中如何创建自定义插件,官方是有一个 Handbook

自定义 Babel 插件,实际上有一个固定的格式:

module.exports = function(babel) {
  // 该函数会自动传入 babel 对象
  // types 也是一个对象,该对象上面有很多的方法,方便我们对 AST 的节点进行操作
  const { types } = babel;
  
  return {
    name: "插件的名字",
    visitor: {
      // 这里书写不同类别的方法,不同的方法会被进入不同类别的节点触发
    }
  }
}

示例一

创建一个自定义插件,该插件能够把 ES6 里面的 ** 转换为 Math.pow

在编写该自定义插件时,会使用到 types 对象的一些方法:

  • callExpression(callee, arguments):用于创建一个表示函数调用的 AST 节点。
    • callee 参数是一个表示被调用的函数的表达式节点
    • arguments 参数是一个数组,包含了所有的参数表达式节点
  • memberExpression(object, property, computed = false):用于创建一个表示属性访问的 AST 节点。
    • object 参数是一个表示对象的表达式节点
    • property 参数是一个表示属性名的标识符或表达式节点
    • computed 参数是一个布尔值,表示属性名是否是动态计算的。
  • identifier():创建 identifier 类型的 AST 节点。

该插件的核心:就是创建一些新的 AST 节点,去替换旧的 AST 节点。

插件的代码如下:

// 该插件负责将 ** 转换为 Math.pow
module.exports = function (babel) {
  const { types: t } = babel;
 
  return {
    name: "transform-to-mathpow",
    visitor: {
      // 遍历到二元表达式会自动执行该方法
      BinaryExpression(path) {
        if (path.node.operator !== "**") {
          return;
        }
 
        const mathpowAstNode = t.callExpression(
          t.memberExpression(t.identifier("Math"), t.identifier("pow")),
          [path.node.left, path.node.right]
        );
 
        // 用新的 AST 节点替换旧的 AST 节点
        path.replaceWith(mathpowAstNode);
      },
    },
  };
};

示例二

编写一个自定义插件,该插件能够将箭头函数转为普通函数。

// 该插件负责将箭头函数转为普通函数
module.exports = function (babel) {
  const { types: t } = babel;
 
  return {
    name: "transform-arrow-to-function",
    visitor: {
      // 遍历到箭头函数表达式会自动执行该方法
      ArrowFunctionExpression(path) {
        let body; // 存储函数体
 
        if (path.node.body.type !== "BlockStatement") {
          // 箭头函数体是一个表达式,需要将 body 部分转为返回语句
          // a => b
          // function(a) { return b }
          body = t.blockStatement([t.returnStatement(path.node.body)]);
        } else {
          // 可以直接使用箭头函数的方法体
          body = path.node.body;
        }
        // 创建一个普通函数表达式的 AST 节点
        const functionExpression = t.functionExpression(
          null, // 函数名
          path.node.params, // 函数参数,和箭头函数的参数是一致的
          body, // 函数方法体
          false, // 不是一个生成器函数
          path.node.async // 是否是异步函数,和箭头函数是一致的
        );
 
		// 用新的 AST 节点替换旧的 AST 节点
        path.replaceWith(functionExpression);
      },
    },
  };
};