前端工程化之AST详解

我们所编写的 JavaScript 代码,它会被计算机执行的过程涉及到几个关键步骤,包括词法分析、解析、编译、优化和执行。

首先是词法分析,它会将源代码分解成词元(tokens)的过程。词元是语言中最小的有意义的单位,比如关键字、变量名、数字、字符串等。

然后解析阶段将词元转换成抽象语法树(AST)。AST 是源代码的树状表示,反映了代码的结构和语法规则。

JavaScript 传统上是解释型语言,但现代 JavaScript 引擎采用即时编译(JIT)技术,在解释执行的同时快速编译成本地机器代码,初次编译通常为基线编译,生成快速但未优化的机器代码。

在执行过程中,JavaScript 引擎会根据代码的运行信息反复重新编译同一代码段,以生成更优化的版本,从而提高执行速度,尽管这个优化过程本身可能更耗时。

编译(或优化)后的代码被加载到内存中执行。这时,代码运行在本地机器上,就像其他编译型语言编写的程序一样。

这个时候我们应该会对 JavaScript 的执行流程有一个大概的认识了,那么接下来我们就来了解一下什么是 AST?

什么是 AST

AST 全称为 抽象语法树(Abstract Syntax Tree)。它是源代码的树形结构表示,用于表示编程语言中的语法结构。AST 对源代码进行结构化解析,从而使得编程语言能夠被计算机更好地理解和处理。

AST 主要有以下特点:

  1. 结构化表示:AST 将源代码转换为树形结构,每个节点代表源代码中的一个构造(如语句、表达式、运算符等)。这种结构清晰地表达了代码的语法关系。

  2. 忽略非结构性元素:AST 通常不包含代码中的注释、空格和格式布局等非结构性信息。它专注于代码的逻辑结构。

  3. 精确的语法映射:AST 的结构精确地映射了源代码的语法结构,可以用于深入理解和分析代码。

  4. 语言无关性:虽然 AST 是针对特定编程语言生成的,但它的概念和处理方式在不同编程语言中是通用的。

根据以上这些特点,通常被应用于以下场景:

  1. 编译器和解释器:在编译器和解释器中,AST 是理解、分析和执行源代码的关键步骤。它们首先将源代码转换为 AST,然后进行进一步的处理,如语义分析、优化和代码生成。

  2. 静态代码分析:AST 可以用于静态代码分析工具,以检查代码质量、寻找潜在的错误和漏洞。

  3. 代码格式化和重构:代码格式化工具(如 Prettier)和重构工具使用 AST 来理解代码结构,然后进行格式化或重构而不改变代码的语义。

  4. 代码高亮和文档生成:开发环境和文档工具使用 AST 来实现代码高亮、文档注释的提取和显示。

  5. 源代码转换:例如,Babel 这样的转译器使用 AST 来将使用最新 JavaScript 特性的代码转换为向后兼容的形式。

  6. 代码优化:编译器使用 AST 进行代码优化,如删除未使用的代码、简化表达式等,以提高执行效率。

  7. 跨语言转换:AST 可以用于源代码从一种编程语言转换为另一种编程语言的工具中。

AST 在现代软件开发中扮演着核心角色,它是编译技术、代码分析和处理工具的基础。通过将源代码转换为这种结构化和标准化的形式,AST 使得对代码的深入理解和高效操作成为可能。

举个例子,我们有这样的一个四则运算表达式:1+3*(4-1)+2,为了构建成 AST,我们需要考虑操作服的优先级和括号的使用。

在上面这个表达式中,乘法和括号内的减法有更高的优先级,整个 AST 的结构我们可以划分为以下结构:

  1. 根节点:整个表达式是一个二元加法表达式。
  2. 左侧节点:简单的字面量 1。
  3. 右侧节点:是另一个加法表达式。
  4. 第二个加法表达式的左侧节点:乘法表达式。
  5. 乘法表达式的左侧节点:字面量 3。
  6. 乘法表达式的右侧节点:括号内的减法表达式。
  7. 减法表达式的左侧节点:字面量 4。
  8. 减法表达式的右侧节点:字面量 1。
  9. 第二个加法表达式的右侧节点:字面量 2。

下面是这个结构的 JSON 表示:

json 复制代码
{
  "type": "BinaryExpression",
  "operator": "+",
  "left": {
    "type": "Literal",
    "value": 1
  },
  "right": {
    "type": "BinaryExpression",
    "operator": "+",
    "left": {
      "type": "BinaryExpression",
      "operator": "*",
      "left": {
        "type": "Literal",
        "value": 3
      },
      "right": {
        "type": "BinaryExpression",
        "operator": "-",
        "left": {
          "type": "Literal",
          "value": 4
        },
        "right": {
          "type": "Literal",
          "value": 1
        }
      }
    },
    "right": {
      "type": "Literal",
      "value": 2
    }
  }
}

抽象语法树的树形结构表示可以更直观地展示表达式的语法结构,在树形表示中,每个节点代表一个操作(如加法、乘法、字面量等)。下面是这个表达式的树形表示:

markdown 复制代码
        +
       / \
      1   +
         / \
        *   2
       / \
      3   -
         / \
        4   1

这种树形表示能够很好地反映出表达式中各个部分的优先级和组织结构,是编译器和解释器内部处理表达式的一种常见方式。

AST 的结构

在上面的例子中我们只是了解到了一个普通的例子转换为 AST 的结构,那么接下来我们从程序的角度来了解一下 AST 的树状结构,如下代码所示:

js 复制代码
const moment = "靓仔";

这段代码经过 @babel/parser 编译之后会变成如下图所示:

在这个 AST 中:

  1. Program 是根节点,表示整个程序。

  2. VariableDeclaration 表示变量声明,kind 属性为 "const",表示这是一个常量声明。

  3. declarations 是一个数组,包含所有的变量声明符。

  4. 每个 VariableDeclarator 有一个 id(标识符)和 init(初始化表达式)。

  5. Identifier 的 name 属性是 "moment",表示变量名。

  6. Literal 的 value 是 "靓仔",表示字符串字面量的值,raw 属性显示原始的字符串表达形式。

  7. sourceType 属性指示了解析的代码应被视为哪种类型的 ECMAScript 代码。这个属性对于解析器来说非常重要,因为它决定了如何解释代码中的某些语法结构。sourceType 主要有两个值:

    1. "script":当 sourceType 设为 "script" 时,代码被视为传统的 ECMAScript 脚本。这种模式下,解析器不会启用 ECMAScript 模块(ESM)的特定语法,比如 import 和 export 声明。

    2. "module":当 sourceType 设为 "module" 时,代码被视为 ECMAScript 模块。这意味着解析器会启用模块相关的语法,如 import 和 export,同时也有一些其他的语法和行为上的差异。例如,模块中的顶级 this 关键字是 undefined,而在脚本中,它通常指向全局对象。

如何生成 AST

假设我们有一段 JavaScript 代码,它的编译器的工作流程如下图所示:

上图的编译流程用文字是可以这样解释的:

  1. 词法分析:对源文件进行扫描,将源文件的字符流拆分分一个个的词(Token)

  2. 语法分析:接收词法分析的 Token 流,根据语法规则将这些 Toke 构造出语法树。

  3. 语义分析:对语法树的各个节点之间的关系进行检查,检查语义规则是否被违背,同时对语法树进行必要的优化

  4. 中间代码生成:编译器一般不会直接生成目标代码,会遍历语法树的节点,先生成中间代码

  5. 中间代码进行优化:尝试生成体积最小、最快、最有效率的代码

  6. 目标代码生成:将中间代码转化为目标代码:

目标代码优化:编译器会利用目标机器的提供的特性对目标代码做进一步的优化,如利用 CPU 的流水线,利用 CPU 的多核等,生成最终的目标代码。

词法分析

词法分析是编译器或解释器中的一个关键步骤,它负责将源代码中的字符序列转换成有意义的符号序列,称为"tokens"。这一过程涉及几个关键步骤:

  1. 分解字符序列:词法分析器读取源代码的字符序列,将其分解成更小的部分。

  2. 识别 Tokens:通过识别关键字、标识符、字面量、运算符等,将字符序列转换为 tokens。

  3. 去除无关字符:如空格、换行和注释,这些通常对语法分析没有直接影响。

  4. 生成 Tokens:每个 token 包含 token 类型和与之相关的值。例如,一个数字字面量"123"将被转换为一个类型为数字的 token,其值为 123。

  5. 错误处理:当遇到无法识别的字符序列时,词法分析器需要能够报告错误。

词法分析的结果通常是一个 tokens 序列,这为后续的语法分析阶段提供输入。这个过程是自动化的,对于编译器或解释器的性能和准确性至关重要。

举个例子:

js 复制代码
const moment = "777";

在上面这段代码中,最终生成的 token 流如下代码所示:

json 复制代码
[
  {
    "type": "Keyword",
    "value": "const"
  },
  {
    "type": "Identifier",
    "value": "moment"
  },
  {
    "type": "Punctuator",
    "value": "="
  },
  {
    "type": "String",
    "value": "'777'"
  }
]

语法分析

语法分析是编译过程中的一个核心阶段,它的任务是根据一组给定的语法规则,分析词法分析器生成的 tokens 序列,以确定程序的语法结构。主要步骤包括:

  1. 构建抽象语法树(AST):根据 tokens 和语法规则,构建代表程序结构的树状数据结构。

  2. 语法检查:确保源代码遵循语言的语法规则,如括号是否成对,语句是否正确结束等。

  3. 错误恢复:当遇到语法错误时,尝试修复或提供有意义的错误信息。

  4. 语法分析器通常采用两种主要方法:自顶向下解析和自底向上解析。自顶向下解析从根开始构建 AST,而自底向上解析从叶子节点开始。

    1. 自顶向下分析: 从开始符号开始,通过不断挑选合适的产生式进行推导,将中间句子中的非终结符展开(将 E、operator 不断的替代),从而展开到给定的句子称为。
    2. 自底向上分析:从给定的句子开始,不断的挑选合适的产生式进行归约,将中间句子中的字串归约为非终结符,最终归约到开始符号。

成功的语法分析是代码正确编译的关键,确保了程序的结构和语法的正确性。

假设我们有这样的一段代码,如下所示:

js 复制代码
const moment == "777";

对于上面的这些代码进行词法分享,会产生一下 token 序列:

  1. const: 关键字,表示变量声明的类型。
  2. moment: 标识符,表示变量名。
  3. ==: 运算符,表示等于比较。
  4. "777":字符串字面量,表示字符串值。

在这个过程中,词法分析器将代码分解为最基本的语法单位(tokens),每个 token 代表代码中的一个元素,如关键字、标识符、运算符、字面量等。然而,需要注意的是,这段代码中使用了 == 运算符来进行比较,这是不合法的语法用法,因为在 JavaScript 中,const 关键字用于声明变量,后面应该是赋值运算符 = 而不是比较运算符 ==。因此,虽然词法分析可以成功分解这段代码,但后续的语法分析将会报错。

parser 生成 AST 树

在这里我们使用 @babel/parser 来生成,编写如下代码:

js 复制代码
import { parse } from "@babel/parser";

const code = "const moment = '靓仔';";
const ast = parse(code, {
  // 配置选项
  sourceType: "module",
});

console.log(ast);

运行代码最终输出结果如下图所示:

generator 将更新后的 AST 转化代码

在这个例子中,我们使用@babel/parser 去生成,在开始之前我们首先需要安装相关依赖,如下:

bash 复制代码
pnpm add @babel/generator @babel/parser @babel/traverse

并编写如下代码:

js 复制代码
import * as parser from "@babel/parser";
import _traverse from "@babel/traverse";
import CodeGenerator from "@babel/generator";

const traverse = _traverse.default;

const code = `function square(n) {
  return n * n;
}`;

const ast = parser.parse(code);

traverse(ast, {
  enter(path) {
    if (path.isIdentifier({ name: "n" })) {
      path.node.name = "x";
    }
  },
});

const updatedCode = CodeGenerator.default(ast);
console.log(updatedCode);

最终的代码输出结果如下图所示:

在上面的代码中,首先使用 @babel/parser 将字符串形式的 JavaScript 代码 function square(n) { return n * n; } 转换成了抽象语法树(AST)。然后,它使用 @babel/traverse 遍历这个 AST,寻找所有标识符为 n 的节点,并将它们重命名为 x。这意味着在原始函数中所有的 n 都会被替换为 x。

参考资料

总结

AST 抽象语法树的知识点作为 JavaScript 中(任何编程语言中都有 ast 这个概念,这里就不过多赘述)相对基础的,也是最不可忽略的知识,带给我们的启发是无限可能的,它就像一把螺丝刀,能够拆解 javascript 这台庞大的机器,让我们能够看到一些本质的东西,同时也能通过它批量构建任何 javascript 代码。

相关推荐
susu1083018911几秒前
前端css样式覆盖
前端·css
学习路上的小刘2 分钟前
vue h5 蓝牙连接 webBluetooth API
前端·javascript·vue.js
&白帝&2 分钟前
vue3常用的组件间通信
前端·javascript·vue.js
小白小白从不日白13 分钟前
react 组件通讯
前端·react.js
罗_三金23 分钟前
前端框架对比和选择?
javascript·前端框架·vue·react·angular
Redstone Monstrosity30 分钟前
字节二面
前端·面试
东方翱翔37 分钟前
CSS的三种基本选择器
前端·css
Fan_web1 小时前
JavaScript高级——闭包应用-自定义js模块
开发语言·前端·javascript·css·html
yanglamei19621 小时前
基于GIKT深度知识追踪模型的习题推荐系统源代码+数据库+使用说明,后端采用flask,前端采用vue
前端·数据库·flask
千穹凌帝1 小时前
SpinalHDL之结构(二)
开发语言·前端·fpga开发