聊聊AST及前端应用

AST 初识

什么是 AST

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

Babel 解析生成的 AST 可视化

如何生成 AST

编译器的工作流程如下:

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

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

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

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

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

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

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

AST的构造过程主要涉及词法分析和语法分析两个步骤

词法分析

扫描源文件的字符流,过滤掉字符流中的空格、注释等,并将其分割为一个个词法单元。

词法单元的识别:

通过有限状态机来判断字符串是否和模式(正则表达式)匹配。

工作过程是:首先自动机处于初始状态,之后它开始读入字符串,每读入一个字符,它都根据当前状态和读入字符转换到下一状态,直到字符串结束,若此时自动机处于其接受状态,则表示该字符串被此自动机接受。

如下状态机,可以识别"ab" "abb" "abbb"... 等字符串,该状态机与正则表达式ab+是等价的

语法分析

语法分析器从词法分析器获得符号流,验证句子可以由源语言的文法(描述程序语言构造的语法)生成,构造出一个语法分析树,

上下文无关文法:

  • 终结符:组成串的基本符号,

  • 非终结符:表示串的集合

  • 开始符号:某个非终结符,由这个开始符号表示的串的集合就是这个文法生成的语言。

  • 产生式:描述了将终结符号和非终结符号组合成串的方法。产生式左部为非终结符,右部为0个或多个终结符与非终结符组成。第一个产生式最左边的非终结符为该文法的开始符号

例子:

arduino 复制代码
E -> E operator E | number
operator -> + | - | * | /

非终结符: { E, operator}

开始符号:E

终结符: { ( +, - , *, / , number },其中 number 表示词法分析得到的一个token,在词法分析中用正则表达式 [0-9]+ ``表示

则此语法可以推导出加减乘除的所有运算,比如E ------> E oprator E ------> number + number

语法分析不但要判断给定的句子是否符合语法结构(即可以从开始符号推导出句子),还要分析出这个句子是怎么从起始符号开始产生推导出来的,并根据产生过程生成语法树。

以上面表达式为例:给定一个句子:1 + 2,此句子可以按以下产生式推导出来:

typescript 复制代码
E ------> E operator E ------> number + number ------>  1 + 2

推导过程使用分析树表示:

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

自底向上分析:从给定的句子开始,不断的挑选合适的产生式进行归约,将中间句子中的字串归约为非终结符,最终归约到开始符号。

语法解析器构造器

词法分析的关键点是根据模式(一般由正则表达式表示)对源代码进行分割成token流

语法分析的关键是根据文法选择合适的产生式推导出句子,根据推导过程生成语法树。

目前社区有很多语法解析器构造器,无需关注具体的产生式推导(归约)过程,开发者只需要专注于词法分析的token的分割模式(正则表达式)以及语法分析的文法书写,即可构造出该文法对应的语法解析器。

自顶向下 自底向上
syntax-parser Jison
antlr4(ANother Tool for Language Recognition,)kison

Demo:实现一个能够解析加减乘除算数表达式的语法解析器,并且能够根据解析的 AST 计算结果

kotlin 复制代码
interface ExprAST {
    left: Number | ExprAST,
    operator: '+' | '-' | '*' | '/'
    right: ExprAST | number
}

1+2*3

(1+2)*3

这里以 Jison (bison在js的实现库) 实现为例: RunKi

php 复制代码
var Parser = require("jison").Parser;

const grammar ={
    "lex": {
        "rules": [
           ["\s+",                    "/* skip whitespace */"],
           ["[0-9]+(?:\.[0-9]+)?\b", "return 'NUMBER';"],
           ["\*",                     "return '*';"],
           ["\/",                     "return '/';"],
           ["-",                       "return '-';"],
           ["\+",                     "return '+';"],
           ["\(",                     "return '(';"],
           ["\)",                     "return ')';"],
           ["$",                       "return 'EOF';"]
        ]
    },

    "bnf": {
        // 第一个产生式左边的非终结符会作为语法的起始符号
        // 遇到EOF结束时,输出结果
        // expression ------> e EOF
        "expressions" :[[ "e EOF",   "console.log($1); return $1;"  ]],
        /**
            e ------> NUMBER |
                  e + e |
                  e - e |
                  e * e |
                  ( e ) |
                  - e
        */
        "e" :[
              [ "NUMBER",  "$$ = Number($1)" ],
              [ "e + e",   "$$ = { left: $1, operator: '+', right: $3 }" ],
              [ "e - e",   "$$ = { left: $1, operator: '-', right: $3 }" ],
              [ "e * e",   "$$ = { left: $1, operator: '*', right: $3 }" ],
              [ "e / e",   "$$ = { left: $1, operator: '/', right: $3 }" ],
              [ "- e",     "$$ = { left: null, operator: '-', right: { left: $2, operator: null, right: null } }" ],
              [ "( e )",   "$$ = $2" ]
             ]
    },
   
    // jison就可以比较移进与规约的选择间涉及的优先级
    "operators": [
        ["left", "+", "-"],
        ["left", "*", "/"]
    ]
}

var parser = new Parser(grammar);

// 输出
parser.parse("1+2*3");
  • lex.rules: 词法分割规则,每个规则第一个元素为正则表达式,第二个元素表示匹配到该规则后的执行动作

  • bnf: 巴科斯范式,产生式的一种规范写法

    • key为产生式左部,第一个产生式的key为开始符号
    • value为产生式右部,产生式右部也是数组,数组之间的关系为 | ,

AST 前端应用场景

代码分析

通过将源代码解析成 AST 进行分析,可以检查代码中的错误、进行语法自动补全等等。例如:

  • 在开发过程中,可以使用 ESLint 对代码进行静态分析,以确保代码符合编码标准和最佳实践。

  • 根据注释生成 API 文档

  • 在编辑器中,可以对输入的语法进行解析成 AST 进行分析,完成语法的智能提示

代码转换

将源代码解析为源 AST,对源 AST 进行 CRUD 操作,转换为目标 AST,再将目标 AST 生成目标代码

  • Babel 通过 AST 将 ES6 代码转换为 ES5 代码:
  • UglifyJS 通过 AST 删除无用节点达到压缩 JS 代码目的
  • UI 框架的模板引擎渲染:例如,Vue 将自己的模板解析成 AST ,再根据 AST 渲染模板。
  • 自定义转换:在开发过程中,可以通过 AST 将特定函数调用替换为其他函数调用 或者 组件转换
  • ......

AST 在 Babel 中的应用

Babel是什么

Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments. Here are the main things Babel can do for you:

  • Transform syntax
  • Polyfill features that are missing in your target environment (through a third-party polyfill such as core-js)
  • Source code transformations (codemods)
  • And more! (check out these videos for inspiration)

Babel 是一套解决方案,主要用来把 ECMAScript 2015+的代码转化为浏览器或者其它环境支持的代码。它主要可以做以下事情:

  • 语法转换

  • 为目标环境提供Polyfill解决方案

  • 源码转换

  • 其它可参考

Babel 工作流

Babel 本质是一个编译器,站在 Babel 的角度来看可以分为三步:

1、parse: 将原始代码解析为抽象语法树(ast)

通过 @babel/parser (acorn 和 acorn-jsx) 将输入代码转化为 AST (Babel AST节点类型)

2、transform:遍历源AST做转换,生成新的AST

@babel/traverse 提供遍历 AST 节点的能力,但是不提供转换能力,具体的转换逻辑交给 Babel 的插件来实现,提高了 Babel 的扩展能力

3、generate:遍历新的 ast,生成目标代码

通过 @babel/generate 根据最新的 AST 生成目标代码。

插件

浅尝一下 Babel 插件的转换能力

javascript 复制代码
let { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 };
console.log(x); // 1
console.log(y); // 2
console.log(z); // { a: 3, b: 4 }

针对解构赋值和扩展运算符两个特性,引入@babel/plugin-transform-destructuring @babel/plugin-transform-spread 两个插件进行转换

perl 复制代码
// babel.config.json
{
  "plugins": [
      "@babel/plugin-transform-destructuring",
      "@babel/plugin-transform-spread"
  ]
}

转换输出的代码如下:

css 复制代码
function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }
function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
let _x$y$a$b = {
    x: 1,
    y: 2,
    a: 3,
    b: 4
  },
  x = _x$y$a$b.x,
  y = _x$y$a$b.y,
  z = _objectWithoutProperties(_x$y$a$b, ["x", "y"]);
console.log(x); // 1
console.log(y); // 2
console.log(z); // { a: 3, b: 4 }

Babel插件类型有三种:

  • babel-plugin-transform-xx:处理语法转换相关的插件
  • babel-plugin-syntax-xx:处理 API Polyfill 相关的插件
  • babel-plugin-proposal-xx:用来编译和转换在提案中的属性

Babel 提供了预设(Presets,理解为插件的集合),对常用插件进行封装,常用预设有 @babel/preset-env

插件设计思路原理

访问者模式

首先我们拥有一个由许多对象构成的对象结构,这些对象的都拥有一个accept方法用来接受访问者对象;访问者是一个接口,它拥有一个visit方法,这个方法对访问到的对象结构中不同类型的元素作出不同的反应;在对象结构的一次访问过程中,我们遍历整个对象结构,对每一个元素都实施accept方法,在每一个元素的accept方法中回调访问者的visit方法,从而使访问者得以处理对象结构的每一个元素。我们可以针对对象结构设计不同的访问者类来完成不同的操作。

Babel插件本质是访问者模式中的一个Visitor对象! 针对不同节点类型,定义不同的visit方法(方法名称为AST节点类型)。

scss 复制代码
// 插件写法
module.exports = function() {
    return {
      visitor: {
        FunctionDeclaration(path) {},
        StringLiteral(path) {},
        CallExpression(path) {},
        // ...
      }
    }
}

@babel/traverse提供了 AST 的遍历能力,在处理每个节点时,按照插件顺序,调用插件对应的visit方法来完成对代码的修改操作。

实现一个插件

以Starling国际化为例,将"字符串" => window.i18n("key", "defaultValue")的形式,key以时间戳替代,defaultValue为当前字符串的值

转换前的代码:

ini 复制代码
const myname = "hello world";
const age = 15;
console.log("yes");

转化后的代码:

ini 复制代码
const myname = window.i18n("1681117167429", "hello world");
const age = 15;
console.log(window.i18n("1681117167430", "yes"));

转化前的 AST:

转化后的 AST:

根据 Babel对AST结构的定义 和可视化树形结构 AST Explorer 对比前后的 AST,只需要将 type: StringLiteral 的 AST 节点替换为 type: CallExpression的AST 节点

less 复制代码
// myplugin.js
const { declare } = require('@babel/helper-plugin-utils');

module.exports = declare(
   // types: @babel/types 封装的AST操作方法
  function({ types: t }) {
    return {
      visitor: {
        FunctionDeclaration() { /** */ },
        VariableDeclaration() { /** */},
         /**
             path: AST 传递给观察者的实例,Path是对AST节点增强的数据结构
             path.node AST节点
             path.parent: 当前节点父节点的Path
             path.replaceWith: 替换当前节点
             ...
         */
        StringLiteral(path) {
          // 替换当前节点
          path.replaceWith(
            // 创建CallExpression节点
            t.callExpression(
              // 第一个参数是 函数名 window.i18n
              t.memberExpression(
                t.identifier("window"),
                t.identifier("i18n")
              ),
              // 第二个参数 是函数对应的参数数组
              [
                t.stringLiteral(`${Date.now()}`),
                t.stringLiteral(path.node.value)
              ]
            )
          )
          path.skip();
        }
      }
    }
  }
)
json 复制代码
// babel.config.json
{
  "plugins": [
      "./myplugin.js"
  ]
}

// 执行 npx babel ./index.js --out-file build.js 即可编译

AST 在业务中实践

DMS 平台致力于为公司多种类型的存储组件提供统一、高效、安全的数据管理终端。

Mongo语法校验和提取:

  • 语法完整性校验: Mongo语句类似js语法,通过@babel/parser解析mongo语句判断语句完整与否

  • 提取运行语句:默认执行位于光标处的mongo语句,通过@babel/parser解析成AST,利用AST的loc提供的start和end的行号列号信息,获取当前光标处所处的AST节点,从而获取到执行的mongo语句

SQL 智能提示

早期的 DMS SQL 提示基于人工分析,没有完整的方法论支撑,对于复杂的 SELECT 语句无法解析,后经过调研,可以通过解析编辑器的 SQL 为AST 后进行分析,提供字段提示。

  1. 支持表名提示
  2. 支持表别名的识别
  3. 输入表源后,支持表对应的字段名提示(包括表别名)并且支持过滤。

经过技术调研后选择了 syntax-parser

  • 纯 js 实现的解析器构造器

  • 提供了部分 SQL 解析的产生式,可以复用

  • 内置了对光标的处理:对提示类功能比较友好

AST结构

Select语句的 AST 结构如下

typescript 复制代码
// 匹配到的Token基本单位
interface IToken {
  type: string;
  value: string;
  position?: [number, number];
}

interface ISelectStatement extends IStatement {
    // type: "statement" variant: "select"
    result: Field[], // select的字段
    from: IFrom,
    union: union
}

/** 字段相关结构 **/

interface IField extends IStatement {
    // type: "identifier" variant: "column"
    alias:  IToken | null; // 字段别名
    name: IToken | IGroupField | IFunction
}

// 表名.字段名
interface IGroupField extends IStatement {
    // type: "identifier" variant: "columnAfterGroup"
    name: IToken, // 字段名 token 信息
    groupName: IToken // 表名 token 信息
}

interface IFuncion extends IStatement {
    // type: "function"
    name: IToken, // 函数名的token 
    args: TODO, // 参数信息,结构比较异常,暂时用不到
}



/** 表源相关结构 **/

interface IFrom {
    sources: ISource[] // 表源
    where: any,
    // 第一个IToken匹配 group 第二个匹配 by IField匹配字段
    group: [IToken, IToken, [IField, null]],
    having: any, //
}

interface ISource extends IStatement {
    // type: "statement" variant: "tableSource"
    source: ITableField | ISelectStatement, // 表源来自 表名 或者 select语句
    joins: '',
}

interface ITableField extends IStatement {
    // type: "identifier" variant: "table"
    alias: IToken, // 表别名
    name: ITableInfo, // 表信息
}

interface ITableInfo extends IStatement {
    // type: "identifier" variant: "tableName"
    namespace: IToken, // 未知
    tableName: IToken,
    tableNames: [] // 未知
}

interface IJoin extends IStatement {
    // type: "statement" variant: "join"
    join: ITableField,
    conditions: any // 连接条件,嵌套的层级比较深,暂时用不到
}

select test| from table_test2 a; 解析生成 AST 如下

技术分层:

select test| from table_test2 a; 解析过程:

参考

相关推荐
YBN娜8 分钟前
Vue实现登录功能
前端·javascript·vue.js
阳光开朗大男孩 = ̄ω ̄=8 分钟前
CSS——选择器、PxCook软件、盒子模型
前端·javascript·css
minDuck13 分钟前
ruoyi-vue集成tianai-captcha验证码
java·前端·vue.js
小政爱学习!33 分钟前
封装axios、环境变量、api解耦、解决跨域、全局组件注入
开发语言·前端·javascript
魏大帅。38 分钟前
Axios 的 responseType 属性详解及 Blob 与 ArrayBuffer 解析
前端·javascript·ajax
花花鱼1 小时前
vue3 基于element-plus进行的一个可拖动改变导航与内容区域大小的简单方法
前端·javascript·elementui
k09331 小时前
sourceTree回滚版本到某次提交
开发语言·前端·javascript
EricWang13581 小时前
[OS] 项目三-2-proc.c: exit(int status)
服务器·c语言·前端
September_ning1 小时前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人1 小时前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架