AST 数据结构与 babel

3,036 阅读9分钟

AST 数据结构与 babel


AST 简介

    抽象语法树(abstract syntax trees),就是将代码转换成的一种抽象的树形结构,通常是 json 描述。AST 并不是哪个编程语言特有的概念,在前端领域,比较常用的 AST 工具如 esprimababel (babylon) 的解析模块,其他如 vue 自己实现的模板解析器。本文主要以 babel 为例对 AST 的原理进行浅析,通过实践掌握利用 AST 进行代码转换的能力。

  • 推荐工具: AST 在线学习tokens 在线分析
  • 处理 AST 的工具集(注意,前端 AST 并不仅针对 JavaScript,CSS、HTML 一样具有相应的解析工具,JavaScript 重点关注):
ast 解析 esprima @babel/parser recast.parse V8 引擎 ……
ast 遍历 estraverse @babel/traverse recast.visit
生成代码 escodegen @babel/generator recast.print
recast.prettyPrint
生成字节码

代码的编译

    对比 AST 标准工具与 V8 引擎的编译过程:

process.png

    看上图,AST 标准工具与 V8 引擎处理代码的过程有一定的重合,不同的是,V8 将 AST 转换为字节码后进入执行代码的阶段,而 AST 标准工具只是将旧代码转换为新代码。
我们把上述过程分为三部分:解析(parse),转换(transform),生成(generate),其中 scanner 部分叫做词法(syntax)分析,parser 部分叫做语法(grammar)分析。显然,词法分析的结果是 tokens,语法分析得到的就是 AST。

    词法和语法的分析以 esprima 为例(@babel/parser 解析 tokens 的结果稍显复杂,可自行尝试):

const esprima = require('esprima');

const code = 'const number = 10';
const token = esprima.tokenize(code);
console.log(token);

// 打印结果:
[
  { type: 'Keyword', value: 'const' },
  { type: 'Identifier', value: 'number' },
  { type: 'Punctuator', value: '=' },
  { type: 'Numeric', value: '10' }
]

    可见词法分析,旨在将源代码按照一定的分隔符(空格/tab/换行等等)、注释进行分割,并将各个部分进行分类构造出一段 token 流。

const esprima = require('esprima');

const code = 'const number = 10';
const ast = esprima.parse(code);
console.log(JSON.stringify(ast, null, '  '));

// 打印 AST:
{
"type": "Program",
"body": [{
  "type": "VariableDeclaration",
  "declarations": [{
    "type": "VariableDeclarator",
    "id": {
        "type": "Identifier",
        "name": "number",
    },
    "init": {
        "type": "Literal",
        "value": 10,
        "raw": "10",
    },
    }],
    "kind": "const"
    }],
    "sourceType": "script"
}

    而语法分析,则基于 tokens 将源码语义化、结构化为一段 json 描述(AST)。反之,如果给出一段代码的描述信息,我们也是可以还原源码的。理论上,描述信息发生变化,生成的源码的对应信息也会发生变化。所以我们可以通过操作 AST 达到修改源码信息的目的,辅以文件的创建接口,这也是 babel 打包生成代码的基本原理。
了解到这一层,便能想象 ES6 => ES5、ts => js、uglifyJS、样式预处理器、eslint、代码提示等工具的工作方式了。

AST 的节点类型

    在操作 AST 过程中,源码部分集中在 Program 对象的 body 属性下,每个节点有着统一固定的格式,下文将以 babel 作为示例工具演示,其他工具类似,不再赘述。

@babel/core依赖了 parser、traverse、generator 模块,所以安装 @babel/core 即可。

const babel = require('@babel/core');

const code = `
  import React from 'react';
  function add(a, b) {
    return a + b;
  }
  let str = 'hello';
`;
const ast = babel.parse(code, {
  sourceType: 'module'
});
console.log(ast.program.body);
// ast.program.body 部分
[{
  ...
  "type": "ImportDeclaration",
  "specifiers": [{ type"ImportDefaultSpecifier", ... }],
  }, {
    "type""FunctionDeclaration",
    "async"false,
    "body": {
      type"BlockStatement",
      "body": [{
        "type""ReturnStatement",
        ...
      }]
    }
  }, {
    "type""VariableDeclaration",
    "kind""let",
    "declarations": [{ type"VariableDeclarator", init: {}, ... }]
}]

    JavaScript 生成的所有 AST 节点类型可在线查阅。知道了节点类型可以高效地进行节点查找和编辑。以上面的代码为例,如果想在函数返回语句之前加入一行语句console.log('函数执行完成'),最朴素的做法是这样:

beforeafter
// 其他语句略
function add(a, b) {
    return a + b;
}
function add(a, b) {
+ console.log('函数执行完成');
    return a + b;
}
已知 console.log('函数执行完成'); 的 AST 用常量 CONSOLE_AST 表示。
const CONSOLE_AST = babel.template.ast(
"console.log('函数执行完成');"
);

我们参照原先的 AST 结构很容易就能实现这个需求:

const babel = require('@babel/core');

const code = `

 import React from 'react';
  function add(a, b) {
    return a + b;
  }
  let str = 'hello';

`;

const ast = parser.parse(code, {
  sourceType: 'module'
});

function insertConsoleBeforeReturn(body) {
  body.forEach(node => {
    // 这里只处理顶层的函数关键字声明且具有 return 关键字的形式
    if 
(node.type === 'FunctionDeclaration') {
      const blockStatementBody = node.body.body;
      if (blockStatementBody && blockStatementBody.length) {
         // 仅作示意,对省略 return 的箭头函数、没有显式 return 的函数没有处理,请知悉
         const index = blockStatementBody.findIndex(n => n.type === 'ReturnStatement');
         if (
index > -1
) { // 直接修改 ast, 前插一个节点
           blockStatementBody.splice(index, 0, CONSOLE_AST);
         }
      } 
    }
  });
}
insertConsoleBeforeReturn(ast.program.body);

虽然手动操作 AST 满足了当前的需求,但是诸如箭头函数,类或对象的方法、没有 return 语句或省略 return 关键字的函数、表达式声明的函数、IIFE、语句内又嵌套的函数……上述方法都是没有考虑的,所以不推荐手动实现。

    由此可见,处理 AST 的过程就是对不同节点类型遍历和操作的过程,为简化操作,babel 提供了专门的接口,我们只需要提供相应类型的处理方法(visitor 对象)即可。还是上面的需求(现在所有的 return 语句都会处理,即使是嵌套的函数):

const babel = require('@babel/core');

const code = `
  import React from 'react';
  function add(a, b) {
    return a + b;
  }
  let str = 'hello';
`;

const ast = babel.parse(code, {
  sourceType: 'module'
});

babel.traverse(ast, { // 第二个参数就是 visitor 对象
  // 仅作示意,对省略 return 的箭头函数、没有显式 return 的函数没有处理,请知悉
  ReturnStatement(path) {
    path.insertBefore(CONSOLE_AST);
  }
});

console.log(babel.transformFromAstSync(ast).code);

    traverse 帮我们处理了 ast 的遍历过程,对于不同节点的处理只需要维护一份 types 对应的方法即可。进一步的,构造 CONSOLE_AST 节点也有几种方式。先使用在线工具将 console.log('函数执行完成')结构化(如果你已经十分熟悉这个过程,可以跳过):

ast.png

const t = require('@babel/types');
const generate = require('@babel/generator').default;

const CONSOLE_AST = t.expressionStatement(
  t.callExpression(
    t.memberExpression(
      t.identifier('console'),
      t.identifier('log')
    ),
    [t.stringLiteral('函数执行完成')],
  )
);

console.log(CONSOLE_AST, '\n\n', generate(CONSOLE_AST).code);
  • 终极简化版——模板 API,也是上面表格提前给出来的方式:
const template = require('@babel/template').default;
// 或 const template = require('@babel/core').template;

const CONSOLE_AST = template.ast(
  console.log('函数执行完成');
);
console.log(CONSOLE_AST);

还是那个需求:

const babel = require('@babel/core');

const code = `
  import React from 'react';
  const add = function (a, b) {
    function nest () {return;}
    return a + b;
  }
  let str = 'hello';
`;

const ast = parser.parse(code, {
  sourceType: 'module'
});

babel.traverse(ast, {
 
 // 仅作示意,对省略 return 的箭头函数、没有显式 return 的函数没有处理,请知悉

  ReturnStatement(path) {
    path.insertBefore(babel.template.ast(`console.log('函数执行完成');`));
    // path 除了拥有当前节点的信息,还挂载着操作当前节点的各种方法、上下级节点的引用
  },
});

console.log(babel.transformFromAstSync(ast).code);

手动构造 AST 的过程

    还记得基础方式——使用 @babel/types 来构造语句那部分吧?相信所有人都会有一个疑问:那个表达式怎么来的?怎么知道一个表达式使用什么方法来构造?下面就来解决这个问题!

  1. 借助网站 astexplorer.net/ 输入源码console.log('函数执行完成'),看到生成的 AST 结构如下:

ast-create.png

  1. 参数的确定,打开 babeljs.io/docs/en/bab…,针对上图右侧的每一个方法进行查阅,来确定参数的类型、个数。例如:

ast.jpg

手动构造 AST 的方式比较低效繁琐,但却是基于对 AST 结构的充分认识。要想深入掌握 AST,这一过程不可忽视。建议在使用 template 之前,构造 AST 进行熟悉与实践。

AST 与 babel 插件

  • 官方插件     随着 ECMAScript 的发展,不断涌出一些新的语言特性(如管道操作符、可选链操作符、控制合并操作符……),也包括但不限于 JSX 语法等。遇到 babel 本身的解析引擎模块不能识别新特性的问题,可以由插件来处理。
...
const code = `
  const square = x => x ** 2;
  const sum = a => a + 2;
  const list = 
5 |> square(^^) |> sum(^^)
;
`;

const ast = parser.parse(code, {
  sourceType: 'module'
});

console.log(ast);

    运行上面的代码会直接报错,源码(第 5 行)使用的管道操作符处于提案阶段,需要借助插件来解析:

  1. @babel/parser 模块 + 内联配置(记得安装@babel/plugin-proposal-pipeline-operator)解析
const parse = require('@babel/parser')
...
const ast = parse(code, {
  sourceType: 'module',
  plugins: [
    ['pipelineOperator', {
      proposal: 'hack',
      topicToken: '^^'
    }],
  ]
});
...
  1. @babel/core 模块 + 文件 babel.config.json 解析(babel 会自动到项目目录查找最近的babel 配置文件
const babel = require('@babel/core')
...
const ast = babel.parse(code, {
  sourceType: 'module'
});
...

babel.config.json:

{
  "plugins": [
    ["@babel/plugin-proposal-pipeline-operator", {
      "proposal": "hack",
      "topicToken": "^^"
    }]
  ]
}

同理,其他插件通过相同的方式使用。

  • 当项目需要支持的语言特性越来越多, plugins 需要逐一添加,为了解决插件的管理与依赖问题,通过预设(presets)提供常用的环境配置。因此 babel 配置文件 总能看到这样的配置 (react 项目):
{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
  "plugins": []
}
  1. 先执行完所有 plugins,再执行 presets。
  2. 多个 plugins,按照声明次序顺序执行。
  3. 多个 presets,按照声明次序逆序执行。
  • 自定义插件(在线指南
    以下面的源码为例,实现变量标识的重命名,源码及转换逻辑:
const babel = require('@babel/core');

const code = `
  const square = x => x ** 2, ddd = 0;
  const sum = a => a + 2;
  const list = 5 |> square(^^) |> sum(^^);
`;

console.log(babel.transform(code).code)

插件内容与配置变更:

// ./my-plugin.js,对于同一作用域内的变量声明进行标识重命名
module.exports = function (babel) {
  return {
    visitor: {
      VariableDeclaration(path, state) {
        path.node.declarations.forEach(each => {
          path.scope.rename(
            each.id.name,
            path.scope.generateUidIdentifier("uid").name
          );
        });
      }
    },
  }
}

//babel.config.json
{
  "plugins": [
    ["@babel/plugin-proposal-pipeline-operator", {
      "proposal": "hack",
      "topicToken": "^^"
    }],
    ["./my-plugin"]
  ]
}

输出结果:

const _uid = x => x ** 2, _uid2 = 0;
const _uid3 = a => a + 2;
const _uid4 = _uid3(_uid(5));

    试想,如果当前作用域内,生成 uid 的方法换作最简化的不重复标识的算法,是不是就有代码压缩的效果了呢?最后,关于 path 参数操作 ast 的一系列方法,可以在线学习
复杂的插件可以借助一些外部工具、插件参数来实现,安装预设 @babel/preset-env,查看部分插件源码。如 @babel/plugin-transform-classes(ES6 的 class 转换)的实现(关注第二种类型):

var _core = require("@babel/core");

// _helperPluginUtils.declare 是插件声明的工具方法
var _default = (0, _helperPluginUtils.declare)((api, options) => {
  // api 是定义插件函数的第一个参数,能够访问到一些 babel 环境和方法
  return {
    visitor: {
      ExportDefaultDeclaration(path) { // 默认导出的 class
        if (!path.get("declaration").isClassDeclaration()) return;
        (0, _helperSplitExportDeclaration.default)(path);
      },
      ClassDeclaration(path) { // class 关键字声明的类
        const { node } = path;
        const ref = node.id || path.scope.generateUidIdentifier("class");
        path.replaceWith(_core.types.variableDeclaration("let", [
          _core.types.variableDeclarator(ref, _core.types.toExpression(node))
        ]));
      },
      ClassExpression(path, state) {} // 类表达式
  };
});

exports.default = _default

    可见,以 ClassDeclaration 类型声明的 class,将被替换为一条 let 语句,这里依赖了 @babel/core 模块构造 AST 节点。当然,_core.types 方法也可以从插件的首个参数(这里指 api,自定义插件不使用 _helperPluginUtils.declare 方法声明的话,第一个参数就是 babel 对象,可以直接解构出 types)解构出来。

本文原作者Oreo_cookie,欢迎留言交流。