从 AST 到 自定义 Babel 插件

147 阅读8分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

从 AST 到 自定义 Babel 插件

本文会将介绍AST抽象语法树的概念和基本原理、AST抽象语法树的遍历和生成、如何使用babel插件进行代码转换以及如何自定义 babel 插件。

实现我们先介绍一下抽象语法树(Abstract Syntax Tree,AST)

1. 抽象语法树(Abstract Syntax Tree,AST)

什么是抽象语法树(Abstract Syntax Tree,AST)? 抽象语法树(Abstract Syntax Tree,AST)是源代码语法结构的⼀种抽象表示, 它以树状的形式表现编程语⾔的语法结构,树上的每个节点都表示源代码中的⼀种结构。类似于虚拟 dom,只是虚拟 dom 是描述 dom 结构。

image.png

Eslint 以及 babel 都是基于抽象语法树来实现的,包括 JSX 语法和 Vue 中的 template 语法都离不开它。

2. 抽象语法树⽤途

  1. 代码语法的检查、代码⻛格的检查、代码的格式化、代码的⾼亮、代码错误提示、代码⾃动补全等等
  2. 优化变更代码,改变代码结构使达到想要的结构。例如将 ES6 等高级语法转换成低级语法、JSX 通过 babel 最终转化成 React.createElement 这种形式

3. JavaScript Parser

JavaScript Parser 是把 JavaScript 源码转化为抽象语法树的解析器 ,常见的 Parser 有 esprima、traceur、acorn、shift 等。

那么 AST 是如何生成的呢?

JS 执行的第一步是读取 JS 文件中的字符流,然后通过词法分析生成 token,之后再通过语法分析生成 AST,最后生成机器码执行。

  1. 词法分析

词法分析,也称之为扫描(scanner),简单来说就是调用 next() 方法,一个一个字母的来读取字符,然后与定义好的 JavaScript 关键字符做比较,生成对应的 Token。Token 是一个不可分割的最小单元,例如 var 这三个字符,它只能作为一个整体,语义上不能再被分解,因此它是一个 Token。词法分析器里,每个关键字是一个 Token ,每个标识符是一个 Token,每个操作符是一个 Token,每个标点符号也都是一个 Token。除此之外,还会过滤掉源程序中的注释和空白字符(换行符、空格、制表符等)。最终,整个代码将被分割进一个tokens 列表(或者说一维数组)。

  1. 语法分析

语法分析会将词法分析出来的 Token 转化成有语法含义的抽象语法树结构。同时,验证语法,语法如果有错的话,抛出语法错误。

这里提提供一个在线工具: AST explorer,利用它可以看到不同的 parser 解析 JS 代码后得到的 AST。

下面这个栗子是使用一个 esprima 这个 Parser 对 function a(){}生成的语法树。

image.png

可以看到这个 AST 对这句代码的描述非常详细。接下来我们不借助这个在线工具来实现它。

4. 代码转换

代码转换的大致流程为:

  1. 将代码转换成 AST 语法树
  2. 深度优先遍历,遍历 AST 抽象语法树
  3. 代码⽣成

实现,我们需要按照三个第三方库:esprimaestraverseescodegen

yarn add esprima estraverse escodegen

然后我们对它们进行导入。

const esprima = require('esprima');
const estraverse = require('estraverse');
const escodegen = require('escodegen');

通过 esprima.parseScript 方法将我们的代码转换成 AST 语法树。

let code = `function a(){}`;
const ast = esprima.parseScript(code);
console.log(ast);

可以看到输出结果与上面生成的 AST 类似。

Script {
  type: 'Program',
  body: [
    FunctionDeclaration {
      type: 'FunctionDeclaration',
      id: [Identifier],
      params: [],
      body: [BlockStatement],
      generator: false,
      expression: false,
      async: false
    }
  ],
  sourceType: 'script'
}

有了这么一个树结构之后,我们当然希望能够对它进去遍历,这个时候我们需要用到 estraverse.traverse

estraverse.traverse(ast, {
  enter(node) { // Program ->  FunctionDeclaration -> Identifier
    console.log('enter:' + node.type)
    if (node.type === 'FunctionDeclaration') {
      node.id.name = 'ast'
    }
  },
  leave(node) {
    console.log('leave:' + node.type)
  }
});

访问模式就是遍历节点的时候会有两个过程,一个是进入一个是离开,分别对应 enterleave 两个钩子函数,并且我们在其中修改函数的标识符。

enter:Program
enter:FunctionDeclaration
enter:Identifier
leave:Identifier
enter:BlockStatement
leave:BlockStatement
leave:FunctionDeclaration
leave:Program

最后我们再通过 escodegen.generate 方法来生成代码。

console.log(escodegen.generate(ast));

输出结果如下。

function ast() {
}

可以看到函数名已经被修改了,至此我们便实现了一个简单的代码转换。

接下来我们来写个 babel 插件,来感受一下 babel 是如何进行转换的。

5. babel 插件

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

Babel 的三个主要处理步骤分别是:解析(parse)转换(transform)生成(generate)。 解析步骤前面已经讲过了,而转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。 这是 Babel 或是其他编译器中最复杂的过程同时也是插件将要介入工作的部分。代码生成步骤把最终(经过一系列转换之后)的 AST 转换成字符串形式的代码,同时还会创建源码映射(source maps)。代码生成其实很简单:深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串。

比较典型的就是将 ES6 语法转换为 ES5 语法,下面我们来实现两个简单的栗子:转换箭头函数、类编译为 Function

5.1 转换箭头函数

首先我们先介绍一下需要用到的第三方库。

  • @babel/core Babel 的编译器,核⼼ API 都在这⾥⾯,⽐如常⻅的 transformparse,并实现了插件功能 ,也就是说可以在其中放入对应的 babel 插件,在转换的时候会默认调用它们。
  • @babel/types ⽤于 AST 节点的 Lodash 式⼯具库, 它包含了构造、验证以及变换 AST 节点的⽅法,对编写处理 AST 逻辑⾮常有⽤
  • babel-plugin-transform-es2015-arrow-functions 转换箭头函数插件

接下来我们来看看如何借助 babel-plugin-transform-es2015-arrow-functions 来实现转换箭头函数。

const babel = require("@babel/core");
const types = require("@babel/types");
const transformFunction = require("babel-plugin-transform-es2015-arrow-functions");

const code = `const sum = (a,b)=> a+b`;
// 转化代码,通过 transformFunction 插件
const result = babel.transform(code, {
  plugins: [transformFunction],
});
console.log(result.code);

最终结果如下。

const sum = function (a, b) {
  return a + b;
};

可以看到我们已经成功将箭头函数转成 function 函数。其实这个 transformFunction 插件的原理跟上面的代码转换类似,下面我们先看看这两个结果的 AST。

image.png

通过仔细观察,相信你已经看出之间的不同。庆幸的是 @babel/core 为我们提供了很好的插件拓展方式,下面我们来看看如何实现。

const transformFunction = {
  visitor: {
    ArrowFunctionExpression(path) { // path就是访问的路径   path -> node
      let {
        node
      } = path;
      node.type = 'FunctionExpression';

      let body = node.body; // 老节点中的 a+b;

      if (!types.isBlockStatement(body)) {
        node.body = types.blockStatement([types.returnStatement(body)])
      }
    }
  }
}

当我们谈及“进入”一个节点,实际上是说我们在访问它们, 之所以使用这样的术语是因为有一个**访问者模式(visitor)**的概念。访问者是一个用于 AST 遍历的跨语言的模式。 简单的说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。我们在对象里定义了 ArrowFunctionExpression 方法,遇到箭头函数表达式会命中此⽅法,如何利用 @babel/types 构造、变换 AST 节点。

然而箭头函数还有一个问题,就是 this 的指向,我们需要单独对它进行处理。例如我们要将以下代码进行转换。

const sum = ()=> console.log(this)

转换后的结果为:

var _this = this;

const sum = function () {
  return console.log(_this);
};

可以看到,我们需要找到上层作用域里的 this ,将它赋值给 _this,从而将函数里的 this 改成了 _thiis 。下面我们先看看如何实现。

const transformFunction = {
  visitor: {
    ArrowFunctionExpression(path) { // path就是访问的路径   path -> node
      let { node } = path;
      node.type = 'FunctionExpression';

      hoistFunctionEvn(path);
      let body = node.body; // 老节点中的 a+b;

      if (!types.isBlockStatement(body)) {
        node.body = types.blockStatement([types.returnStatement(body)])
      }
    }
  }
}

function getThisPath(path) {
  let arr = []
  path.traverse({
    ThisExpression(path) {
      arr.push(path);
    }
  })
  return arr;
}

function hoistFunctionEvn(path) {
  // 查找父作用域
  const thisEnv = path.findParent((parent) => (parent.isFunction() && !parent.isArrowFunctionExpression()) || parent.isProgram())

  const bingingThis = '_this'; // var _this = this;

  const thisPaths = getThisPath(path);

  // 修改当前路径中的this  变为_this
  thisPaths.forEach(path => {
    // this -> _this
    path.replaceWith(types.identifier(bingingThis))
  })
  thisEnv.scope.push({
    id: types.identifier(bingingThis),
    init: types.thisExpression()
  })

}

我们通过 path.findParent 方法从一个路径向上遍历语法树,直到满足相应的条件。对于每一个父路径调用 callback 并将其 NodePath 当作参数,当 callback 返回真值时,则将其 NodePath 返回。我们的判断条件是:该节点类型是 function 函数 or 根节点。接着我们便需要修改当前路径中的 this 变为 _this。当然这里用到了很多 API 这里便不再一一解释,读者可自行查阅:Babel 插件手册

5.2 类编译为 Function

同样的,我们要把以下 ES6 语法的类更改为 Function 的方式。

class Person {
  constructor(name) {
    this.name = name;
  }
  getName() {
    return this.name;
  }
  setName(newName) {
    this.name = newName;
  }
}

// 转换后

function Person(name) {
  this.name = name;
}
Person.prototype.getName = function () {
  return this.name;
};
Person.prototype.setName = function () {
  this.name = newName;
};

下面我们先看看这两个结果的 AST,以及给出实现方式,这里就不再过多解释其中的 API,读者可自行查阅:Babel 插件手册

image.png

const transformFunction = {
  visitor: {
    ClassDeclaration(path) {
      const {
        node
      } = path;
      const id = node.id;
      const methods = node.body.body; // 获取类中的⽅法
      const nodes = [];
      methods.forEach((method) => {
        if (method.kind === "constructor") {
          let constructorFunction = types.functionDeclaration(
            id,
            method.params,
            method.body
          );
          nodes.push(constructorFunction);
        } else {
          // Person.prototype.getName
          const memberExpression = types.memberExpression(
            types.memberExpression(id, types.identifier("prototype")),
            method.key
          );
          // function(name){return name}
          const functionExpression = types.functionExpression(
            null,
            method.params,
            method.body
          );
          // 赋值
          const assignmentExpression = types.assignmentExpression(
            "=",
            memberExpression,
            functionExpression
          );
          nodes.push(assignmentExpression);
        }
      });
      // 替换节点
      if (node.length === 1) {
        path.replaceWith(nodes[0]);
      } else {
        path.replaceWithMultiple(nodes);
      }
    },
  },
};