浅读AST

857 阅读17分钟

AST概念

AST编译原理中被广泛应用,它不仅是编译器的核心数据结构,还被用于许多相关领域的工具和技术中。比如当我们打开前端项目中的package.json文件时,我们会看到许多工具已经成为我们日常开发中的重要角色。这些工具包括JavaScript转译器CSS预处理器代码压缩工具ESLintPrettier等。今天我们来说说这些功能的实现。

JavaScript转译举例:JavaScript转译器将使用较新版本的JavaScript编写的代码转换为向后兼容的代码,以便在旧版浏览器或其他环境中运行。常见的JavaScript转译工具包括BabelTypeScript。它们使用语法解析器(parser)将源代码解析为抽象语法树(AST),然后通过转换器(transformer)对AST进行修改和转换,最后生成转译后的代码。

JavaScript转译是前端编译中的一部分,它在开发过程中起着重要的作用。那么前端的编译原理是什么呢?它是如何运用抽象语法树(AST)的呢?

前端的编译原理

将高级语言(如JavaScript)转换为计算机可以执行的低级语言(如机器码)的过程就是前端的编译过程。它包含以下几个主要过程和链路:

  1. 词法分析(Lexical Analysis):将源代码分解为一个个的词法单元(Token),如标识符、关键字、操作符等。词法分析器根据预定义的词法规则进行识别和分类。

  2. 语法分析(Syntax Analysis):根据语法规则,将词法单元组织成一个树状结构,即抽象语法树(AST)。语法分析器通过语法规则和语法分析算法,将词法单元解析为语法结构,如表达式、语句、函数等。

  3. 语义分析(Semantic Analysis):对AST进行语义分析,检查代码中的语义错误和不一致性。语义分析器会进行类型检查、作用域分析、符号表管理等操作,以确保代码的正确性和一致性。

  4. 中间代码生成(Intermediate Code Generation):根据AST生成中间代码,即一种介于源代码和目标代码之间的表示形式。中间代码通常是一种抽象的、与具体机器无关的表示形式,方便后续的优化和目标代码生成。

  5. 优化(Optimization):对中间代码进行优化,以提高代码的执行效率和空间利用率。优化器会对中间代码进行各种优化操作,如常量折叠、循环优化、内联展开等。

  6. 目标代码生成(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的方法和工具:

  1. 手动构建:可以通过编写代码来手动构建AST。根据语言的语法规则,逐步构建AST的节点和关系。这种方法适用于简单的语言或特定的场景,但对于复杂的语言或大型项目来说,手动构建AST可能会变得繁琐和容易出错。

  2. 解析器/编译器:使用解析器或编译器工具可以自动构建AST。这些工具会接收源代码作为输入,然后根据语法规则解析源代码,并生成相应的AST。常见的解析器/编译器工具包括Babel(JavaScript)、ANTLR(通用)、ANTLR4(通用)等。

  3. 第三方库:许多编程语言和平台提供了第三方库来构建AST。这些库提供了API和工具,使得构建AST变得更加简单和高效。例如,在JavaScript中,有Esprima、acorn等库可以用于构建AST。

AST的遍历和操作:

遍历和操作AST是对AST进行分析、转换和优化的重要步骤。以下是常见的方法和工具:

  1. 递归遍历:使用递归的方式遍历AST的节点和子节点。通过递归,可以深度优先地遍历整个AST,并对每个节点执行相应的操作。

  2. 访问者模式:使用访问者模式可以对AST进行更复杂的遍历和操作。访问者模式将遍历和操作分离,通过定义访问者对象来实现对AST节点的访问和操作。这种模式对于需要在多个节点上执行相同操作的情况特别有用。

  3. 第三方库:许多编程语言和平台提供了第三方库来简化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),我们可以对代码进行静态分析,并进行各种代码重构操作。以下是一些常见的代码重构技术及其在抽象语法树上的实现方式:

  1. 变量重命名:
    • 在抽象语法树中,通过遍历变量声明节点,找到需要重命名的变量节点,并修改其标识符。
  2. 函数提取:
    • 在抽象语法树中,通过遍历函数调用节点,找到需要提取的代码片段,并创建一个新的函数节点,将代码片段移动到新函数中,并将函数调用节点替换为对新函数的调用。
  3. 条件简化:
    • 在抽象语法树中,通过遍历条件语句节点,找到需要简化的条件表达式,并根据情况进行逻辑优化,如合并冗余的条件、简化复杂的逻辑等。
  4. 代码块提取:
    • 在抽象语法树中,通过遍历代码块节点,找到可以提取为函数或方法的代码块,并创建一个新的函数节点,将代码块移动到新函数中,并将原来的代码块替换为对新函数的调用。
  5. 类提取:
    • 在抽象语法树中,通过遍历类节点,找到可以提取为独立类的代码片段,并创建一个新的类节点,将代码片段移动到新类中,并将原来的代码替换为对新类的实例化和调用。

在抽象语法树中,我们可以遍历条件语句、定位并修改特定节点,找到需要简化的条件表达式,并根据情况进行逻辑优化,如合并冗余的条件、简化复杂的逻辑等,从而实现各种代码重构操作。在进行代码重构之前,建议先对代码进行静态分析,构建抽象语法树,并确保对代码的改变不会影响其外部行为。在实际应用中,可以使用工具库或编写自定义脚本来处理抽象语法树的操作,可能需要使用更高级的抽象语法树操作技术,如语法模式匹配和代码模式转换等。

AST在前端开发中的应用

项目中可以用到的ast有哪些?以下是一些常见的项目中可以使用到的AST工具和库:

  1. Esprima:Esprima是一个JavaScript解析器,可以将JavaScript代码解析为抽象语法树(AST)。它可以用于静态代码分析、代码转换和代码重构等任务。

  2. Babel:Babel是一个广泛使用的JavaScript编译器,它可以将新版本的JavaScript代码转换为向后兼容的版本。Babel使用AST来分析和转换代码,可以通过插件机制进行自定义的代码转换。

  3. ESLint:ESLint是一个用于静态代码分析的工具,它可以检查JavaScript代码中的潜在问题和错误。ESLint使用AST来分析代码,并提供了丰富的规则集和插件机制,可以根据项目需求进行定制。

  4. TypeScript Compiler API:TypeScript编译器提供了一个API,可以用于将TypeScript代码解析为AST,并进行类型检查、代码转换和生成目标代码等操作。TypeScript编译器API可以用于构建工具、编辑器插件和自定义工作流程。

  5. recast:recast是一个JavaScript语法树重构工具,它提供了一组API来操作和转换JavaScript代码的AST。recast可以用于代码重构、代码生成和代码分析等任务。

同时,AST也在其他领域中得到广泛应用。在静态代码分析、代码重构、代码格式化等工具中,AST可以帮助开发者理解和操作代码。在编辑器和IDE中,AST可以用于代码高亮、自动补全、错误检测等功能。编译原理和AST是紧密相关的,AST是编译过程中的一个重要数据结构,它在编译和代码分析中起着关键的作用。