AST概念
AST在编译原理
中被广泛应用,它不仅是编译器的核心数据结构,还被用于许多相关领域的工具和技术中。比如当我们打开前端项目中的package.json
文件时,我们会看到许多工具已经成为我们日常开发中的重要角色。这些工具包括JavaScript转译器
、CSS预处理器
、代码压缩工具
、ESLint
、Prettier
等。今天我们来说说这些功能的实现。
拿JavaScript转译
举例:JavaScript转译器将使用较新版本的JavaScript编写的代码转换为向后兼容的代码,以便在旧版浏览器或其他环境中运行。常见的JavaScript转译工具包括Babel
和TypeScript
。它们使用语法解析器(parser)将源代码解析为抽象语法树(AST),然后通过转换器(transformer)对AST进行修改和转换,最后生成转译后的代码。
JavaScript转译是前端编译中的一部分,它在开发过程中起着重要的作用。那么前端的编译原理是什么呢?它是如何运用抽象语法树(AST)
的呢?
前端的编译原理
将高级语言(如JavaScript)转换为计算机可以执行的低级语言(如机器码)的过程就是前端的编译过程。它包含以下几个主要过程和链路:
-
词法分析(Lexical Analysis):将源代码分解为一个个的词法单元(Token),如标识符、关键字、操作符等。词法分析器根据预定义的词法规则进行识别和分类。
-
语法分析(Syntax Analysis):根据语法规则,将词法单元组织成一个树状结构,即抽象语法树(AST)。语法分析器通过语法规则和语法分析算法,将词法单元解析为语法结构,如表达式、语句、函数等。
-
语义分析(Semantic Analysis):对AST进行语义分析,检查代码中的语义错误和不一致性。语义分析器会进行类型检查、作用域分析、符号表管理等操作,以确保代码的正确性和一致性。
-
中间代码生成(Intermediate Code Generation):根据AST生成中间代码,即一种介于源代码和目标代码之间的表示形式。中间代码通常是一种抽象的、与具体机器无关的表示形式,方便后续的优化和目标代码生成。
-
优化(Optimization):对中间代码进行优化,以提高代码的执行效率和空间利用率。优化器会对中间代码进行各种优化操作,如常量折叠、循环优化、内联展开等。
-
目标代码生成(Code Generation):根据中间代码生成目标代码,即特定机器上可执行的形式。目标代码生成器将中间代码转换为特定机器的指令集,包括机器指令、寄存器分配、内存管理等。
以上过程和链路组成了前端的编译原理,将高级语言代码转换为可执行的目标代码。这些过程相互关联,通过不同的算法和技术实现。编译原理在前端开发中起着重要的作用,可以提高代码的执行效率、可维护性和可扩展性。
编译原理与AST之间的关联
编译原理是研究将高级语言转换为低级语言的一门学科,它涉及到词法分析、语法分析、语义分析、中间代码生成、优化和目标代码生成等过程。其中,语法分析阶段是将源代码转换为抽象语法树的重要环节。
抽象语法树(AST)是编译过程中的一种数据结构,它以树状结构表示源代码的语法结构。AST是对源代码的抽象和简化,它去除了具体语法细节和不必要的信息,只保留了程序的结构和关键元素。
在编译过程中,词法分析器将源代码分解为词法单元(Token),然后语法分析器根据语法规则将这些词法单元组织成抽象语法树。AST的节点表示源代码的语法结构,节点之间的关系表示语法的层次结构和依赖关系。
AST在编译过程中起着重要的作用,它是后续步骤(如语义分析、中间代码生成、优化等)的基础。通过遍历和操作AST,我们可以进行语义分析、优化和代码生成等操作,从而最终生成目标代码。
当说到JavaScript的编译过程时,可以通过一个简单的示例来演示。
假设我们有以下的代码:
(add 2 (subtract 4 2))
现在,我们将通过编译过程将代码转换为可以在浏览器中执行的代码。编译过程通常包括以下几个步骤:
词法分析
词法分析(Lexical Analysis),也称为扫描(Scanning),是编译过程中的第一个阶段,用于将源代码分解为一个个的词法单元(Token)。词法分析器(Lexer)负责执行词法分析,它根据预定义的词法规则,逐个读取源代码字符,并将其组合成词法单元。词法单元是代码中具有独立意义的最小单元,如标识符、关键字、操作符、常量等。
词法分析器通常使用有限状态自动机(Finite State Automaton)来实现。它根据预定义的词法规则,通过状态转换来识别和生成词法单元。词法规则通常使用正则表达式或有限状态机的形式来描述。它为后续的语法分析和语义分析提供了基础。词法分析器生成的词法单元将作为语法分析器的输入,用于构建抽象语法树(AST)和进行后续的语义分析。
词法分析是编译过程中的第一个阶段,负责将源代码分解为词法单元。它通过词法规则和有限状态自动机来识别和生成词法单元,为后续的编译过程提供了基础。
在我们的示例中,我们的词法单元有:
// [
// { type: 'paren', value: '(' },
// { type: 'name', value: 'add' },
// { type: 'number', value: '2' },
// { type: 'paren', value: '(' },
// { type: 'name', value: 'subtract' },
// { type: 'number', value: '4' },
// { type: 'number', value: '2' },
// { type: 'paren', value: ')' }, <<< Closing parenthesis
// { type: 'paren', value: ')' }, <<< Closing parenthesis
// ]
然后是词法分析函数:
function tokenizer (input) {
let current = 0;
let tokens = []
while (current < input.length) {
let chat = input[current]
if (chat === "(") {
tokens.push({
type: "paren",
value: "("
})
current++
continue
}
if (chat === ")") {
tokens.push({
type: "paren",
value: ")"
})
current++
continue
}
let WHITEPACE = /\s/;
if (WHITEPACE.test(chat)) {
current++
continue
}
//处理数字 "(add 2222 (subtract 4 2))" 2222还是2 2 2 2从第一个数字遍历到不是数字为止
let NUMBERS = /[0-9]/
if (NUMBERS.test(chat)) {
let val = ''
while (NUMBERS.test(chat)) {
val += chat
chat = input[++current]
}
tokens.push({
type: "number",
value: val
})
continue
}
//(concat "abc" "def")
if (chat === "") {
let value = ''
chat = input[++current]
while (chat !== '"') { //跳过第一个引号
value += chat;
chat = input[++current]
}
chat = input[++current] //跳过最后一个引号
tokens.push({
type: "string",
value
})
continue
}
//操作符
let LETTERS = /[a-z]/i;
if (LETTERS.test(chat)) {
let value = ''
while (LETTERS.test(chat)) {
value += chat;
chat = input[++current]
}
tokens.push({
type: "name",
value
})
continue
}
throw new TypeError("没有指定的类型:", chat)
}
return tokens
}
// 调用词法分析函数
const tokens = tokenizer(code);
// 输出词法单元
console.log(tokens);
语法分析
语法分析(Syntax Analysis),也称为解析(Parsing),是编译过程中的一个重要阶段,它将词法分析器生成的词法单元组织成一个树状结构,即抽象语法树(AST)。语法分析器(Parser)负责执行语法分析,它根据语法规则和语法分析算法,逐个读取词法单元,并将其组织成语法结构。语法结构包括表达式、语句、函数等,它们按照特定的语法规则组合而成。
语法分析器通常使用自顶向下(Top-Down)或自底向上(Bottom-Up)的算法来实现。常见的语法分析算法包括递归下降、LL(1)分析、LR分析等。它将词法分析器生成的词法单元转换为抽象语法树,并为后续的语义分析、中间代码生成、优化等阶段提供了基础。
语法分析是编译过程中的一个重要阶段,负责将词法单元组织成抽象语法树。语法分析器根据语法规则和语法分析算法,匹配词法单元,处理语法错误,并构建抽象语法树。它为后续的编译过程提供了基础。
在我们的示例中的语法分析函数:
function parser (tokens) {
let current = 0;
function walk () {
let token = tokens[current]
if (token.type === "number") {
current++
return {
type: "NumberLiteral",
value: token.value
}
}
if (token.type === "string") {
current++
return {
type: "stringLiteral",
value: token.value
}
}
if (token.type === "paren" && token.value === "(") {
token = tokens[++current]
let node = {
type: "CallExpression",
name: token.value,
params: [] //存add里面的参数
}
//收集下一个元素
token = tokens[++current]
while (token.type !== "paren" || (token.type === "paren" && token.value !== ")")) {
//要么遇到的不是(,要么遇到的不是)
node.params.push(walk())
token = tokens[current]
}
current++
return node
}
throw new TypeError(token.type)
}
let ast = {
type: "Program",
body: []
}
while (current < tokens.length) {
ast.body.push(walk())
}
return ast
}
// 调用语法分析函数
const ast = parser(tokens);
// 输出抽象语法树
console.log(ast);
在这个示例中,语法分析器根据语法规则,将词法单元按照特定的结构组织成抽象语法树。抽象语法树反映了源代码的语法结构,使得后续的语义分析、中间代码生成和优化等步骤可以基于抽象语法树进行操作。
代码转换
代码转换(Code Transformation)是将一个形式的代码转换为另一个形式的过程,通常在编译器或代码转换工具中使用。代码转换可以基于不同的目的,如优化代码性能、改变代码结构、适配不同的平台等。代码转换同样是编译过程中的一个重要阶段,通常在语法分析和语义分析之后,中间代码生成之前进行。代码转换可以应用各种转换技术和优化策略,以改进代码的执行效率、减少资源消耗或提高可维护性。
代码转换可以手动进行,也可以使用自动化工具和编译器来实现。自动化工具和编译器通常提供了各种转换规则和转换策略,使得代码转换变得更加高效和准确。
/**
* Next up, the transformer. Our transformer is going to take the AST that we
* have built and pass it to our traverser function with a visitor and will
* create a new ast.
*
* ----------------------------------------------------------------------------
* Original AST | Transformed AST
* ----------------------------------------------------------------------------
* { | {
* type: 'Program', | type: 'Program',
* body: [{ | body: [{
* type: 'CallExpression', | type: 'ExpressionStatement',
* name: 'add', | expression: {
* params: [{ | type: 'CallExpression',
* type: 'NumberLiteral', | callee: {
* value: '2' | type: 'Identifier',
* }, { | name: 'add'
* type: 'CallExpression', | },
* name: 'subtract', | arguments: [{
* params: [{ | type: 'NumberLiteral',
* type: 'NumberLiteral', | value: '2'
* value: '4' | }, {
* }, { | type: 'CallExpression',
* type: 'NumberLiteral', | callee: {
* value: '2' | type: 'Identifier',
* }] | name: 'subtract'
* }] | },
* }] | arguments: [{
* } | type: 'NumberLiteral',
* | value: '4'
* ---------------------------------- | }, {
* | type: 'NumberLiteral',
* | value: '2'
* | }]
* (sorry the other one is longer.) | }
* | }
* | }]
* | }
* ----------------------------------------------------------------------------
*/
//visitor 模式
//CallExpression visitor有没有包含 当前处理节点的操作 有的话执行对应的操作
// {
// "CallExpression":{
// someMethods: () => {}
// }
// }
function traverser (ast, visitor) {
function traverseArray (array, parent) { //parent是外部节点,是关联整个执行过程的关键元素
array.forEach(child => {
traverseNode(child, parent);
});
}
function traverseNode (node, parent) {
let methods = visitor[node.type]; //visitor中有没有对应的方法,所谓的钩子函数 beforEnter
if (methods && methods.enter) { //判度要不要执行这个节点
methods.enter(node, parent); //在这里注入,相关联的方法去处理它
}
switch (node.type) {
case 'Program':
traverseArray(node.body, node);
break;
case 'CallExpression':
traverseArray(node.params, node);
break;
case 'NumberLiteral':
case 'StringLiteral':
break;
default:
throw new TypeError(node.type);
}
if (methods && methods.exit) {
methods.exit(node, parent);
}
}
traverseNode(ast, null); //第一次是顶级节点body,没有父节点
}
// 代码转换
function transformer (ast) {
// We'll create a `newAst` which like our previous AST will have a program
// node.
let newAst = {
type: 'Program',
body: [],
};
// Next I'm going to cheat a little and create a bit of a hack. We're going to
// use a property named `context` on our parent nodes that we're going to push
// nodes to their parent's `context`. Normally you would have a better
// abstraction than this, but for our purposes this keeps things simple.
//
// Just take note that the context is a reference *from* the old ast *to* the
// new ast.
ast._context = newAst.body;
// We'll start by calling the traverser function with our ast and a visitor.
traverser(ast, {
NumberLiteral: {
enter (node, parent) {
//此时enter的parent要指向当前处理元素的parent
//如何将newAst 跟上部分parent关联起来
//parent._context === ast._context === newAst._context
parent._context.push({
type: 'NumberLiteral',
value: node.value,
});
},
},
StringLiteral: {
enter (node, parent) {
parent._context.push({
type: 'StringLiteral',
value: node.value,
});
},
},
CallExpression: {
enter (node, parent) {
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name,
},
arguments: [],
};
node._context = expression.arguments;
if (parent.type !== 'CallExpression') {
expression = {
type: 'ExpressionStatement',
expression: expression,
};
}
parent._context.push(expression);
},
}
});
return newAst;
}
代码生成
代码生成(Code Generation)将经过语法分析、语义分析和代码转换等步骤处理后的中间表示(如抽象语法树、中间代码)转换为目标代码或特定平台的代码。在代码生成阶段,根据特定的目标语言或平台的规则和约束,将中间表示转换为等效的目标代码。代码生成的目标可以是机器代码、字节码、汇编代码、JavaScript代码等,取决于所编译的语言和目标平台。
// 代码生成
function codeGenerator (node) {
switch (node.type) {
case "Program":
return node.body.map(codeGenerator).join("\n"); //let a = 1; let b = 2;
case "ExpressionStatement":
return codeGenerator(node.expression) + ";";
case "CallExpression":
return codeGenerator(node.callee) + "(" + node.arguments.map(codeGenerator).join(", ") + ")"
case "Identifier":
return node.name;
case "NumberLiteral":
return node.value
case "stringLiteral":
return '"' + node.value + '"'
default:
throw new TypeError(node.type)
}
}
module.exports = {
tokenizer,
parser,
transformer,
codeGenerator
}
构建AST的方法和工具:
-
手动构建:可以通过编写代码来手动构建AST。根据语言的语法规则,逐步构建AST的节点和关系。这种方法适用于简单的语言或特定的场景,但对于复杂的语言或大型项目来说,手动构建AST可能会变得繁琐和容易出错。
-
解析器/编译器:使用解析器或编译器工具可以自动构建AST。这些工具会接收源代码作为输入,然后根据语法规则解析源代码,并生成相应的AST。常见的解析器/编译器工具包括Babel(JavaScript)、ANTLR(通用)、ANTLR4(通用)等。
-
第三方库:许多编程语言和平台提供了第三方库来构建AST。这些库提供了API和工具,使得构建AST变得更加简单和高效。例如,在JavaScript中,有Esprima、acorn等库可以用于构建AST。
AST的遍历和操作:
遍历和操作AST是对AST进行分析、转换和优化的重要步骤。以下是常见的方法和工具:
-
递归遍历:使用递归的方式遍历AST的节点和子节点。通过递归,可以深度优先地遍历整个AST,并对每个节点执行相应的操作。
-
访问者模式:使用访问者模式可以对AST进行更复杂的遍历和操作。访问者模式将遍历和操作分离,通过定义访问者对象来实现对AST节点的访问和操作。这种模式对于需要在多个节点上执行相同操作的情况特别有用。
-
第三方库:许多编程语言和平台提供了第三方库来简化AST的遍历和操作。这些库提供了API和工具,使得遍历和操作AST变得更加方便和高效。例如,在JavaScript中,有Esprima、acorn等库可以用于遍历和操作AST。
以下是一个简单的示例代码,展示了如何使用Esprima库构建和遍历JavaScript的AST:
const esprima = require('esprima');
// JavaScript源代码
const code = `
function add(a, b) {
return a + b;
}
let result = add(2, 3);
console.log(result);
`;
// 构建AST
const ast = esprima.parseScript(code);
// 遍历AST并打印节点类型
function traverseAST(node) {
console.log(node.type);
if (node.body) {
node.body.forEach(childNode => traverseAST(childNode));
}
}
traverseAST(ast);
在示例中,我们使用Esprima库解析JavaScript源代码,生成对应的AST。然后,我们使用递归遍历的方式遍历AST,并打印每个节点的类型。
如何利用抽象语法树进行代码重构
代码重构
是指通过改变代码的内部结构和组织,而不改变其外部行为的过程。它旨在提高代码的可读性、可维护性和可扩展性,减少代码中的重复和冗余,并改进代码的结构和设计。
利用抽象语法树(AST),我们可以对代码进行静态分析,并进行各种代码重构操作。以下是一些常见的代码重构技术及其在抽象语法树上的实现方式:
- 变量重命名:
- 在抽象语法树中,通过遍历变量声明节点,找到需要重命名的变量节点,并修改其标识符。
- 函数提取:
- 在抽象语法树中,通过遍历函数调用节点,找到需要提取的代码片段,并创建一个新的函数节点,将代码片段移动到新函数中,并将函数调用节点替换为对新函数的调用。
- 条件简化:
- 在抽象语法树中,通过遍历条件语句节点,找到需要简化的条件表达式,并根据情况进行逻辑优化,如合并冗余的条件、简化复杂的逻辑等。
- 代码块提取:
- 在抽象语法树中,通过遍历代码块节点,找到可以提取为函数或方法的代码块,并创建一个新的函数节点,将代码块移动到新函数中,并将原来的代码块替换为对新函数的调用。
- 类提取:
- 在抽象语法树中,通过遍历类节点,找到可以提取为独立类的代码片段,并创建一个新的类节点,将代码片段移动到新类中,并将原来的代码替换为对新类的实例化和调用。
在抽象语法树中,我们可以遍历条件语句、定位并修改特定节点,找到需要简化的条件表达式,并根据情况进行逻辑优化,如合并冗余的条件、简化复杂的逻辑等,从而实现各种代码重构操作。在进行代码重构之前,建议先对代码进行静态分析,构建抽象语法树,并确保对代码的改变不会影响其外部行为。在实际应用中,可以使用工具库或编写自定义脚本来处理抽象语法树的操作,可能需要使用更高级的抽象语法树操作技术,如语法模式匹配和代码模式转换等。
AST在前端开发中的应用
项目中可以用到的ast有哪些?以下是一些常见的项目中可以使用到的AST工具和库:
-
Esprima:Esprima是一个JavaScript解析器,可以将JavaScript代码解析为抽象语法树(AST)。它可以用于静态代码分析、代码转换和代码重构等任务。
-
Babel:Babel是一个广泛使用的JavaScript编译器,它可以将新版本的JavaScript代码转换为向后兼容的版本。Babel使用AST来分析和转换代码,可以通过插件机制进行自定义的代码转换。
-
ESLint:ESLint是一个用于静态代码分析的工具,它可以检查JavaScript代码中的潜在问题和错误。ESLint使用AST来分析代码,并提供了丰富的规则集和插件机制,可以根据项目需求进行定制。
-
TypeScript Compiler API:TypeScript编译器提供了一个API,可以用于将TypeScript代码解析为AST,并进行类型检查、代码转换和生成目标代码等操作。TypeScript编译器API可以用于构建工具、编辑器插件和自定义工作流程。
-
recast:recast是一个JavaScript语法树重构工具,它提供了一组API来操作和转换JavaScript代码的AST。recast可以用于代码重构、代码生成和代码分析等任务。
同时,AST也在其他领域中得到广泛应用。在静态代码分析、代码重构、代码格式化等工具中,AST可以帮助开发者理解和操作代码。在编辑器和IDE中,AST可以用于代码高亮、自动补全、错误检测等功能。编译原理和AST是紧密相关的,AST是编译过程中的一个重要数据结构,它在编译和代码分析中起着关键的作用。