来点小小的AST震撼

216 阅读13分钟

music.163.com/outchain/pl…

为什么是AST

和自然语言一样,计算机语言也是由语法和具体值组成的。我们在中学阶段都学习过句子的组成(主语、谓语、宾语),当我们想对句子进行语句分析,往往会把句子拆分成主谓宾三个部分,然后分别对这些部分里的值以及他们之间的关系来句子解析。除此之外,还需要对句子中的标点符号进行解析判断。

举个🌰:

解析一下:“我是阿威罗软蛋。”

如果我们只是简单的把这个句子通过正则匹配进行拆分,并平摊在一个数组中的话,会得到下面的内容

[
  { type: "主语", value: "我" },
  { type: "谓语", value: "是" },
  { type: "宾语", value: "阿威罗软蛋" },
  { type: "标点符号", value: "。" },
]

显然,我们通过拆分语句吧这个句子的主谓宾全部拆分了出来,在这个🌰中,我们能够完成想完成的工作(句子校验、语句提炼),但如果把这个🌰复杂化:“她说我是阿威罗软蛋”,按照上面的解析方式,我们将会获得:

[
  { type: "主语", value: "她" },
  { type: "谓语", value: "说" },
  { type: "主语", value: "我" },
  { type: "谓语", value: "是" },
  { type: "宾语", value: "阿威罗软蛋" },
  { type: "标点符号", value: "。" },
]

按照上述拆分结果,不难看出这是个病句(明显缺少一个宾语),但实际上,这个句子不存在实质上的问题(如果你也认为阿威罗软蛋是一个宾语)。

上述解析方法存在一个巨大的问题:缺少对上下文状态的保存。由于这些上下文在句子解析中非常重要,要在计算机语言中实现这样的解析方式,就需要考虑保存上下文内容,而不是简单的使用正则匹配来进行判断解析。显然如果我们仅仅用上述解析结果(转化为若干节点,并收集进入一个数组进行集中管理)来作为后续转译的根基的话,是无法完成关系校验等工作的,究其根本是因为我们现在选择的数据结构(一维数组)过于扁平化,没办法清晰的表达上下文内容。

需要保存自己的信息,同时还要能够查看上下文的信息,树,这个数据结构便脱引而出成为我们构造中间件的首选数据结构。没人比树节点更懂访问自己的老爹、兄弟和儿子。(双向链表:??)

image.png 在JS中,我们选择AST(Abstract Syntax Tree)作为解决方案,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。在代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码错误提示、代码自动补全等等场景均有广泛的应用。回到刚才的🌰中,把解析后的代码进行转译

 {
  type: "语句",
  body: {
    type: "陈述句",
    weiyu:{
      value:'说'
    }
    zhuYu: {
      type: "person",
      value: "她",
    },
    binYu: {
      type: "语句",
      body: {
        type:"陈述句",
        weiyu:{
          value:"是"
        }
        zhuyu:{
          type:"person",
          value:"我"
        },
        binYu:{
          type:"snack",
          value:"阿威罗软蛋"
        }
      }
    }
  },
};

编译流程(code->AST->code)

说了那么多,那AST是如何帮助编译器进行代码语法检查、高亮、错误提示等操作的呢?

一个完整的编译器整体执行过程可以分为三个步骤:

  • Parsing(解析过程) :这个过程包括词法分析->语法分析->构建AST(抽象语法树);
  • Transformation(转化过程):这个过程就是将上一步解析后的内容AST,按照编译器指定的规则进行处理,形成一个新的AST。
  • Code Generation(代码生成):将上一步处理后的AST再转换为新的代码;

换句话来说,AST是代码内容的一个信息摘要,根据语法将代码内容进行抽离,而编译器就是根据这样一个信息摘要,对摘要进行相应操作后,再最后转译为新的代码。

Parsing

解析过程包括两个过程:词法分析语法分析

词法分析(code->tokens)

词法分析是通过tokenizer(分词器) 将code拆分成token流,同样的,我们像之前拆分“我是阿威路软蛋!”一样拆分一下以下代码

let test = function(){
  console.log('hello world')
}
[
  { type:'Program',kind:'let'},
  { type:'Identifier',value:'test'},
  { type:'FunctionExpression',optional:false},
  { type:'BlockStatement'},
  { type:'CallExpression'},
  { type:'Identifier',name:'console'},
  { type:'Identifier',name:'log'},
  { type:'Literal',value:'hello world'}
]

将代码拆分成tokens,实际上并没有完成我们希望的工作,tokens流仅仅表示了原代码的所有要素信息,但每个要素的关系其实是缺失的,所以我们还需要对tokens进行进一步加工。

语法分析(tokens->AST)

获得tokens后发现这好像元素间缺少关联,语法分析就完成了这一工作,将tokens整理成相互关联的语法表达形式。

还是上述🌰

let test = function(){
  console.log('hello world')
}

把这个🌰进行解析,能得到下面的“树”结构👇:

{
  "type": "Program",
  "start": 0,
  "end": 232,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 179,
      "end": 232,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 183,
          "end": 232,
          "id": {
            "type": "Identifier",
            "start": 183,
            "end": 187,
            "name": "test"
          },
          "init": {
            "type": "FunctionExpression",
            "start": 190,
            "end": 232,
            "id": null,
            "expression": false,
            "generator": false,
            "async": false,
            "params": [],
            "body": {
              "type": "BlockStatement",
              "start": 200,
              "end": 232,
              "body": [
                {
                  "type": "ExpressionStatement",
                  "start": 204,
                  "end": 230,
                  "expression": {
                    "type": "CallExpression",
                    "start": 204,
                    "end": 230,
                    "callee": {
                      "type": "MemberExpression",
                      "start": 204,
                      "end": 215,
                      "object": {
                        "type": "Identifier",
                        "start": 204,
                        "end": 211,
                        "name": "console"
                      },
                      "property": {
                        "type": "Identifier",
                        "start": 212,
                        "end": 215,
                        "name": "log"
                      },
                      "computed": false,
                      "optional": false
                    },
                    "arguments": [
                      {
                        "type": "Literal",
                        "start": 216,
                        "end": 229,
                        "value": "hello world",
                        "raw": "'hello world'"
                      }
                    ],
                    "optional": false
                  }
                }
              ]
            }
          }
        }
      ],
      "kind": "let"
    }
  ],
  "sourceType": "module"
}

仔细整理,不难发现生成的AST都具有自己的类型描述(type),同时也有指向其子节点的指针(具体表现为数组)

常见的AST节点类型(type)
类型名称中文名称描述
Program代码主体程序段:包含start、end、body
VariableDeclaration变量声明变量声明:包含start、end、kind(let、var、const)、declartion(声明内容)
FunctionDeclaration函数声明声明一个函数(function):包含start、end、id、params等
FunctionExpression函数表达式example: const fn = function(){ }
ArrowFunctionExpression箭头函数表达式example: const fn = () => { ]
AwaitExpressionawait函数表达式example: const fn = await f()
ObjectMethod在对象中定义的方法example: const obj = { function () { } }
BlockStatement块函数段包裹在 {} 块内的代码,包含start、end、body等
ExpressionStatement表达式函数调用(如console.warn()):包含start、end、expression等
Identifier标识符example: const a = 1中,a就是Identifier

AST在线转换工具

Transformation

这个过程主要是改写AST,或者根据当前AST生成一个新的AST,也就是说、我们需要对这“信息摘要”进行读取并改写。即将经历遍历(traversal)和访问器(visitor)访问节点这两个步骤。

traversal

由于AST是一个类树结构(至少在我们心中它更像一棵树而不是链表),遍历(traversal)便成为我们读取信息的第一方案,在这里我们采用深度优先遍历来对AST进行遍历。(思考:为什么不优先使用广度优先遍历来遍历AST呢)

const traversal = (AST) => {
  if(AST === null) return
  AST.body.foreach(body => traversal(body))
  //对该节点进行访问操作
}
visitor

访问器是一个“访问器”对象,这个对象可以处理不同类型的节点函数。

const visitor = {
  LiteralVistor(currentNode,parentNode){}, //visit LiteralNode
  ExpressionVistor(currentNode,parentNode){} //visit ExpressionNode
}

在遍历过程中进入该节点时,我们希望调用访问器对该节点进行访问操作(enter),此时需要传入当前节点信息和父节点信息。同时在离开的时候我们也希望能够调用访问器进行退出操作(exit),需要注意的是,由于我们遍历的时候遵循深度优先原则,所以我们将会依次遍历到最外层,然后依次进行递归的访问直到抵达根节点,此时再依次退出节点(由内向外)

const visitor = {
  LiteralVistor:{
    enter(currentNode,parentNode){}, // excute when get access 
    exit(currentNode,parentNode){}   // excute when finish visit
  }, //visit LiteralNode
  ExpressionVistor:{
    enter(currentNode,parentNode){}, // excute when get access 
    exit(currentNode,parentNode){}   // excute when finish visit
  }//visit ExpressionNode
}

Code Generation

最后就是代码生成阶段了,其实就是将生成的新AST树再转译为代码的过程。大部分的代码生成器主要过程是,不断的访问Transformation生成的AST(抽象语法树)或者再结合tokens,按照指定的规则,将“树”上的节点打印拼接最终还原为新的code,自此编译器的执行过程就结束了。

一个“小小”的实现

解决问题

前情提要:

假如你是一名普普通通的前端开发者,在回顾你的同事“没戏”的代码的时候发现他在发起网络请求的时候都没有进行错误捕捉,而“没戏”现在正好在休假,所以在被他当场气晕的同时,你还需要帮他完成错误捕捉的工作。项目里面的网络请求很多(假设都是用async和await实现的),你觉得一个一个去找并添加try...catch非常费劲,所以聪明的你打算写一个小小的脚本来完成这件事情。(虽然一般工程中不会这么搞)

问题分析:

我们可以通过修改AST的内容来实现try...catch的自动添加,由于async函数与try函数比较特殊,在FunctionDeclaration中存在特殊标记的字段,在这里就简化了我们定位的问题,先把二者的AST对比研究一下:

async function fn(){
  await f()
}

image.png

async function fn() {
    try {
        await f()
    } catch (e) {
        console.warn(e)
    }
}

image.png

image.png 对比之下不难看出,我们需要做的工作就是将async函数段的body中的ExpressionStatement放到TryStatement的block的body中去。

插件初始化:

我们通过babel.type拿到types对象,进而控制AST,同时,定义了一个访问者,可以设置需要访问的节点类型,当访问到目标节点后,做相应的处理来实现插件的功能

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       // 设置需要范围的节点类型
       CallExression: (path, state) => { 
         //对AST进行操作
       }
     }
   }
 }

获取async await节点

我们可以通过AwaitExpression函数来获取Await节点

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       // 设置AwaitExpression
       AwaitExpression(path) {
         // 获取当前的await节点
         let node = path.node;
       }
     }
   }
 }

然后使用findParent再向上对async节点进行查找,先前提到,async函数有标记字段,所以我们只需要对async字段进行匹配即可(true为async函数)

const asyncPath = path.findParent(p => p.node.async)

但需要注意的是,声明async函数的方式不尽相同,其对应的AST也不相同,所以我们需要对所有async函数声明进行适配

//函数式声明
async function fn (){ await f () }
//函数表达式声明
const fn = async function(){ await f () }
//箭头函数声明
const fn = async () =>{ await f () }
//声明在对象中
const obj = {
  async fn() {await f() }
}

函数式声明

image.png 函数表达式声明

image.png

箭头函数声明

image.png

对象中声明

image.png

所以我们需要对上述这些情况分别进行判断处理

const asyncPath = path.findParent((p) => {
  p.node.async && (p.isFunctionDeclartion || p.isFunctionExpression || p.isObjectExpression || p.isArrowFunctionExpression
})
module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       // 设置AwaitExpression
       AwaitExpression(path) {
         // 获取当前的await节点
         let node = path.node;
         // 获取当前await节点对应的父async节点
       	 const asyncPath = path.findParent((p) => {p.node.async && (p.isFunctionDeclartion || p.isFunctionExpression || p.isObjectExpression || p.isArrowFunctionExpression})
       	}
    	}
   }
 }

将async节点的body内容替换成try

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       AwaitExpression(path) {
         let node = path.node;
         const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
         // TODO:构造一个tryNode
         let tryNode ;
         // 获取父节点的函数体body
         let info = asyncPath.node.body;

         // 将函数体放到try语句的body中
         tryNode.block.body.push(...info.body);

         // 将父节点的body替换成新创建的try语句
         info.body = [tryNode];
       }
     }
   }
 }

至此我们基本上完成了所有的工作:定位async与await,将await函数(async的函数体)放到tryNode中,并将tryNode替换到async的函数体,接下来我们需要构造一个tryNode

生成一个tryNode

完事具备只差tryNode,这里我们引入babel-template来完成模版的编写

// 引入babel-template
const template = require('babel-template');

// 定义try/catch语句模板
let tryCatchTemplate = `
try {
} catch (e) {
console.warn(catchError:e)
}`;

// 创建模板
const temp = template(tryTemplate);

// 给模版增加key,添加console.warn打印信息
let argumentObj = {
   // 通过types.stringLiteral创建字符串字面量
   catchError: types.stringLiteral('Error')
};

// 通过temp创建try语句的AST节点
let tryNode = temp(tempArgumentObj);

小小的集成一下

// 引入babel-template
const template = require('babel-template');

// 定义try/catch语句模板
let tryCatchTemplate = `
try {
} catch (e) {
console.warn(catchError:e)
}`;

// 创建模板
const temp = template(tryTemplate);

let argumentObj = {
   catchError: types.stringLiteral('Error')
};

// 通过temp创建try语句的AST节点
let tryNode = temp(tempArgumentObj);

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       AwaitExpression(path) {
         let node = path.node;
         const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
         // TODO:构造一个tryNode
         let tryNode ;
         // 获取父节点的函数体body
         let info = asyncPath.node.body;

         // 将函数体放到try语句的body中
         tryNode.block.body.push(...info.body);

         // 将父节点的body替换成新创建的try语句
         info.body = [tryNode];
       }
     }
   }
 }

借助Babel干点啥

babel是啥

Babel其实就是一个最常用的Javascript编译器,它能够转译 ES6+的代码,使它在旧的浏览器或者环境中也能够运行,工作过程分为三个部分:Parsing -> Transformation -> Code Generation,也就是说babel内部完成了我们之前的全部工作。

  • @babel/parser 可以把源码转换成AST
  • @babel/traverse 用于对 AST 的遍历,维护了整棵树的状态,并且负责替换、移除和添加节点
  • @babel/generate 可以把AST生成源码,同时生成sourcemap
  • @babel/types 用于 AST 节点的 Lodash 式工具库, 它包含了构造、验证以及变换 AST 节点的方法,对编写处理 AST 逻辑非常有用
  • @babel/core Babel 的编译器,核心 API 都在这里面,比如常见的 transform、parse,并实现了插件功能

来个简单的活

我们先解决一个'hello world'的🌰:

//把const hello = 'hello world'转译为 const world = 'hello world'

还是先查看一下这两者的AST区别吧:

前者:

image.png 后者:

image.png

也就是说需要把Identifier中的name进行替换即可

const parser = require("@babel/parser");
const traverse = require("@babel/traverse");
const generator = require("@babel/generator");

// 源代码
const code = `
const hello = 'hello world';
`;

// 源代码解析成 ast
const ast = parser.parse(code);

const visitor = {
  // traverse 会遍历树节点,只要节点的 type 在 visitor 对象中出现,变化调用该方法
  Identifier(path) {
    const { node } = path; //从path中解析出当前 AST 节点
    if (node.name === "hello") {
      node.name = "world"; //找到hello的节点,替换成world
    }
  }
};
traverse.default(ast, visitor);

const result = generator.default(ast, {}, code);

console.log(result.code); //const world = 'hello world';

回到前情提要

那我们再尝试对前情提要的问题使用babel插件进行解决

const parser = require("@babel/parser");
const traverse = require("@babel/traverse");
const generator = require("@babel/generator");
const template = require('babel-template');

// 定义try/catch语句模板
let tryCatchTemplate = `
try {
} catch (e) {
console.warn(catchError:e)
}`;

// 创建模板
const temp = template(tryTemplate);

let argumentObj = {
   catchError: types.stringLiteral('Error')
};

// 通过temp创建try语句的AST节点
let tryNode = temp(tempArgumentObj);

// 源代码
const code = `
async function fn(){
  await f()
}
`;

//  源代码解析成 ast
const ast = parser.parse(code);

const visitor = {
  // traverse 会遍历树节点,只要节点的 type 在 visitor 对象中出现便调用
 AwaitExpression(path) {
         let node = path.node;
         const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
         // TODO:构造一个tryNode
         let tryNode ;
         // 获取父节点的函数体body
         let info = asyncPath.node.body;

         // 将函数体放到try语句的body中
         tryNode.block.body.push(...info.body);

         // 将父节点的body替换成新创建的try语句
         info.body = [tryNode];
  }
};

traverse.default(ast, visitor);

const result = generator.default(ast, {}, code);

console.log(result.code); 
//async function fn() {
//    try {
//        await f()
//    } catch (e) {
//        console.warn(e)
//    }
//};

参考文章: juejin.cn/post/715515…