说明:文中的AST的JSON结构的字段都是用 esprima 的格式,不同的解析器所生成的结构会有所不同,但是概念是一致的。
文章中出现 AST (Abstract Syntax Tree) 就是表示抽象语法树的简写

什么是AST

抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构 — 摘自维基百科

当写下一段代码的时候,其实写的就是一段字符串,如何让机器能够理解代码的逻辑,比如下面这段JS代码:

  1. const x = 1 + 2

每一种编程语言都有其独特的语法规则,而写下的代码是一个个的字符,为了让机器读懂书写的代码,需要一个个字符的去读取,第一步就是把其中的所有关键词提取出来,并且判断这些关键词属于什么类型,关系结构是怎么样。比如 const 是保留关键词,+和= 号是操作符等等,并且把不同字段间的关系也表达出来,比如 1 + 2 是要赋值给x的等等。上述代码的 抽象语法树如下:

  1. // 采用 esprima 生成
  2. {
  3. "type": "Program",
  4. "body": [
  5. {
  6. "type": "VariableDeclaration",
  7. "declarations": [
  8. {
  9. "type": "VariableDeclarator",
  10. "id": { "type": "Identifier", "name": "x" },
  11. "init": {
  12. "type": "BinaryExpression",
  13. "operator": "+",
  14. "left": { "type": "Literal", "value": 1, "raw": "1" },
  15. "right": { "type": "Literal", "value": 2, "raw": "2" }
  16. }
  17. }
  18. ],
  19. "kind": "const"
  20. }
  21. ],
  22. "sourceType": "script"
  23. }

这是用JSON的格式来表现了,不同的解析器其字段名可能有些不同,但是其本质是一样的。

如何生成AST

生成AST的第一步就是读取字符串,然后识别出一个个的token,比如下面这个JS的代码片段,需要识别出 const,a,=,19,1,2,-,a,/,10 这些标识(token),这些标识是有分类的,比如 const 是保留关键词,a、b是变量,=、*、-、/ 这些的操作符。

  1. const a = 10
  2. const b = sum(1 * 2 - a / 10, 10)

然后根据语言的语法规则来排列、分类这些token,从而形成一种树状结构(AST)。

Shunting-Yard算法

了解这个算法前,先来复习一些概念,下面摘录自wikipedia:

中缀表示法(Infix notation,或中缀记法)是一个通用的算术或逻辑公式表示方法, 操作符是以中缀形式处于操作数的中间。例:1 * 2 - 10 / 10 波兰表示法(Polish notation,或波兰记法),是一种逻辑、算术和代数表示方法,其特点是操作符置于操作数的前面,因此也称做前缀表示法。 逆波兰表示法(Reverse Polish notation,RPN,或逆波兰记法),是一种是由波兰数学家扬·武卡谢维奇1920年引入的数学表达式方式,在逆波兰记法中,所有操作符置于操作数的后面,因此也被称为后缀表示法。例:1 2 * 10 10 / -

Shunting-Yard算法就是把中缀表示法转换为逆波兰表示法。也就是把 1 * 2 - a / 10 转换为 1 2 * 10 10 / - 的形式。这种形式非常易于计算机处理

为什么要使用逆波兰表示法?这种表示方法非常易于计算机处理,具体的解释不是本文的重点,可以看这里:https://zh.wikipedia.org/wiki/逆波兰表示法

这和抽象语法树有什么关系?把几种表示方式放在一起比较一下

  1. 逆波兰表示法: 1 2 * 10 10 / -
  1. 1 * 2 - 10 / 10转换为下面的树形表示法,
  2. -
  3. * /
  4. 1 2 10 10
  1. 再把上述的结构转换为,JSON数据结构表示:
  2. {
  3. "type": "Program",
  4. "body": [
  5. {
  6. "type": "ExpressionStatement",
  7. "expression": {
  8. "type": "BinaryExpression",
  9. "operator": "-",
  10. "left": {
  11. "type": "BinaryExpression",
  12. "operator": "*",
  13. "left": { "type": "Literal", "value": 1, "raw": "1" },
  14. "right": { "type": "Literal", "value": 2, "raw": "2" }
  15. },
  16. "right": {
  17. "type": "BinaryExpression",
  18. "operator": "/",
  19. "left": { "type": "Identifier", "name": "a" },
  20. "right": { "type": "Literal", "value": 10, "raw": "10" }
  21. }
  22. }
  23. }
  24. ],
  25. "sourceType": "script"
  26. }

当把几种方式放在一起比较的时候,发现其实是一致的,AST的数据结构只是给每一个token添加更多的属性,以实现更加复杂的语法,其本质没有变化。

算法实现原理

直接用一个例子来看看算法的实现吧:

  1. // 这里操作符的优先级,默认为从小学的数学的操作符的优先级,没有什么特殊哦
  2. 1 * 2 + (10 - 2) * 5

转换为 RPN 和 AST 的过程
Token 算法行为 输出 操作符堆栈
1 把1输出 1
* 把*放入堆栈 1 *
2 把2输出 1 2 *
+ 把*出栈输出 1 2 *
+ 把+放入堆栈 1 2 * +
( 把(放入堆栈 1 2 * ( +
10 把10输出 1 2 * 10 ( +
- 把-放入堆栈 1 2 * 10 - ( +
2 把2输出 1 2 * 10 2 - ( +
) 把 - 出栈 1 2 * 10 2 - ( +
) 把(出栈 1 2 * 10 2 - +
* 把*放入堆栈 1 2 * 10 2 - * +
5 把5输出 1 2 * 10 2 - 5 * +
结束 把所有的操作符出栈 1 2 * 10 2 - 5 * + * +

转换为AST的过程和上述非常相似,最终的结果 1 2 * 10 2 - 5 * +
如果把这个放入一个堆栈,然后再一个个出栈就可以构建AST树了,来试试看:

Token 类型 算法行为
+ 二元操作符(BinaryExpression) 出栈,它就是树的root,发现是二元操作符,所以会有左右两个子节点
* 二元操作符 出栈,它是root的右节点,本身是二元操作符,会有左右两个节点
5 数字 出栈,它是 * 的右节点
- 二元操作符 出栈,* 的左节点,本身是二元操作符,会有两个子节点
2 数字 出栈,- 的右节点
10 数字 出栈,- 的左节点, - 、*子节点都满了,回到 + 节点
* 二元操作符 出栈,它是+根节点的左节点,本身是二元操作符,创建两个子节点
2 数字 出栈,* 的右节点
1 数字 出栈,* 的左节点

最后生成如下AST:

  • Addition +
    • Multiply *
      • Number 1
      • Number 2
    • Multiply *
      • Minus -
        • Number 10
        • Number 2

这个例子中,只有数字和操作符,如果加入变量,那么过程也是一样的,只是类型不同罢了。

AST的一些应用场景

AST的应用场景是非常广泛的,在写代码的过程中,在不知觉中就用到了,代码可以转换为AST,AST也可以转换为代码。下面是一些应用场景的例子:

  • IDE
    • 代码高亮,纠错等功能都是基于AST的
  • 代码转译
    • 经常使用babel,通过分析AST,把新的语法转换为旧的语法实现。
    • 比如像Typescript作为JS的超集,编译到JS的过程需要用到AST
  • 代码的一些处理,比如JS的minify
  • 通过代码生成文档的实现
  • 代码静态扫描

解析器

下面列举一些 js、ts的解析器的实现库

库名 针对语言 地址
acorn esnext & JSX (using acorn-jsx) https://github.com/acornjs/acorn
esprima esnext & older https://github.com/jquery/esprima
cherow esnext & older https://github.com/cherow/cherow
espree esnext & older https://github.com/eslint/espree
shift esnext & older https://github.com/shapesecurity/shift-parser-js
babel esnext, JSX & typescript https://github.com/babel/babel
TypeScript esnext & typescript https://github.com/Microsoft/TypeScript

其中像babel, typescript是比较常见,babel是转译工具,ts是一门新的语言。

Babel

babel是一个编译器,可以把新的js语法编译为老的语法实现。现在基本上为了提高生产力,babel必然是所用的构建工具中的一环。所以很有必要了解,简单从AST的角度介绍下。

babel主要三步走来完成编译的过程

  1. 读入JS源代码,转换成AST
  2. 遍历AST所有节点,运行插件,执行转换任务,比如把变量 a 重命名为 x,这个过程生成了一个新的转换过的AST
  3. 把新的AST生成新的JS代码

这三个过程分别对应了三个包:@babel/parser, @babel/traverse, @babel/generator

当时通常不需要主动调用这些包的方法来自己手动做,只要通过一定的规范格式,写插件即可。

下面是一个简单的直接使用的例子:

  1. const parser = require("@babel/parser");
  2. const traverse = require("@babel/traverse").default;
  3. const generate = require("@babel/generator").default;
  4. const sampleCode = `
  5. function add(a, b) {
  6. return a + b
  7. }
  8. `;
  9. // 1.解析为AST
  10. const ast = parser.parse(sampleCode);
  11. // 2.遍历,并做转换
  12. traverse(ast, {
  13. FunctionDeclaration: function(path) {
  14. path.node.id.name = 'addTwo'
  15. }
  16. });
  17. // 3.重新生成新的代码
  18. const result = generate(ast)
  19. console.log(result.code)
  20. // 打印的结果是
  21. // function addTwo(a, b) {
  22. // return a + b
  23. // }

这是一个非常基本的例子,但是涵盖了babel整个工作的基本打框架,可以看到AST在其中的关键作用。

总结

从AST的概念,然后AST如何生成,最后写到AST使用的一些场景。

虽然在平时工作中,尤其在业务开发中很少用到,但是当想要对一些编程工具、编译工具、写一些babel或者webpack的插件,去构建一些提效工具的时候,就需要涉及AST的概念,所以去理解,去了解是很有必要的。