AST 初识
什么是 AST
Abstract Syntax Tree,抽象语法树,是源代码结构的一种抽象表示,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
Babel 解析生成的 AST 可视化
如何生成 AST
编译器的工作流程如下:
-
词法分析:对源文件进行扫描,将源文件的字符流拆分分一个个的词(Token)
-
语法分析:接收词法分析的Token流,根据语法规则将这些Toke构造出语法树。
-
语义分析:对语法树的各个节点之间的关系进行检查,检查语义规则是否被违背,同时对语法树进行必要的优化
-
中间代码生成:编译器一般不会直接生成目标代码,会遍历语法树的节点,先生成中间代码
-
中间代码进行优化:尝试生成体积最小、最快、最有效率的代码
-
目标代码生成:将中间代码转化为目标代码:
-
目标代码优化:编译器会利用目标机器的提供的特性对目标代码做进一步的优化,如利用 CPU 的流水线,利用 CPU 的多核等,生成最终的目标代码。
AST的构造过程主要涉及词法分析和语法分析两个步骤
词法分析
扫描源文件的字符流,过滤掉字符流中的空格、注释等,并将其分割为一个个词法单元。
词法单元的识别:
通过有限状态机来判断字符串是否和模式(正则表达式)匹配。
工作过程是:首先自动机处于初始状态,之后它开始读入字符串,每读入一个字符,它都根据当前状态和读入字符转换到下一状态,直到字符串结束,若此时自动机处于其接受状态,则表示该字符串被此自动机接受。
如下状态机,可以识别"ab" "abb" "abbb"... 等字符串,该状态机与正则表达式ab+是等价的
语法分析
语法分析器从词法分析器获得符号流,验证句子可以由源语言的文法(描述程序语言构造的语法)生成,构造出一个语法分析树,
上下文无关文法:
-
终结符:组成串的基本符号,
-
非终结符:表示串的集合
-
开始符号:某个非终结符,由这个开始符号表示的串的集合就是这个文法生成的语言。
-
产生式:描述了将终结符号和非终结符号组合成串的方法。产生式左部为非终结符,右部为0个或多个终结符与非终结符组成。第一个产生式最左边的非终结符为该文法的开始符号
例子:
arduino
E -> E operator E | number
operator -> + | - | * | /
非终结符: { E, operator}
开始符号:E
终结符: { ( +, - , *, / , number },其中 number 表示词法分析得到的一个token,在词法分析中用正则表达式 [0-9]+
``表示
则此语法可以推导出加减乘除的所有运算,比如E ------> E oprator E ------> number + number
语法分析不但要判断给定的句子是否符合语法结构(即可以从开始符号推导出句子),还要分析出这个句子是怎么从起始符号开始产生推导出来的,并根据产生过程生成语法树。
以上面表达式为例:给定一个句子:1 + 2
,此句子可以按以下产生式推导出来:
typescript
E ------> E operator E ------> number + number ------> 1 + 2
推导过程使用分析树表示:
自顶向下分析: 从开始符号开始,通过不断挑选合适的产生式进行推导,将中间句子中的非终结符展开(将E、operator不断的替代),从而展开到给定的句子称为。
自底向上分析:从给定的句子开始,不断的挑选合适的产生式进行归约,将中间句子中的字串归约为非终结符,最终归约到开始符号。
语法解析器构造器
词法分析的关键点是根据模式(一般由正则表达式表示)对源代码进行分割成token流
语法分析的关键是根据文法选择合适的产生式推导出句子,根据推导过程生成语法树。
目前社区有很多语法解析器构造器,无需关注具体的产生式推导(归约)过程,开发者只需要专注于词法分析的token的分割模式(正则表达式)以及语法分析的文法书写,即可构造出该文法对应的语法解析器。
自顶向下 | 自底向上 |
---|---|
syntax-parser | Jison |
antlr4(ANother Tool for Language Recognition,)kison |
Demo:实现一个能够解析加减乘除算数表达式的语法解析器,并且能够根据解析的 AST 计算结果
kotlin
interface ExprAST {
left: Number | ExprAST,
operator: '+' | '-' | '*' | '/'
right: ExprAST | number
}
1+2*3
(1+2)*3
这里以 Jison (bison在js的实现库) 实现为例: RunKi
php
var Parser = require("jison").Parser;
const grammar ={
"lex": {
"rules": [
["\s+", "/* skip whitespace */"],
["[0-9]+(?:\.[0-9]+)?\b", "return 'NUMBER';"],
["\*", "return '*';"],
["\/", "return '/';"],
["-", "return '-';"],
["\+", "return '+';"],
["\(", "return '(';"],
["\)", "return ')';"],
["$", "return 'EOF';"]
]
},
"bnf": {
// 第一个产生式左边的非终结符会作为语法的起始符号
// 遇到EOF结束时,输出结果
// expression ------> e EOF
"expressions" :[[ "e EOF", "console.log($1); return $1;" ]],
/**
e ------> NUMBER |
e + e |
e - e |
e * e |
( e ) |
- e
*/
"e" :[
[ "NUMBER", "$$ = Number($1)" ],
[ "e + e", "$$ = { left: $1, operator: '+', right: $3 }" ],
[ "e - e", "$$ = { left: $1, operator: '-', right: $3 }" ],
[ "e * e", "$$ = { left: $1, operator: '*', right: $3 }" ],
[ "e / e", "$$ = { left: $1, operator: '/', right: $3 }" ],
[ "- e", "$$ = { left: null, operator: '-', right: { left: $2, operator: null, right: null } }" ],
[ "( e )", "$$ = $2" ]
]
},
// jison就可以比较移进与规约的选择间涉及的优先级
"operators": [
["left", "+", "-"],
["left", "*", "/"]
]
}
var parser = new Parser(grammar);
// 输出
parser.parse("1+2*3");
-
lex.rules: 词法分割规则,每个规则第一个元素为正则表达式,第二个元素表示匹配到该规则后的执行动作
-
bnf: 巴科斯范式,产生式的一种规范写法
- key为产生式左部,第一个产生式的key为开始符号
- value为产生式右部,产生式右部也是数组,数组之间的关系为 | ,
AST 前端应用场景
代码分析
通过将源代码解析成 AST 进行分析,可以检查代码中的错误、进行语法自动补全等等。例如:
-
在开发过程中,可以使用 ESLint 对代码进行静态分析,以确保代码符合编码标准和最佳实践。
-
根据注释生成 API 文档
-
在编辑器中,可以对输入的语法进行解析成 AST 进行分析,完成语法的智能提示
代码转换
将源代码解析为源 AST,对源 AST 进行 CRUD 操作,转换为目标 AST,再将目标 AST 生成目标代码
- Babel 通过 AST 将 ES6 代码转换为 ES5 代码:
- UglifyJS 通过 AST 删除无用节点达到压缩 JS 代码目的
- UI 框架的模板引擎渲染:例如,Vue 将自己的模板解析成 AST ,再根据 AST 渲染模板。
- 自定义转换:在开发过程中,可以通过 AST 将特定函数调用替换为其他函数调用 或者 组件转换
- ......
AST 在 Babel 中的应用
Babel是什么
Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments. Here are the main things Babel can do for you:
Babel 是一套解决方案,主要用来把 ECMAScript 2015+的代码转化为浏览器或者其它环境支持的代码。它主要可以做以下事情:
-
语法转换
-
为目标环境提供Polyfill解决方案
-
源码转换
Babel 工作流
Babel 本质是一个编译器,站在 Babel 的角度来看可以分为三步:
1、parse: 将原始代码解析为抽象语法树(ast)
通过 @babel/parser (acorn 和 acorn-jsx) 将输入代码转化为 AST (Babel AST节点类型)
2、transform:遍历源AST做转换,生成新的AST
@babel/traverse 提供遍历 AST 节点的能力,但是不提供转换能力,具体的转换逻辑交给 Babel 的插件来实现,提高了 Babel 的扩展能力
3、generate:遍历新的 ast,生成目标代码
通过 @babel/generate 根据最新的 AST 生成目标代码。
插件
浅尝一下 Babel 插件的转换能力
javascript
let { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 };
console.log(x); // 1
console.log(y); // 2
console.log(z); // { a: 3, b: 4 }
针对解构赋值和扩展运算符两个特性,引入@babel/plugin-transform-destructuring
@babel/plugin-transform-spread
两个插件进行转换
perl
// babel.config.json
{
"plugins": [
"@babel/plugin-transform-destructuring",
"@babel/plugin-transform-spread"
]
}
转换输出的代码如下:
css
function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }
function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
let _x$y$a$b = {
x: 1,
y: 2,
a: 3,
b: 4
},
x = _x$y$a$b.x,
y = _x$y$a$b.y,
z = _objectWithoutProperties(_x$y$a$b, ["x", "y"]);
console.log(x); // 1
console.log(y); // 2
console.log(z); // { a: 3, b: 4 }
Babel插件类型有三种:
- babel-plugin-transform-xx:处理语法转换相关的插件
- babel-plugin-syntax-xx:处理 API Polyfill 相关的插件
- babel-plugin-proposal-xx:用来编译和转换在提案中的属性
Babel 提供了预设(Presets,理解为插件的集合),对常用插件进行封装,常用预设有 @babel/preset-env
插件设计思路原理
访问者模式
首先我们拥有一个由许多对象构成的对象结构,这些对象的类都拥有一个accept方法用来接受访问者对象;访问者是一个接口,它拥有一个visit方法,这个方法对访问到的对象结构中不同类型的元素作出不同的反应;在对象结构的一次访问过程中,我们遍历整个对象结构,对每一个元素都实施accept方法,在每一个元素的accept方法中回调访问者的visit方法,从而使访问者得以处理对象结构的每一个元素。我们可以针对对象结构设计不同的访问者类来完成不同的操作。
Babel插件本质是访问者模式中的一个Visitor对象! 针对不同节点类型,定义不同的visit方法(方法名称为AST节点类型)。
scss
// 插件写法
module.exports = function() {
return {
visitor: {
FunctionDeclaration(path) {},
StringLiteral(path) {},
CallExpression(path) {},
// ...
}
}
}
@babel/traverse提供了 AST 的遍历能力,在处理每个节点时,按照插件顺序,调用插件对应的visit方法来完成对代码的修改操作。
实现一个插件
以Starling国际化为例,将
"字符串"
=>window.i18n("key", "defaultValue")
的形式,key以时间戳替代,defaultValue为当前字符串的值
转换前的代码:
ini
const myname = "hello world";
const age = 15;
console.log("yes");
转化后的代码:
ini
const myname = window.i18n("1681117167429", "hello world");
const age = 15;
console.log(window.i18n("1681117167430", "yes"));
转化前的 AST:
转化后的 AST:
根据 Babel对AST结构的定义 和可视化树形结构 AST Explorer 对比前后的 AST,只需要将 type: StringLiteral
的 AST 节点替换为 type: CallExpression
的AST 节点
less
// myplugin.js
const { declare } = require('@babel/helper-plugin-utils');
module.exports = declare(
// types: @babel/types 封装的AST操作方法
function({ types: t }) {
return {
visitor: {
FunctionDeclaration() { /** */ },
VariableDeclaration() { /** */},
/**
path: AST 传递给观察者的实例,Path是对AST节点增强的数据结构
path.node AST节点
path.parent: 当前节点父节点的Path
path.replaceWith: 替换当前节点
...
*/
StringLiteral(path) {
// 替换当前节点
path.replaceWith(
// 创建CallExpression节点
t.callExpression(
// 第一个参数是 函数名 window.i18n
t.memberExpression(
t.identifier("window"),
t.identifier("i18n")
),
// 第二个参数 是函数对应的参数数组
[
t.stringLiteral(`${Date.now()}`),
t.stringLiteral(path.node.value)
]
)
)
path.skip();
}
}
}
}
)
json
// babel.config.json
{
"plugins": [
"./myplugin.js"
]
}
// 执行 npx babel ./index.js --out-file build.js 即可编译
AST 在业务中实践
DMS 平台致力于为公司多种类型的存储组件提供统一、高效、安全的数据管理终端。
Mongo语法校验和提取:
-
语法完整性校验: Mongo语句类似js语法,通过@babel/parser解析mongo语句判断语句完整与否
-
提取运行语句:默认执行位于光标处的mongo语句,通过@babel/parser解析成AST,利用AST的loc提供的start和end的行号列号信息,获取当前光标处所处的AST节点,从而获取到执行的mongo语句
SQL 智能提示
早期的 DMS SQL 提示基于人工分析,没有完整的方法论支撑,对于复杂的 SELECT 语句无法解析,后经过调研,可以通过解析编辑器的 SQL 为AST 后进行分析,提供字段提示。
- 支持表名提示
- 支持表别名的识别
- 输入表源后,支持表对应的字段名提示(包括表别名)并且支持过滤。
经过技术调研后选择了 syntax-parser:
-
纯 js 实现的解析器构造器
-
提供了部分 SQL 解析的产生式,可以复用
-
内置了对光标的处理:对提示类功能比较友好
AST结构
Select语句的 AST 结构如下
typescript
// 匹配到的Token基本单位
interface IToken {
type: string;
value: string;
position?: [number, number];
}
interface ISelectStatement extends IStatement {
// type: "statement" variant: "select"
result: Field[], // select的字段
from: IFrom,
union: union
}
/** 字段相关结构 **/
interface IField extends IStatement {
// type: "identifier" variant: "column"
alias: IToken | null; // 字段别名
name: IToken | IGroupField | IFunction
}
// 表名.字段名
interface IGroupField extends IStatement {
// type: "identifier" variant: "columnAfterGroup"
name: IToken, // 字段名 token 信息
groupName: IToken // 表名 token 信息
}
interface IFuncion extends IStatement {
// type: "function"
name: IToken, // 函数名的token
args: TODO, // 参数信息,结构比较异常,暂时用不到
}
/** 表源相关结构 **/
interface IFrom {
sources: ISource[] // 表源
where: any,
// 第一个IToken匹配 group 第二个匹配 by IField匹配字段
group: [IToken, IToken, [IField, null]],
having: any, //
}
interface ISource extends IStatement {
// type: "statement" variant: "tableSource"
source: ITableField | ISelectStatement, // 表源来自 表名 或者 select语句
joins: '',
}
interface ITableField extends IStatement {
// type: "identifier" variant: "table"
alias: IToken, // 表别名
name: ITableInfo, // 表信息
}
interface ITableInfo extends IStatement {
// type: "identifier" variant: "tableName"
namespace: IToken, // 未知
tableName: IToken,
tableNames: [] // 未知
}
interface IJoin extends IStatement {
// type: "statement" variant: "join"
join: ITableField,
conditions: any // 连接条件,嵌套的层级比较深,暂时用不到
}
select test| from table_test2 a;
解析生成 AST 如下
技术分层:
select test| from table_test2 a;
解析过程: