前端开始学编译-抽象语法树 (Abstract Syntax Tree)

本文深入探讨了抽象语法树(AST)的概念及其在代码处理中的重要作用。通过实例展示了如何使用AST进行代码高亮、压缩、转换等操作,详细解释了词法分析、语法分析和代码生成的过程。重点讲解了如何遍历和修改AST,以及在实际场景中如Babel和webpack中的应用。最后,通过三个具体的例子说明了如何利用AST将函数中的log转换为error,为函数添加错误捕获,以及实现按需导入的功能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

概念和基本结构

AST 是一种源代码的抽象语法结构的树形表示。树中的每个节点都表示源代码中出现的一个构造。

AST 的世界”里所有的一切都是 节点(Node),不同类型的节点之间相互嵌套形成一颗完整的树形结构。

{
  "program": {
    "type": "Program",
    "sourceType": "module",
    "body": [
      {
        "type": "FunctionDeclaration",
        "id": {
          "type": "Identifier",
          "name": "foo"
        },
        "params": [
          {
            "type": "Identifier",
            "name": "x"
          }
        ],
        "body": {
          "type": "BlockStatement",
          "body": [
            {
              "type": "IfStatement",
              "test": {
                "type": "BinaryExpression",
                "left": {
                  "type": "Identifier",
                  "name": "x"
                },
                "operator": ">",
                "right": {
                  "type": "NumericLiteral",
                  "value": 10
                }
              }
            }
          ]
        }
        ...
       }
       ...
    ]
}

AST 的结构不全一样,目前 JavaScript 编译器遵循的通用规范 —— ESTree 中对于 AST 结构的一些基本定义,不同的编译工具都是基于此结构进行了相应的拓展。

生成方式

计算机想要理解一串源代码需要经过一系列的分析过程:

1. 词法分析 (Lexical Analysis) 

词法分析阶段扫描输入的源代码字符串,生成一系列的词法单元 (tokens),这些词法单元包括数字,标点符号,运算符等。词法单元之间都是独立,该阶段并不关心代码的组合方式。

2. 语法分析 (Syntax Analysis)

语法分析阶段就会将上一阶段生成的 token 列表转换为如下图右侧所示的 AST,根据这个数据结构可看出转换之前源代码的基本构造;

3. 代码生成 (Code Generation)

代码生成阶段是一个非常自由的环节,可由多个步骤共同组成,此阶段可以遍历初始的 AST,对其结构进行改造,再将改造后的结构生成对应的代码字符串。

用法与实战 

AST 的应用场景:

  • 代码高亮、格式化、错误提示、自动补全等ESlintPrettierVetur等。
  • 代码压缩混淆uglifyJS等
  • 代码转译webpackbabelTypeScript等

那如何使用 AST,其使用步骤:

  1. 解析 (Parsing):这个过程由编译器实现,会经过词法分析过程和语法分析过程,从而生成 AST
  2. 读取/遍历 (Traverse):深度优先遍历 AST ,访问树上各个节点的信息(Node)。
  3. 修改/转换 (Transform):在遍历的过程中可对节点信息进行修改,生成新的 AST
  4. 输出 (Printing):对初始 AST 进行转换后,根据不同的场景,既可以直接输出新的 AST,也可以转译成新的代码块。

通常情况下使用 AST重点关注的是步骤2和3,诸如 Babel、ESLint 等工具暴露出来的通用能力都是对初始 AST 进行访问和修改。

这两步的实现基于一种名为访问者模式的设计模式,即定义一个 visitor 对象,在该对象上定义了对各种类型节点的访问方法,这样就可以针对不同的节点做出不同的处理。例如,编写 Babel 插件其实就是在构造一个 visitor 实例来处理各个节点信息,从而生成想要的结果:

const visitor = {

    CallExpression(path) {

        ...

    }

    FunctionDeclaration(path) {

        ...

    }   

    ImportDeclaration(path) {

        ...

    }

    ...

}

现在我们来看看如何使用 Bable 操作 AST。需要用到以下开发工具:

  • AST Explorer:在线 AST 转换工具,集成了多种语言和解析器
  • @babel/parser :将 JS 代码解析成对应的 AST
  • @babel/traverse:对 AST 节点进行递归遍历
  • @babel/types:集成了一些快速生成、修改、删除 AST Node的方法
  • @babel/generator :根据修改过后的 AST 生成新的 js 代码

1. 将所有函数中的普通 log 打印转换成 error 打印,并在打印内容前方附加函数名的字符串

/** 转换前 */
function add(a, b) {
    console.log(a + b);
    return a + b;
}

/** 转换后 */
function add(a, b) {
    console.error('add', a + b);
    return a + b;
}

实现思路:

  • 遍历所有的函数调用表达式(CallExpression)节点
  • 将函数调用方法的属性由 log 改为 error
  • 找到函数声明(FunctionDeclaration)父节点,提取函数名信息
  • 将函数名信息包装成字符串字面量(StringLiteral)节点,插入函数调用表达式的参数节点数组中
const compile = (code) => {
  /** 解析成初始AST */
  const ast = parser.parse(code);
  const visitor = {
    /** 1. 遍历所有的函数调用表达式(CallExpression)节点 */
    CallExpression(path) {
      const { callee } = path.node;
      if (types.isCallExpression(path.node) && types.isMemberExpression(callee)) {
        const { object, property } = callee;
        if (object.name !== 'console' || property.name !== 'log') return;
        /** 2. 将成员表达式的属性由 log -> error */
        property.name = 'error';
        /** 3. 向上遍历,在该函数调用节点的父节点中找到函数声明节点 */
        const FunctionDeclarationNode = path.findParent(parent => {
          return parent.type === 'FunctionDeclaration';
        })
        /** 4. 提取函数名称信息,包装成一个字符串字面量节点,插入当前节点的参数数组中 */
        const funcNameNode = types.stringLiteral(FunctionDeclarationNode.node.id.name)
        path.node.arguments.unshift(funcNameNode);
      }
    }
  }
  traverse.default(ast, visitor);
  /** code generator */
  const newCode = generator.default(ast, {}, code).code;
}

2. 为所有的函数添加错误捕获,并在捕获阶段实现自定义的处理操作

/** 处理前 */
function add(a, b) {
  console.log('23333');
  throw new Error('233 Error');
  return a + b;
}

/** 处理后 */
function add(a, b) {
  /** 这里只能捕获到同步代码的执行错误 */
  try {    
    console.log('23333');
    throw new Error('233 Error');
    return a + b;
  } catch (myError) {
      /** 自定义处理(eg:函数错误自动上报) */
      mySlardar(myError);
  }
}

思路:

  • 遍历函数声明(FunctionDeclaration)节点
  • 提取该节点下整个代码块节点,作为 try 语句tryStatement)处理块中的内容
  • 构造一个自定义的 catch 子句(catchClause)节点,作为 try 异常处理块的内容
  • 将整个 try 语句节点作为一个新的函数声明节点的子节点,用新生成的节点替换原有的函数声明节点
const compile = (code) => {

  /** 解析成初始AST */
  const ast = parser.parse(code);
  /** 查看 ast 结果 */
  /** utils.writeAst2File(ast) **/ // 
  const visitor = {
    /** 1. 遍历所有的函数调用表达式(CallExpression)节点 */
    FunctionDeclaration(path) {
      const node = path.node
      const { params, id } = node;
      /** 2. 提取该节点下整个代码块节点,作为 try 语句(tryStatement)处理块中的内容 */
      const blockStatementNode = node.body;
      /** 已经有 try-catch 块的停止遍历,防止 circle loop */
      if (blockStatementNode.body && types.isTryStatement(blockStatementNode.body[0])) return;
      /** 3. 构造 catch 块节点和catch 子句节点 */
      const catchBlockStatement = types.blockStatement(
        [types.expressionStatement(
          types.callExpression(types.identifier('mySlardar'), [types.identifier('myError')])
        )]
      );
      const catchClause = types.catchClause(types.identifier('myError'), catchBlockStatement);
      /** try - catch 语句节点 */
      const tryStatementNode = types.tryStatement(blockStatementNode, catchClause);
      /** 4. try-catch 节点作为新的函数声明节点 */
      const tryCatchFunctionDeclare = types.functionDeclaration(id, params, types.blockStatement([tryStatementNode]));
      path.replaceWith(tryCatchFunctionDeclare);
    }
  }
  traverse.default(ast, visitor);
  /** code generator */
  const newCode = generator.default(ast, {}, code).code;
}

3. 在 webpack 中实现 import 的按需导入(简单版 babel-import-plugin)

/** 处理前 */
import { Button as Btn, Dialog } from '233_UI'
import { HHH as hhh } from '233_UI'

/**
 * 设置自定义参数: 
 * (moduleName) => `233_UI/lib/src/${moduleName}/${moduleName} `
 */

/** 处理后 */
import { Button as Btn } from "2333_UI/lib/src/Button/Button"
import { Dialog } from "2333_UI/lib/src/Dialog/Dialog"
import { HHH as hhh } from "2333_UI/lib/src/HHH/HHH"

思路:

  • 在插件运行的上下文状态中指定自定义的查找文件路径规则
  • 遍历 import 声明节点(ImportDeclaration)
  • 提取 import 节点中所有被导入的变量节点(ImportSpecifier)
  • 将该节点的值通过查找文件路径规则生成新的导入源路径,有几个导入节点就有几个新的源路径
  • 组合被导入的节点和源头路径节点,生成新的 import 声明节点并替换
const visitor = ({types}) => {
  return {
    visitor: {
      ImportDeclaration(path, {opts}) {
        /** 1. 通过插件的参数获取模块指定路径 */
        const _getModulePath = opts.moduleName;
        /** 2. 所有的 import 声明对象节点的来源 */
        const importSpecifierNodes = path.node.specifiers; 
        const importSourceNode = path.node.source; 
        /** 导入的来源路径 */
        const sourceNodePath = importSourceNode.value;
        /** import 节点来源路径不等于参数指定路径的,不做替换 */
        if (!opts.libaryName || sourceNodePath !== opts.libaryName) return;
        /** 3. 根据查找文件路径规则生成新的导入源路径 */
        const modulePaths = importSpecifierNodes.map(node => {
          return _getModulePath(node.imported.name);
        })
        /** 4. 组合被导入的节点和源头路径节点,生成新的 import 声明节点并替换 */
        const newImportDeclarationNodes = importSpecifierNodes.map((node, index) => {
          return types.importDeclaration([node], types.stringLiteral(modulePaths[index]));
        })
        path.replaceWithMultiple(newImportDeclarationNodes);
      }
    }
  }
}

const result = babel.transform(code, {
  plugins: [
    [
      visitor,
      {
        libaryName: '2333_UI',
        moduleName: moduleName => `2333_UI/lib/src/${moduleName}/${moduleName}`
      }
    ]
  ]
})

总结

日常的开发或许较少的接触到AST,也不太有机会关注这些编译原理。但对AST的了解可以加快我们上手熟练这些开发工具和api。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

薛定谔的猫96

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值