概念和基本结构
AST 是一种源代码的抽象语法结构的树形表示。树中的每个节点都表示源代码中出现的一个构造。
AST 的世界”里所有的一切都是 节点(Node),不同类型的节点之间相互嵌套形成一颗完整的树形结构。
{
"program": {
"type": "Program",
"sourceType": "module",
"body": [
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "foo"
},
"params": [
{
"type": "Identifier",
"name": "x"
}
],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "IfStatement",
"test": {
"type": "BinaryExpression",
"left": {
"type": "Identifier",
"name": "x"
},
"operator": ">",
"right": {
"type": "NumericLiteral",
"value": 10
}
}
}
]
}
...
}
...
]
}
AST 的结构不全一样,目前 JavaScript 编译器遵循的通用规范 —— ESTree 中对于 AST 结构的一些基本定义,不同的编译工具都是基于此结构进行了相应的拓展。
生成方式
计算机想要理解一串源代码需要经过一系列的分析过程:
1. 词法分析 (Lexical Analysis)
词法分析阶段扫描输入的源代码字符串,生成一系列的词法单元 (tokens),这些词法单元包括数字,标点符号,运算符等。词法单元之间都是独立,该阶段并不关心代码的组合方式。
2. 语法分析 (Syntax Analysis)
语法分析阶段就会将上一阶段生成的 token 列表转换为如下图右侧所示的 AST,根据这个数据结构可看出转换之前源代码的基本构造;
3. 代码生成 (Code Generation)
代码生成阶段是一个非常自由的环节,可由多个步骤共同组成,此阶段可以遍历初始的 AST
,对其结构进行改造,再将改造后的结构生成对应的代码字符串。
用法与实战
AST
的应用场景:
- 代码高亮、格式化、错误提示、自动补全等:ESlint、Prettier、Vetur等。
- 代码压缩混淆:uglifyJS等。
- 代码转译:webpack、babel、TypeScript等。
那如何使用 AST,其使用步骤:
- 解析 (Parsing):这个过程由编译器实现,会经过词法分析过程和语法分析过程,从而生成
AST
。 - 读取/遍历 (Traverse):深度优先遍历
AST
,访问树上各个节点的信息(Node)。 - 修改/转换 (Transform):在遍历的过程中可对节点信息进行修改,生成新的
AST
。 - 输出 (Printing):对初始
AST
进行转换后,根据不同的场景,既可以直接输出新的AST
,也可以转译成新的代码块。
通常情况下使用 AST
重点关注的是步骤2和3,诸如 Babel、ESLint 等工具暴露出来的通用能力都是对初始 AST
进行访问和修改。
这两步的实现基于一种名为访问者模式的设计模式,即定义一个 visitor 对象,在该对象上定义了对各种类型节点的访问方法,这样就可以针对不同的节点做出不同的处理。例如,编写 Babel 插件其实就是在构造一个 visitor 实例来处理各个节点信息,从而生成想要的结果:
const visitor = {
CallExpression(path) {
...
}
FunctionDeclaration(path) {
...
}
ImportDeclaration(path) {
...
}
...
}
现在我们来看看如何使用 Bable 操作 AST。需要用到以下开发工具:
- AST Explorer:在线 AST 转换工具,集成了多种语言和解析器
- @babel/parser :将 JS 代码解析成对应的 AST
- @babel/traverse:对 AST 节点进行递归遍历
- @babel/types:集成了一些快速生成、修改、删除 AST Node的方法
- @babel/generator :根据修改过后的 AST 生成新的 js 代码
1. 将所有函数中的普通 log 打印转换成 error 打印,并在打印内容前方附加函数名的字符串
/** 转换前 */
function add(a, b) {
console.log(a + b);
return a + b;
}
/** 转换后 */
function add(a, b) {
console.error('add', a + b);
return a + b;
}
实现思路:
- 遍历所有的函数调用表达式(CallExpression)节点
- 将函数调用方法的属性由 log 改为 error
- 找到函数声明(FunctionDeclaration)父节点,提取函数名信息
- 将函数名信息包装成字符串字面量(StringLiteral)节点,插入函数调用表达式的参数节点数组中
const compile = (code) => {
/** 解析成初始AST */
const ast = parser.parse(code);
const visitor = {
/** 1. 遍历所有的函数调用表达式(CallExpression)节点 */
CallExpression(path) {
const { callee } = path.node;
if (types.isCallExpression(path.node) && types.isMemberExpression(callee)) {
const { object, property } = callee;
if (object.name !== 'console' || property.name !== 'log') return;
/** 2. 将成员表达式的属性由 log -> error */
property.name = 'error';
/** 3. 向上遍历,在该函数调用节点的父节点中找到函数声明节点 */
const FunctionDeclarationNode = path.findParent(parent => {
return parent.type === 'FunctionDeclaration';
})
/** 4. 提取函数名称信息,包装成一个字符串字面量节点,插入当前节点的参数数组中 */
const funcNameNode = types.stringLiteral(FunctionDeclarationNode.node.id.name)
path.node.arguments.unshift(funcNameNode);
}
}
}
traverse.default(ast, visitor);
/** code generator */
const newCode = generator.default(ast, {}, code).code;
}
2. 为所有的函数添加错误捕获,并在捕获阶段实现自定义的处理操作
/** 处理前 */
function add(a, b) {
console.log('23333');
throw new Error('233 Error');
return a + b;
}
/** 处理后 */
function add(a, b) {
/** 这里只能捕获到同步代码的执行错误 */
try {
console.log('23333');
throw new Error('233 Error');
return a + b;
} catch (myError) {
/** 自定义处理(eg:函数错误自动上报) */
mySlardar(myError);
}
}
思路:
- 遍历函数声明(FunctionDeclaration)节点
- 提取该节点下整个代码块节点,作为 try 语句(tryStatement)处理块中的内容
- 构造一个自定义的 catch 子句(catchClause)节点,作为 try 异常处理块的内容
- 将整个 try 语句节点作为一个新的函数声明节点的子节点,用新生成的节点替换原有的函数声明节点
const compile = (code) => {
/** 解析成初始AST */
const ast = parser.parse(code);
/** 查看 ast 结果 */
/** utils.writeAst2File(ast) **/ //
const visitor = {
/** 1. 遍历所有的函数调用表达式(CallExpression)节点 */
FunctionDeclaration(path) {
const node = path.node
const { params, id } = node;
/** 2. 提取该节点下整个代码块节点,作为 try 语句(tryStatement)处理块中的内容 */
const blockStatementNode = node.body;
/** 已经有 try-catch 块的停止遍历,防止 circle loop */
if (blockStatementNode.body && types.isTryStatement(blockStatementNode.body[0])) return;
/** 3. 构造 catch 块节点和catch 子句节点 */
const catchBlockStatement = types.blockStatement(
[types.expressionStatement(
types.callExpression(types.identifier('mySlardar'), [types.identifier('myError')])
)]
);
const catchClause = types.catchClause(types.identifier('myError'), catchBlockStatement);
/** try - catch 语句节点 */
const tryStatementNode = types.tryStatement(blockStatementNode, catchClause);
/** 4. try-catch 节点作为新的函数声明节点 */
const tryCatchFunctionDeclare = types.functionDeclaration(id, params, types.blockStatement([tryStatementNode]));
path.replaceWith(tryCatchFunctionDeclare);
}
}
traverse.default(ast, visitor);
/** code generator */
const newCode = generator.default(ast, {}, code).code;
}
3. 在 webpack 中实现 import 的按需导入(简单版 babel-import-plugin)
/** 处理前 */
import { Button as Btn, Dialog } from '233_UI'
import { HHH as hhh } from '233_UI'
/**
* 设置自定义参数:
* (moduleName) => `233_UI/lib/src/${moduleName}/${moduleName} `
*/
/** 处理后 */
import { Button as Btn } from "2333_UI/lib/src/Button/Button"
import { Dialog } from "2333_UI/lib/src/Dialog/Dialog"
import { HHH as hhh } from "2333_UI/lib/src/HHH/HHH"
思路:
- 在插件运行的上下文状态中指定自定义的查找文件路径规则
- 遍历 import 声明节点(ImportDeclaration)
- 提取 import 节点中所有被导入的变量节点(ImportSpecifier)
- 将该节点的值通过查找文件路径规则生成新的导入源路径,有几个导入节点就有几个新的源路径
- 组合被导入的节点和源头路径节点,生成新的 import 声明节点并替换
const visitor = ({types}) => {
return {
visitor: {
ImportDeclaration(path, {opts}) {
/** 1. 通过插件的参数获取模块指定路径 */
const _getModulePath = opts.moduleName;
/** 2. 所有的 import 声明对象节点的来源 */
const importSpecifierNodes = path.node.specifiers;
const importSourceNode = path.node.source;
/** 导入的来源路径 */
const sourceNodePath = importSourceNode.value;
/** import 节点来源路径不等于参数指定路径的,不做替换 */
if (!opts.libaryName || sourceNodePath !== opts.libaryName) return;
/** 3. 根据查找文件路径规则生成新的导入源路径 */
const modulePaths = importSpecifierNodes.map(node => {
return _getModulePath(node.imported.name);
})
/** 4. 组合被导入的节点和源头路径节点,生成新的 import 声明节点并替换 */
const newImportDeclarationNodes = importSpecifierNodes.map((node, index) => {
return types.importDeclaration([node], types.stringLiteral(modulePaths[index]));
})
path.replaceWithMultiple(newImportDeclarationNodes);
}
}
}
}
const result = babel.transform(code, {
plugins: [
[
visitor,
{
libaryName: '2333_UI',
moduleName: moduleName => `2333_UI/lib/src/${moduleName}/${moduleName}`
}
]
]
})
总结
日常的开发或许较少的接触到AST,也不太有机会关注这些编译原理。但对AST的了解可以加快我们上手熟练这些开发工具和api。