让我们把JS再向下拆解一层

抽象语法树是什么?

抽象语法树(Abstract Syntax Tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。各种编程语言都有抽象语法树,本文的抽象语法树特指JS的抽象语法树。借用网上流传甚广的一幅图说明一下JS的抽象语法树:

虽然我们不直接使用抽象语法树,可是我们使用的许多工具,都用到了抽象语法树。比如说Webpack,Babel,UglifyJS2,rollup,ESLint,IDE等。如果你想了解这些工具的工作原理或者也想写一个类似的工具,那么你需要了解一下抽象语法树相关的知识。

AST是如何生成的?

众所周知,js是解释型语言,边解释边执行。可是JavaScript 引擎解释的是什么呢? 没错,就是今天我们要讨论的主题-抽象语法树。抽象语法树产生的过程是:字符流 -> 词法分析 -> 语法分析 -> 语法树。我们通过下面的例子, 看看每个阶段的产物:

js 复制代码
const test="hello,ast!";

词法分析(Lexical Analysis)

词法分析是把字符流(char stream)转换为记号流(token stream),将字符串组成的字符分解成有意义的代码块,这些代码块被称为词法单元。JS源代码字符流可以分为下面几类:

  • 空白字符(WhiteSpace) 包括缩进符号,分页符,非断行空格,零宽非断行空格,普通空格等。
  • 换行符(LineTerminator) 包含ASCII和Unicode中的各种换行符
  • 注释(Comment) 行注释或块注释
  • 词法单元(Token)

其中Token又可分以下几类:

Token分类 示例
标识符名称-IdentifierName 包含变量名,js关键字等
符号-Punctuator +,-,*,/ 运算符,大括号等
数字直接量-NumbericLiteral 就是代码中的数字
字符串直接量-StringLiteral 用单引号或双引号括起来的内容
字符串模板-Template 用反括号括起来的内容

想进一步了解JS源代码字符流每种分类的详细内容,可阅读此文。 在esprima网站,输入一段js代码,可以实时查看解析出来的词法分析结果。const test="hello,ast!";这句的词法分析结果如下:

json 复制代码
[
    {
        "type": "Keyword",
        "value": "const"
    },
    {
        "type": "Identifier",
        "value": "test"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "String",
        "value": "\"hello,ast!\""
    }
]

从中可以看出,空白字符,换行,注释都被丢弃。还有一点要说明一下,上面我们归类中的标识符名称(IndentifierName)可以是ldentifier,Nullliteral,BooleanLiteral 或者 Keyword,当不是Nullliteral,BooleanLiteral 或者 Keyword的时候,IndentifierName 就会被解析成ldentifier,否则会分别解析成Null,Boolean,Keyword类型。

语法分析(Syntax Analysis)

语法分析的过程就是把词法分析所产生的记号生成语法树,从结果看,就是把从程序中收集的信息存储到数据结构中。词法分析和语法分析不是完全独立的,而是交错进行,也就是说,词法分析器不会在读取所有的词法记号后再使用语法分析器来处理。在通常情况下,每取得一个词法记号,就将其送入语法分析器进行分析。

const test="hello,ast!";这句经过语法分析生成的语法树结果如下:

json 复制代码
{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "test"
          },
          "init": {
            "type": "Literal",
            "value": "hello,ast!",
            "raw": "\"hello,ast!\""
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

JS的抽象语法树都遵循ESTree规范,ESTree规范如何解读,请查看此文的后半部分。 这里仅列举一下上面例子中用到ast的一些type含义。

类型 含义
Program 程序主题,整段代码的主体
VariableDeclaration 变量声明,例如var、let、 const
FunctionDeclaration 函数声明,例如如function
ldentifier 标识符,例如申明变量时 const name = 'tank'; 中的 name
Literal 字面量,通常指字符串型的字面量
BinaryExpression 二进制表达式,例如1+1
BockStatement 块语句,包裹在块内的代码
ReturnStatement 返回语句,通常指return
CallExpression 调用表达式,通常指调用一个函数
MemberExpression 成员表达式,通常指调用对象的成员,例如console对象到的log成员

使用AST如何修改源代码?

使用ast修改源代码分为三步实现: parse(代码解析)、transform(代码转换)、generate(代码生成)。

  1. 用社区成熟的工具(参见下面的JS解析器章节) ,用JS解析器 parse 源代码,经过词法分析、语法分析生成ast,就好比把机器拆解成零件。
  2. 用社区成熟的工具对ast进行分析与变换,就好比按照机器图纸,对拆开的零部件,进行重新加工改造,transform 这一步是重点,需要开发者自己实现。
  3. 用社区成熟的工具,将第二步变换之后的AST,generate为最终的代码,就好比把加工改造过的机器零部件重新组装成机器。

解析器将js转换为ast,常见的js解析器有:

名称 说明
Esprima 第一个用JS编写的遵循ESTree规范的JS解析器
Acorn fork自Esprima,代码更精简,webpack使用的解析器
babel-parser fork自Acorn,babel使用的解析器
Espree ESLint默认的解析器
uglify-js 多用于代码混淆压缩

这个网站可以查询各种解析器转换之后的结果。Esprima 官网上有一个JS解析器性能测试页面, 在chrome中运行了一下,发现同样是解析jQuery.Mobile, Angular 和 React 代码,综合下来,耗时比较短的是Esprima<Acorn<UglifyJS2

动手练习

光说不练假把式,本来想实现一个代码压缩功能,原以为代码压缩功能就是去除多余的空格与换行,还有对函数名,变量名进行简短化处理。看完这篇文章,才发现还会对代码预解析,进行压缩与优化。对于初学者,写起来还是有点难度。我们挑一个难度稍低一点的功能,练练手。通过实现一个给函数中的console.log日志追加一个打印函数名的功能,熟悉一下ast的用法。从前面的js解析器性能测试我们知道,Esprima解析器的性能比较好,所以js解析器我们就选择它了。js解析器有了,根据上一章节讲到的用ast修改源代码三部曲,还差一个ast分析工具和一个将ast还原成代码的工具。经过查找,找到了estraverseescodegen, 从下面的表格可以看出,这三个工具之间有关联,要按照一定的顺序使用。

npm包 用途
esprima 将源代码解析抽象语法树
estraverse 遍历修改抽象语法树
escodegen 将抽象语法树内容转换成代码

现在安装一下这三个npm工具包。

bash 复制代码
 pnpm add esprima estraverse escodegen

实现思路是:通过一个简单的例子比如说改一下变量的名称,跑一下ast修改源代码的整个流程。然后再实现我们真正想实现的功能。先写一个声明语句,把esprima解析的抽象语法树打印出来看一下,查看ast对象的数据特征,用estraverse遍历这个ast对象,修改一下前面定义的变量名称,接着用escodegen工具把修改之后的ast语法树复原成代码,看看生成的代码变量名称是否已变更为刚刚修改的变量名称。

主流程文件index.js内容如下:

js 复制代码
import fs from "fs/promises";
import esprima from "esprima";
import estraverse from "estraverse";
import escodegen from "escodegen";

// 读取源代码
const code = await fs.readFile("./source.js", { encoding: "utf8" });

// 将源代码解析成抽象语法树
const ast = esprima.parseScript(code);
console.log(ast);
// 遍历抽象语法树修改内容
estraverse.traverse(ast, {
  enter: function (node) {
    if (node.name === "dream") {
      node.name = "work";
    }
  },
});

// 将抽象语法树内容转换成代码
const transformCode = escodegen.generate(ast);

// 将转换之后的代码写入目标文件
await fs.writeFile("./dest.js", transformCode);

主流程文件中引入的source.js内容如下:

js 复制代码
const dream="doctor";

执行node index.js, 可以看到在node终端控制台打印出来的信息不完整, 如下图红框所圈部分,打印时并不会展开显示。

要通过VSCode的Debug功能,才能看到AST的完整内容。

发现在调试模式下看到的内容与在esprima.org看到的内容完全吻合,利用这一点,后续我们可以在esprima官网上分析ast数据。

执行完之后,生成的dest.js的内容如下所示,符合预期。至此,我们已经跑通了用ast修改源代码的完整流程。

js 复制代码
const work = 'doctor';

现在我们实现一下给函数中的console.log语句添加打印函数名的功能,如果输入文件是这样的:

js 复制代码
const multiply = function (prev, cur) {
  console.log(prev, cur);
  return prev * cur;
};

function sum(prev, cur) {
  console.log(prev, cur);
  return prev + cur;
}

我们期望最后生成的文件是这样的:

js 复制代码
const multiply = function (prev, cur) {
    console.log('multiply', prev, cur);
    return prev * cur;
};
function sum(prev, cur) {
    console.log('sum', prev, cur);
    return prev + cur;
}

通过在esprima.org网站上观察生成的ast对象的特点

找到了要修改的节点是:

要添加的打印参数值的获取节点是:

根据上面的观察,经过一番调试和对箭头函数表达式,立即执行表达式中函数声明和表达式这两种场景的完善,最终主流程文件内容修改如下:

js 复制代码
import fs from "fs/promises";
import esprima from "esprima";
import estraverse from "estraverse";
import escodegen from "escodegen";

// 读取源代码
const code = await fs.readFile("./source.js", { encoding: "utf8" });

// 将源代码解析成抽象语法树
const ast = esprima.parseScript(code);

// 遍历抽象语法树修改内容
estraverse.traverse(ast, {
  enter: function (node) {
    if (node.type === "Program") {
      node.body.forEach((nodeItem) => {
        // 给console.log打印加上函数名名称
        logAddFunName(nodeItem);
      });
    }
  },
});

// 将抽象语法树内容转换成代码
const transformCode = escodegen.generate(ast);

// 将转换之后的代码写入目标文件
await fs.writeFile("./dest.js", transformCode);

/**
 * 给函数中的console.log添加函数名
 * @param {*} node
 */
function logAddFunName(node) {
  // 正常定义的函数声明和表达式
  if (["FunctionDeclaration", "VariableDeclaration"].includes(node.type)) {
    nodeType(node);
  }
  // 在立即执行函数中定义的函数声明和表达式
  else if (
    node.type === "ExpressionStatement" &&
    ["FunctionExpression", "ArrowFunctionExpression"].includes(node.expression.callee.type)
  ) {
    node.expression.callee.body.body.forEach((item) => {
      nodeType(item);
    });
  }
}
/**
 * 节点类型判断
 * @param {*} item
 */
function nodeType(item) {
  // 函数声明
  if (item.type === "FunctionDeclaration") {
    addFunName(item.body.body, item.id.name);
  }
  // 函数表达式--普通函数+箭头函数
  else if (item.type === "VariableDeclaration") {
    item.declarations.forEach((item) => {
      if (["ArrowFunctionExpression", "FunctionExpression"].includes(item.init.type)) {
        addFunName(item.init.body.body, item.id.name);
      }
    });
  }
}
/**
 * 找到抽象语法树中的console.log参数,追加函数名称打印参数
 * @param {*} nodeArr 表达式节点
 * @param {*} funName 函数名称
 */
function addFunName(nodeArr, funName) {
  nodeArr.forEach((item) => {
    if (item.type === "ExpressionStatement" && item.expression.callee.object.name === "console") {
      item.expression.arguments.unshift({
        type: "Literal",
        value: `${funName}`,
        raw: `${funName}`,
      });
    }
  });
}

运行效果如下:

js 复制代码
const multiply = function (prev, cur) {
    console.log('multiply', prev, cur);
    return prev * cur;
};
function sum(prev, cur) {
    console.log('sum', prev, cur);
    return prev + cur;
}
const divide = (prev, cur) => {
    console.log('divide', prev, cur);
    return prev / cur;
};

(() => {
    function subtract1(prev, cur) {
        console.log('subtract1', prev, cur);
        return prev - cur;
    }
    const subtract2 = (prev, cur) => {
        console.log('subtract2', prev, cur);
        return prev - cur;
    };
    subtract1();
    subtract2();
})();

本文的练习代码已经上传到码云,你可以点击这里下载学习。写完本文我有一个感悟,人对于自己不太了解,不太熟悉,不太擅长的事情,提不起任何兴趣,一看到,一听到,一说起就犯困。对于这一点可以反向合理利用一下,把这些事情当做一个学习方向导航,按图索骥,一点一点扩大自己的技术视野。

参考链接

相关推荐
y先森3 小时前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy3 小时前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu10830189113 小时前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿5 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡6 小时前
commitlint校验git提交信息
前端
虾球xz6 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇6 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒6 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员6 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐7 小时前
前端图像处理(一)
前端