让我们把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();
})();

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

参考链接

相关推荐
Jiaberrr3 分钟前
Vite环境下uniapp Vue 3项目添加和使用环境变量的完整指南
前端·javascript·vue.js·uni-app
Marry1.011 分钟前
uniapp背景图用本地图片
前端·uni-app
夏河始溢17 分钟前
一七八、Node.js PM2使用介绍
前端·javascript·node.js·pm2
记忆深处的声音17 分钟前
vue2 + Element-ui 二次封装 Table 组件,打造通用业务表格
前端·vue.js·代码规范
陈随易18 分钟前
兔小巢收费引发的论坛调研Node和Deno有感
前端·后端·程序员
熊的猫33 分钟前
webpack 核心模块 — loader & plugins
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
速盾cdn40 分钟前
速盾:vue的cdn是干嘛的?
服务器·前端·网络
四喜花露水1 小时前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy1 小时前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie2 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript