从Babel开始认识AST抽象语法树

2023/6/25 babel

# 前言

AST抽象语法树想必大家都有听过这个概念,但是不是只停留在听过这个层面呢。其实它对于编程来讲是一个非常重要的概念,当然也包括前端,在很多地方都能看见AST抽象语法树的影子,其中不乏有vue、react、babel、webpack、typeScript、eslint等。简单来说但凡需要编译的地方你基本都能发现AST的存在。

babel是用来将javascript高级语法编译成浏览器能够执行的语法,我们可以从babel出发来了解AST抽象语法树。

如果这篇文章有帮助到你,❤️关注+点赞❤️鼓励一下作者,文章公众号首发,关注 前端南玖 第一时间获取最新文章~

# babel编译流程

了解AST抽象语法树之前我们先来简单了解一下babel的编译流程,以及AST在babel编译过程中起到了什么作用?

1-babel-ast.png

我这里画了张图方便理解babel编译的整个流程

  • parse: 用于将源代码编译成AST抽象语法树
  • transform: 用于对AST抽象语法树进行改造
  • generator: 用于将改造后的AST抽象语法树转换成目标代码

很明显AST抽象语法树在这里充当了一个中间人的身份,作用就是可以通过对AST的操作还达到源代码到目标代码的转换过程,这将会比暴力使用正则匹配要优雅的多。

# AST抽象语法树

在计算机科学中,抽象语法树(Abstract Syntax Tree,AST) 是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

虽然在日常业务中我们可能很少会涉及到AST层面,但如果你想在babelwebpack等前端工程化上有所深度,AST将是你深入的基础。

# 预览AST

说了这么多,那么AST到底长什么样呢?

接下来我们可以通过工具AST Explorer (opens new window)来直观的感受一下!

比如我们如下代码:

let fn = () => {
  console.log('前端南玖')
}

它最终生成的AST是这样的:

2-babel-ast.png

  • AST抽象语法树是源代码语法结构的一种抽象表示
  • 每个包含type属性的数据结构,都是一个AST节点
  • 它以树状的形式表现编程语言的语法结构,每个节点都表示源代码中的一种结构

# AST结构

为了统一ECMAScript标准的语法表达。社区中衍生出了ESTree Spec (opens new window),是目前前端所遵循的一种语法表达标准。

# 节点类型

类型 说明
File 文件 (顶层节点包含 Program)
Program 整个程序节点 (包含 body 属性代表程序体)
Directive 指令 (例如 "use strict")
Comment 代码注释
Statement 语句 (可独立执行的语句)
Literal 字面量 (基本数据类型、复杂数据类型等值类型)
Identifier 标识符 (变量名、属性名、函数名、参数名等)
Declaration 声明 (变量声明、函数声明、Import、Export 声明等)
Specifier 关键字 (ImportSpecifier、ImportDefaultSpecifier、ImportNamespaceSpecifier、ExportSpecifier)
Expression 表达式

# 公共属性

类型 说明
type AST 节点的类型
start 记录该节点代码字符串起始下标
end 记录该节点代码字符串结束下标
loc 内含 line、column 属性,分别记录开始结束的行列号
leadingComments 开始的注释
innerComments 中间的注释
trailingComments 结尾的注释
extra 额外信息

# AST是如何生成的

一般来讲生成AST抽象语法树都需要javaScript解析器来完成

JavaScript解析器通常可以包含四个组成部分:

  • 词法分析器(Lexical Analyser)
  • 语法解析器(Syntax Parser)
  • 字节码生成器(Bytecode generator)
  • 字节码解释器(Bytecode interpreter)

# 词法分析

这里主要是对代码字符串进行扫描,然后与定义好的 JavaScript 关键字符做比较,生成对应的Token。Token 是一个不可分割的最小单元。

词法分析器里,每个关键字是一个 Token ,每个标识符是一个 Token,每个操作符是一个 Token,每个标点符号也都是一个 Token,词法分析过程中不会关心单词与单词之间的关系.

除此之外,还会过滤掉源程序中的注释和空白字符、换行符、空格、制表符等。最终,整个代码将被分割进一个tokens列表

javaScript中常见的token主要有:

关键字:var、let、const等
标识符:没有被引号括起来的连续字符,可能是一个变量,也可能是 if、else 这些关键字,又或者是 true、false 这些内置常量
运算符: +、-、 *、/ 等
数字:像十六进制,十进制,八进制以及科学表达式等
字符串:变量的值等
空格:连续的空格,换行,缩进等
注释:行注释或块注释都是一个不可拆分的最小语法单元
标点:大括号、小括号、分号、冒号等

比如我们还是这段代码:

let fn = () => {
  console.log('前端南玖')
}

它在经过词法分析后生成的token是这样的:

工具:esprima (opens new window)

[
    {
        "type": "Keyword",
        "value": "let"
    },
    {
        "type": "Identifier",
        "value": "fn"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Punctuator",
        "value": "("
    },
    {
        "type": "Punctuator",
        "value": ")"
    },
    {
        "type": "Punctuator",
        "value": "=>"
    },
    {
        "type": "Punctuator",
        "value": "{"
    },
    {
        "type": "Identifier",
        "value": "console"
    },
    {
        "type": "Punctuator",
        "value": "."
    },
    {
        "type": "Identifier",
        "value": "log"
    },
    {
        "type": "Punctuator",
        "value": "("
    },
    {
        "type": "String",
        "value": "'前端南玖'"
    },
    {
        "type": "Punctuator",
        "value": ")"
    },
    {
        "type": "Punctuator",
        "value": "}"
    }
]

拆分出来的每个字符都是一个token

# 语法分析

这个过程也称为解析,是将词法分析产生的token按照某种给定的形式文法转换成AST的过程。也就是把单词组合成句子的过程。在转换过程中会验证语法,语法如果有错的话,会抛出语法错误。

还是上面那段代码,在经过语法分析后生成的AST是这样的:

工具:AST Explorer (opens new window)

{
    "type": "VariableDeclaration",  // 节点类型: 变量声明
    "declarations": [   // 声明
      {
        "type": "VariableDeclarator",  
        "id": {
          "type": "Identifier",  // 标识符
          "name": "fn"  // 变量名
        },
        "init": {
          "type": "ArrowFunctionExpression",    // 箭头函数表达式
          "id": null,
          "generator": false,
          "async": false,
          "params": [],  // 函数参数
          "body": {  // 函数体
            "type": "BlockStatement",  // 语句块
            "body": [   
              {
                "type": "ExpressionStatement",  // 表达式语句
                "expression": {
                  "type": "CallExpression", 
                  "callee": {
                    "type": "MemberExpression",
                    "object": {
                      "type": "Identifier",
                        "identifierName": "console"
                      },
                      "name": "console"
                    },
                    "computed": false,
                    "property": {
                      "type": "Identifier",
                      "name": "log"
                    }
                  },
                  "arguments": [  // 函数参数
                    {
                      "type": "StringLiteral",  // 字符串
                      "extra": {
                        "rawValue": "前端南玖",
                        "raw": "'前端南玖'"
                      },
                      "value": "前端南玖"
                    }
                  ]
                }
            ],
            "directives": []
          }
        }
      }
    ],
    "kind": "let"    // 变量声明类型
  }

在得到AST抽象语法树之后,我们就可以通过改造AST语法树来转换成自己想要生成的目标代码。

# 常见的解析器

第一个用JavaScript编写的符合EsTree规范的JavaScript的解析器,后续多个编译器都是受它的影响

一个小巧、快速的 JavaScript 解析器,完全用 JavaScript 编写

babel官方的解析器,最初fork于acorn,后来完全走向了自己的道路,从babylon改名之后,其构建的插件体系非常强大

UglifyJS 是一个 JavaScript 解析器、缩小器、压缩器和美化器工具包。

esbuild是用go编写的下一代web打包工具,它拥有目前最快的打包记录和压缩记录,snowpack和vite的也是使用它来做打包工具,为了追求卓越的性能,目前没有将AST进行暴露,也无法修改AST,无法用作解析对应的JavaScript。

# AST应用

了解完AST,你会发现我们可以用它做许多复杂的事情,我们先来利用@babel/core简单实现一个移除console的插件来感受一下吧。

这个其实就是找规律,你只要知道console语句在AST上是怎样表现的就能够通过这一特点精确找到所有的console语句并将其移出就好了。

  • 先来看下console语句的AST长什么样 3-babel-ast.png

很明显它是一个表达式节点,所以我们只需要找到name为console的表达式节点删除即可。

  • 编写plugin
const babel  = require("@babel/core")
let originCode = `
    let fn = () => {
        const a = 1
        console.log('前端南玖')
        if(a) {
            console.log(a)
        }else {
            return false
        }
    }
`


let removeConsolePlugin = function() {
    return {
        // 访问器
        visitor: {
            CallExpression(path, state) {
                const { node } = path

                if(node?.callee?.object?.name === 'console') {
                    console.log('找到了console语句')
                    path.parentPath.remove()
                }
            }
        }
    }
}

const options = {
    plugins: [removeConsolePlugin()]
}
let res = babel.transformSync(originCode, options)

console.dir(res.code)

4-babel-ast.png

从执行结果来看,它找到了两个console语句,并且都将它们移除了

这就是对AST的简单应用,学会AST能做的远不止这些像前端大部分比较高级的内容都能看到它的存在。后面会继续更新Babel以及插件的用法。

原文首发地址点这里 (opens new window),欢迎大家关注公众号 「前端南玖」,如果你想进前端交流群一起学习,请点这里 (opens new window)

我是南玖,我们下期见!!!