AST概念
AST(Abstract Syntax Tree) ,抽象语法树,或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式。
为什么要好好的源代码搞成树状的形式?因为统一的结构化的形式,有助于对源代码进行后续分析和处理。
比如下面这句代码:
import xx from 'jq'
经过AST转换后,我们得到这样的形式:
{
"type": "Program",
"start": 0,
"end": 19,
"body": [
{
"type": "ImportDeclaration",
"start": 0,
"end": 19,
"specifiers": [
{
"type": "ImportDefaultSpecifier",
"start": 7,
"end": 9,
"local": {
"type": "Identifier",
"start": 7,
"end": 9,
"name": "xx"
}
}
],
"source": {
"type": "Literal",
"start": 15,
"end": 19,
"value": "jq",
"raw": "'jq'"
}
}
],
"sourceType": "module"
}
简单的看下这个结构不难发现,似乎将我们的每一个词语都拆开了,用了很多描述的属性来描述。比如,body中现在只有一个对象(因为我们只有一句话暂时)这个对象的type:ImportDeclaration顾名思义就是Import的声明语句,然后我们看他的specifiers,标识符也有type: ImportDefaultSpecifier,import的default标识符(显然,如果我们引入不是default引入而是{xx}, 其type会变成ImportSpecifier),然后local字段表示在当前文件的变量名,比如我们这个xx,类型就是Identifier, 名字就是xx。后面继续看source,就是这个Import的声明语句的被引入源的信息。
还有一点值得注意的是,每一处要点信息都有start、end表示在这句字符串中的位置信息。这为处理字符串提供了很大的方便。
词法分析和语法分析
上面就一句代码,生成AST如此庞大,虽然啰嗦,但是全面。那么,AST 如何生成?
生成大致分为两个核心步骤:
- 词法分析 (Lexical Analysis):扫描输入的源代码字符串,生成一系列的词法单元 (tokens)。这些词法单元包括数字,标点符号,运算符等。词法单元之间都是独立的,暂时不考虑如何组合以及组合的含义。
- 语法分析 (Syntax Analysis):建立分析语法单元之间的关系,这个阶段就能得到完整的AST了。
上面的说法比较官方,不直观,借用一个通俗的例子(参考自这里)
比如分析:它是猪
在词法分析阶段,得到孤立的token:
[
{type: '主语', value: '它'},
{type: '谓语', value: '是'}
{type: '宾语', value: '猪'}
]
然后经过语法分析之后,生成有意义的结构化的树:
{
type: '语句',
body:[
{
type: '陈述句',
declaration: [
{
type: '声明',
id: {
type: 'Identifier',
name: '它'
},
init: {
type: 'Literal',
name: '猪',
raw: '猪'
}
}
]
}
]
}
这里只是一个DEMO,真的的AST规范和说明可以参见这篇文章:https://juejin.cn/post/6844903450287800327#heading-3
AST有什么用
AST的作用一般不会体现在业务代码之中,但却它隐式存在在前端程序员开发的各个方面:
- IDE 的错误提示、代码美化、代码高亮、代码自动补全等
- eslint对代码错误或风格的检查等
- webpack、rollup 进行代码打包等
- TypeScript、JSX 等转化为原生 Javascript
- bable转化高版本JS为低版本
- vue 模板编译
相关的工具和库
astexplorer
https://astexplorer.net/
这是一个在线的AST转换工具,将源代码输入到左边,右边就会显示转换之后的AST。而且源语言不限于JS,转换目标格式默认是acorn,也可以选择。
babel-types
这是一篇文档,列出了AST中的类型
https://babeljs.io/docs/en/babel-types
常用类型(参考自:这里 ):
类型名称 | 中文译名 | 描述 |
---|---|---|
Program | 程序主体 | 整段代码的主体 |
VariableDeclaration | 变量声明 | 声明变量,比如 let const var |
FunctionDeclaration | 函数声明 | 声明函数,比如 function |
ExpressionStatement | 表达式语句 | 通常为调用一个函数,比如 console.log(1) |
BlockStatement | 块语句 | 包裹在 {} 内的语句,比如 if (true) { console.log(1) } |
BreakStatement | 中断语句 | 通常指 break |
ContinueStatement | 持续语句 | 通常指 continue |
ReturnStatement | 返回语句 | 通常指 return |
SwitchStatement | Switch 语句 | 通常指 switch |
IfStatement | If 控制流语句 | 通常指 if (true) {} else {} |
Identifier | 标识符 | 标识,比如声明变量语句中 const a = 1 中的 a |
ArrayExpression | 数组表达式 | 通常指一个数组,比如 [1, 2, 3] |
StringLiteral | 字符型字面量 | 通常指字符串类型的字面量,比如 const a = ‘1’ 中的 ‘1’ |
NumericLiteral | 数字型字面量 | 通常指数字类型的字面量,比如 const a = 1 中的 1 |
ImportDeclaration | 引入声明 | 声明引入,比如 import |
babel
babel作为转换代码转换工具,原理也是利用了AST。babel自身也有AST相关的工具:
- @babel/parser:转换工具, https://babeljs.io/docs/en/babel-parser
- @babel/traverse: 遍历工具,https://babeljs.io/docs/en/babel-traverse
- @babel/generator:反向生成代码,AST -> js code, https://babeljs.io/docs/en/babel-generator
下面的代码演示了如何去除源码中的debugger, 思路是利用parser.parse生成AST,利用traverse在遍历的过程中,删除DebuggerStatement类型的tree节点,最终再通过generator.default反向生成JS的代码:
const parser = require('@babel/parser');
const traverse = require("@babel/traverse");
const generator = require("@babel/generator");
// 源代码
const code = `
function fn() {
console.log('debugger')
debugger;
}
`;
// 1. 源代码解析成 ast
const ast = parser.parse(code);
// 2. 转换
const visitor = {
// traverse 会遍历树节点,只要节点的 type 在 visitor 对象中出现,变化调用该方法
DebuggerStatement(path) {
// 删除该抽象语法树节点
path.remove();
}
}
traverse.default(ast, visitor);
// 3. 生成
const result = generator.default(ast, {}, code);
console.log(result.code)
// 4. 日志输出
// function fn() {
// console.log('debugger');
// }
esprima
这是一个解析code为AST的库
https://esprima.org/doc/index.html#
下面代码演示了利用esprima解析AST,利用estraverse遍历AST的,利用escodegen反向生成js code的代码,代码把const变成var:
const esprima = require('esprima');
const estraverse = require('estraverse');
const escodegen = require('escodegen');
let code = 'const a = 1';
const ast = esprima.parseScript(code);
estraverse.traverse(ast, {
enter: function (node) {
node.kind = "var";
}
});
const transformCode = escodegen.generate(ast);
console.log(transformCode);
recast
https://github.com/benjamn/recast
// parse.js
const recast = require('recast')
const code = `function add (a, b) {
return a + b
}`
const ast = recast.parse(code)
// 获取代码块 ast 的第一个 body,即我们的 add 函数
const add = ast.program.body[0]
console.log(add)
// print:
// FunctionDeclaration {
// type: 'FunctionDeclaration',
// id: Identifier...,
// params: [Identifier...],
// body: BlockStatement...
// }
这个库的方法很多,除了能建立AST之外,利用recast.types.builders可以操作修改AST,或者利用recast.visit遍历AST树。
recast的用法参考官网或者这些文章:
https://juejin.cn/post/6844903910960791566#heading-3
https://segmentfault.com/a/1190000016231512
jscodeshift
https://github.com/facebook/jscodeshift
jscodeshift封装了recast,比之API设计的更为友好一点,可以比较方便的对AST中的节点进行增删查改,可以参照这篇文章:https://juejin.cn/post/6942016231214055454#heading-12
acorn
acorn是一个解析AST的库,解析的结果符合 The Estree Spec 规范(这是 Mozilla 的工程师给出的 SpiderMonkey 引擎输出的 JavaScript AST 的规范文档,也可以参考:SpiderMonkey in MDN)。
webpack、rollup解析AST都用的是acorn。
https://github.com/acornjs/acorn
这里有一文介绍acorn可以参考:https://juejin.cn/post/6844903450287800327#heading-1
我们使用acorn,一句acorn.parse就搞定了
const ast = acorn.parse( code, {
locations: true,
ranges: true,
sourceType: 'module',
ecmaVersion: 8
});
不过需要做点什么的时候,需要便利AST,不过acorn不提供遍历的方法(有acorn-walk),不过我们可以尝试自己写一下,假设我们期望这样遍历ast :
ast.body.forEach(statement =>
walk(statement, {
enter(node) {
// enter
},
leave(node) {
// leave
}
});
});
那么实现下walk就是这样(这是rollup_v0.31 /src/ast/walk.js的方法):
function walk(ast, { enter, leave }) {
visit(ast, null, enter, leave)
}
function visit(node, parent, enter, leave) {
if (enter) {
enter.call(null, node, parent)
}
let keys = Object.keys(node).filter(key => typeof node[key] === 'object');
keys.forEach(key => {
let value = node[key];
if (Array.isArray(value)) {
value.forEach(val => {
visit(val, node, enter, leave);
});
} else if (value && value.type) {
visit(value, node, enter, leave);
}
});
if (leave) {
leave(node, parent)
}
}
其他和参考
很遗憾,有些参考文章的demo只能简单记录下要点而不能亲自实践了。
案例:jscodeshift + commander 实现命令行处理css后缀名
参考文章:https://juejin.cn/post/6942016231214055454#heading-17
在对 jscodeshift 有了初步了解之后,我们接下来做一个命令行工具来解决我在上面提出的“引入样式文件后缀名问题”,接下来会简单使用到 commander ,它使 nodejs 命令行接口变得更简单~ import ‘./style.scss’ ,全部转换成以 .css
案例:开发babel-loader
参考文章:https://juejin.cn/post/6844903910960791566#heading-15
AST遍历
这篇文章专门介绍AST的遍历和遍历工具:https://juejin.cn/post/7016711681158053925
介绍了一种通用的遍历AST的思路:visitorKeys,另外,分析了 babel、eslint、tsc、estraverse、postcss 这些工具都是怎么遍历 AST 的。