AST 数据结构与 babel
AST 简介
抽象语法树(abstract syntax trees),就是将代码转换成的一种抽象的树形结构,通常是 json 描述。AST 并不是哪个编程语言特有的概念,在前端领域,比较常用的 AST 工具如 esprima,babel (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 引擎的编译过程:
看上图,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('函数执行完成'),最朴素的做法是这样:
| before | after | |
|---|---|---|
| // 其他语句略 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('函数执行完成')结构化(如果你已经十分熟悉这个过程,可以跳过):
- 基础方式——使用 @babel/types 来构造语句
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 来构造语句那部分吧?相信所有人都会有一个疑问:那个表达式怎么来的?怎么知道一个表达式使用什么方法来构造?下面就来解决这个问题!
- 借助网站 astexplorer.net/ 输入源码
console.log('函数执行完成'),看到生成的 AST 结构如下:
- 参数的确定,打开 babeljs.io/docs/en/bab…,针对上图右侧的每一个方法进行查阅,来确定参数的类型、个数。例如:
手动构造 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 行)使用的管道操作符处于提案阶段,需要借助插件来解析:
- @babel/parser 模块 + 内联配置(记得安装@babel/plugin-proposal-pipeline-operator)解析
const parse = require('@babel/parser')
...
const ast = parse(code, {
sourceType: 'module',
plugins: [
['pipelineOperator', {
proposal: 'hack',
topicToken: '^^'
}],
]
});
...
- @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": []
}
- 先执行完所有 plugins,再执行 presets。
- 多个 plugins,按照声明次序顺序执行。
- 多个 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,欢迎留言交流。