【你应该了解的】抽象语法树AST

团队:skFeTeam  本文做者:李世伟html

做为前端程序员,webpack,rollup,babel,eslint这些是否是常常用到?他们是打包工具,代码编译工具,语法检查工具。他们是如何实现的呢?本文介绍的抽象语法树,就是他们用到的技术,是否是应该了解一下呢?前端

本文没有晦涩难懂的理论,也没有大段大段的代码,彻底从零开始,小白阅读也无任何障碍。经过本文的阅读,您将会了解AST的基本原理以及使用方法。node

前言

什么是抽象语法树?webpack

  • AST(Abstract Syntax Tree)是源代码的抽象语法结构树状表现形式。下面这张图示意了一段JavaScript代码的抽象语法树的表现形式。

0.什么是抽象语法树.png

抽象语法树有什么用呢?git

  • IDE的错误提示、代码格式化、代码高亮、代码自动补全等
  • JSLint、JSHint、ESLint对代码错误或风格的检查等
  • webpack、rollup进行代码打包等
  • Babel 转换 ES6 到 ES5 语法
  • 注入代码统计单元测试覆盖率

目录

  • 1.AST解析器
  • 2.AST in Babel
  • 3.Demo with esprima
  • 4.思考题

1.AST解析器

1.1 JS Parser解析器

AST是如何生成的?程序员

  • 可以将JavaScript源码转化为抽象语法树(AST)的工具叫作JS Parser解析器。

JS Parser的解析过程包括两部分github

  • 词法分析(Lexical Analysis):将整个代码字符串分割成最小语法单元数组
  • 语法分析(Syntax Analysis):在分词基础上创建分析语法单元之间的关系

1.JS_Parser的解析过程.png

常见的AST parserweb

  • 早期有uglifyjs和esprima
  • Espree,基于esprima,用于eslint
  • Acorn,号称是相对于esprima性能更优,体积更小
  • Babylon,出自acorn,用于babel
  • Babel-eslint,babel团队维护,用于配合使用ESLint

1.2 词法分析(Lexical Analysis)

语法单元是被解析语法当中具有实际意义的最小单元,简单的来理解就是天然语言中的词语。chrome

Javascript 代码中的语法单元主要包括如下这么几种:express

  • 关键字:例如 var、let、const等
  • 标识符:没有被引号括起来的连续字符,多是一个变量,也多是 if、else 这些关键字,又或者是 true、false 这些内置常量
  • 运算符: +、-、 *、/ 等
  • 数字:像十六进制,十进制,八进制以及科学表达式等
  • 字符串:由于对计算机而言,字符串的内容会参与计算或显示
  • 空格:连续的空格,换行,缩进等
  • 注释:行注释或块注释都是一个不可拆分的最小语法单元
  • 其余:大括号、小括号、分号、冒号等

1.3 语法分析(Syntax Analysis)

组合分词的结果,肯定词语之间的关系,肯定词语最终的表达含义,生成抽象语法树。

1.4 示例

  • 以赋值语句为例,使用esprima来解析:
var a = 1;
复制代码
  • 词法分析结果以下,能够看到,分词的结果是一个数组,每个元素都是一个最小的语法单元:
[
    {
        "type": "Keyword",
        "value": "var"
    },
    {
        "type": "Identifier",
        "value": "a"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Numeric",
        "value": "1"
    },
    {
        "type": "Punctuator",
        "value": ";"
    }
]
复制代码
  • 语法分析结果以下,把分词的结果按照相互的关系组成一个树形结构:
{
    "type": "Program",
    "body": [
        {
            "type": "VariableDeclaration",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "a"
                    },
                    "init": {
                        "type": "Literal",
                        "value": 1,
                        "raw": "1"
                    }
                }
            ],
            "kind": "var"
        }
    ],
    "sourceType": "script"
}
复制代码

1.5 工具网站

esprima/parser

  • 经典的JavaScript抽象语法树解析器,网站提供的功能也很是丰富
  • 能够在线查看分词和抽象语法树
  • Syntax展现抽象语法树,Tokens展现分词
    1.esprima-parser.png
  • 还提供了各类parse的性能比较,看起来Acorn的性能更优秀一点。
    1.各类parse的性能比较.png

AST Explorer

  • AST的可视化工具网站,可使用各类parse对代码进行AST转换
    1.AST-Explorer可视化工具.png

AST解析规范(The Estree Spec

  • 相同的JavaScript代码,经过各类parser解析的AST结果都是同样的,这是由于他们都参照了一样的AST解析规范
  • The Estree Spec 规范是 Mozilla 的工程师给出的 SpiderMonkey 引擎输出的 JavaScript AST 的规范文档,也能够参考:SpiderMonkey in MDN

2.AST in Babel

前面已经介绍了AST的内容,下面咱们来看看babel是如何使用AST的。

Babel的运行原理

Babel的工做过程通过三个阶段,parse、transform、generate

  • parse阶段,将源代码转换为AST
  • transform阶段,利用各类插件进行代码转换
  • generator阶段,再利用代码生成工具,将AST转换成代码

2.Babel的运行原理.png

Parse-解析

  • Babel 使用 @babel/parser 解析代码,输入的 js 代码字符串根据 ESTree 规范生成 AST
  • Babel 使用的解析器是 babylon

Transform-转换

  • 接收 AST 并对其进行遍历,在此过程当中对节点进行添加、更新及移除等操做。也是Babel插件接入工做的部分。
  • Babel提供了@babel/traverse(遍历)方法维护AST树的总体状态,方法的参数为原始AST和自定义的转换规则,返回结果为转换后的AST。

Generator-生成

  • 代码生成步骤把最终(通过一系列转换以后)的 AST 转换成字符串形式的代码,同时还会建立源码映射(source maps)。
  • 遍历整个 AST,而后构建能够表示转换后代码的字符串。
  • Babel使用 @babel/generator 将修改后的 AST 转换成代码,生成过程能够对是否压缩以及是否删除注释等进行配置,而且支持 sourceMap。

3.Demo with esprima

了解了babel的运行原理,咱们根据babel的三个步骤来动手写一个demo,加深对AST的理解。

咱们准备使用esprima来模拟两个代码转换的功能:
  • 把 == 改成全等 ===
  • 把parseInt(a) 改成 parseInt(a,10)

转换前的代码,before.js:

function fun1(opt) {
  if (opt.status == 1) {
      console.log('1');
  }
}
function fun2(age) {
  if (parseInt(age) >= 18) {
      console.log('2');
  }
}
复制代码

指望转换后的代码,after.js:

function fun1(opt) {
    if (opt.status === 1) {//==变成===
        console.log('1');
    }
}
function fun2(age) {
    if (parseInt(age, 10) >= 18) {//parseInt(a)变成parseInt(a,10)
        console.log('2');
    }
}
复制代码
  1. 开始动手,先引入工具包
//引入工具包
const esprima = require('esprima');//JS语法树模块
const estraverse = require('estraverse');//JS语法树遍历各节点
const escodegen = require('escodegen');//JS语法树反编译模块
const fs = require('fs');//读写文件
复制代码
  1. 使用esprima parse把源代码转换成AST。怎么样,是否是很简单,一句代码就搞定了。
const before = fs.readFileSync('./before.js', 'utf8');
const ast = esprima.parseScript(before);
复制代码
  1. 遍历AST,找到符合转换规则的代码进行转换
estraverse.traverse(ast, {
  enter: (node) => {
    toEqual(node);//把 == 改成全等 ===
    setParseInt(node); //把 parseInt(a) 改成 parseInt(a,10)
  }
});
复制代码
  1. 再来看看toEqual和setParseInt函数的实现
function toEqual(node) {
  if (node.operator === '==') {
    node.operator = '===';
  }
}

function setParseInt(node) {
  //判断节点类型,方法名称,方法的参数的数量,数量为1就增长第二个参数
  if (node.type === 'CallExpression' && node.callee.name === 'parseInt' && node.arguments.length === 1) {
    node.arguments.push({//增长参数,其实就是数组操做
      "type": "Literal",
      "value": 10,
      "raw": "10"
    });
  }
}
复制代码
  1. 最后,把转换后的AST生成字符串代码,写入文件。
//生成目标代码
const code = escodegen.generate(ast);
//写入文件
fs.existsSync('./after.js') && fs.unlinkSync('./after.js');
fs.writeFileSync('./after.js', code, 'utf8');
复制代码

好了,打开after.js文件看看,是否是已经转换成功了?是否是和咱们指望的同样?有没有一种babel的感受?是的,其实babel也是这么作的,只不过它的转换规则函数至关的复杂,由于须要考虑各类JavaScript的语法状况,工做量巨大,这也就是babel最核心的地方。

再回头看看咱们写的demo,是彻底遵循babel的三个步骤来作的。第一步parse和第三步generate都很是简单,一句话的事,没什么好说的。重点是Transform,转换规则函数的实现,有人可能会问,你怎么知道,toEqual和setParseInt转换函数要这么写呢?

好的,为了回答这个问题,咱们来看看这两个规则的代码转换先后的AST就明白了。

  • 把 == 改成全等 ===

a==b的AST以下:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "BinaryExpression",
                "operator": "==",
                "left": {
                    "type": "Identifier",
                    "name": "a"
                },
                "right": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}
复制代码

a===b的AST以下:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "BinaryExpression",
                "operator": "===",
                "left": {
                    "type": "Identifier",
                    "name": "a"
                },
                "right": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}
复制代码

比较上面两个AST,是否是只有一个"operator"字段有区别,一个是==, 另外一个是===。

再来看看toEqual函数,是否是明白了?只要修改一下node.operator的值就能完成转换了。

function toEqual(node) {
  if (node.operator === '==') {
    node.operator = '===';
  }
}
复制代码
  • 把parseInt(a) 改成 parseInt(a,10)

parseInt(a)的AST以下:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": {
                    "type": "Identifier",
                    "name": "parseInt"
                },
                "arguments": [
                    {
                        "type": "Identifier",
                        "name": "a"
                    }
                ]
            }
        }
    ],
    "sourceType": "script"
}
复制代码

parseInt(a, 10)的AST以下:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": {
                    "type": "Identifier",
                    "name": "parseInt"
                },
                "arguments": [
                    {
                        "type": "Identifier",
                        "name": "a"
                    },
                    {
                        "type": "Literal",
                        "value": 10,
                        "raw": "10"
                    }
                ]
            }
        }
    ],
    "sourceType": "script"
}
复制代码

比较这两个AST,看到了吗?只是arguments数组多了下面这个元素。

{
    "type": "Literal",
    "value": 10,
    "raw": "10"
}
复制代码

因此在转换规则函数中,咱们把这个元素加进去就能实现转换了。是否是很是简单?

function setParseInt(node) {
  //判断节点类型,方法名称,方法的参数的数量,数量为1就增长第二个参数
  if (node.type === 'CallExpression' && node.callee.name === 'parseInt' && node.arguments.length === 1) {
    node.arguments.push({//增长参数,其实就是数组操做
      "type": "Literal",
      "value": 10,
      "raw": "10"
    });
  }
}
复制代码

好了,到此为止,这个Demo应该彻底理解了吧。

4.思考题

看到这里,你已经明白了AST的原理以及使用方法。下面来看一道题目,检验一下学习成果。

假设a是一个对象,var a = { b : 1},那么a.b和a['b'] ,哪一个性能更高呢?

a.b和a['b']的写法,你们常常会用到,也许没有注意过这两种写法会有性能差别。事实上,有人作过测试,二者的性能差距不大,a.b会比a['b']性能稍微好一点。那么,为何a.b比a['b']性能稍微好一点呢?

我认为,a.b能够直接解析b为a的属性,而a['b']可能会多一个判断的过程,由于[]里面的内容多是一个变量,也多是个常量。

这种说法看起来好像颇有道理,事实上是否是这样呢?有没有什么证据来证实这个说法吗?

好吧,要想解释清楚这个问题,就只能从V8引擎提及了。

4.V8引擎.png

js代码能在cpu上运行,主要是js引擎的功劳,V8引擎是google开发,应用在chrome浏览器和nodejs上,是一个经典的js引擎。上图能够看出,在V8引擎中,js从源代码到机器码的转译主要有三个步骤:Parser(AST) ->Ignition(Bytecode)->TurboFan(Machine Code)

  • Parser:负责将JavaScript源码转换为Abstract Syntax Tree (AST)
  • Ignition:interpreter,即解释器,负责将AST转换为Bytecode,解释执行Bytecode;同时收集TurboFan优化编译所需的信息,好比函数参数的类型
  • TurboFan:compiler,即编译器,利用Ignitio所收集的类型信息,将Bytecode转换为优化的汇编代码

Parser-AST解析器

  • 你们应该很熟悉了,就是咱们今天介绍的AST解析器

Ignition-解释器

  • 把AST解析成一种类汇编语言,样子和汇编语言很类似,叫作Bytecode。这种语言和CPU无关,不一样cpu的机器上生成的Bytecode都是相同的。

TurboFan-编译器

  • 你们知道,每种cpu的架构和指令集是不一样的,对应的汇编语言会有差别。V8在这一步,针对不一样的cpu,把Bytecode解析成适合不一样cpu的汇编语言。V8能够支持十几种cpu的汇编语言。

4.V8引擎代码解析.png

如今,咱们就来比较一下a.b和a['b']在V8的解析下,到底有什么不一样

  • a.b的测试代码以下:
function test001() {
    var a = { b: 1 };
    console.log(a.b)
}
test001();
复制代码
  • a['b']的测试代码以下:
function test002() {
    var a = { b: 1 };
    console.log(a['b'])
}
test002();
复制代码

先看下他们生成的Bytecode

  • a.b的Bytecode代码以下:
[generated bytecode for function: test001]
Parameter count 1
Frame size 32
   16 E> 000001F6C03D7192 @    0 : a0                StackCheck 
   33 S> 000001F6C03D7193 @    1 : 79 00 00 29 fa    CreateObjectLiteral [0], [0], #41, r1
         000001F6C03D7198 @    6 : 27 fa fb          Mov r1, r0
   46 S> 000001F6C03D719B @    9 : 13 01 01          LdaGlobal [1], [1]
         000001F6C03D719E @   12 : 26 f9             Star r2
   54 E> 000001F6C03D71A0 @   14 : 28 f9 02 03       LdaNamedProperty r2, [2], [3]
         000001F6C03D71A4 @   18 : 26 fa             Star r1
   60 E> 000001F6C03D71A6 @   20 : 28 fb 03 05       LdaNamedProperty r0, [3], [5]
         000001F6C03D71AA @   24 : 26 f8             Star r3
   54 E> 000001F6C03D71AC @   26 : 57 fa f9 f8 07    CallProperty1 r1, r2, r3, [7]
         000001F6C03D71B1 @   31 : 0d                LdaUndefined 
   63 S> 000001F6C03D71B2 @   32 : a4                Return 
Constant pool (size = 4)
Handler Table (size = 0)
复制代码
  • a['b']的Bytecode代码以下:
[generated bytecode for function: test002]
Parameter count 1
Frame size 32
   16 E> 0000022E1C7D6DC2 @    0 : a0                StackCheck 
   33 S> 0000022E1C7D6DC3 @    1 : 79 00 00 29 fa    CreateObjectLiteral [0], [0], #41, r1
         0000022E1C7D6DC8 @    6 : 27 fa fb          Mov r1, r0
   46 S> 0000022E1C7D6DCB @    9 : 13 01 01          LdaGlobal [1], [1]
         0000022E1C7D6DCE @   12 : 26 f9             Star r2
   54 E> 0000022E1C7D6DD0 @   14 : 28 f9 02 03       LdaNamedProperty r2, [2], [3]
         0000022E1C7D6DD4 @   18 : 26 fa             Star r1
   59 E> 0000022E1C7D6DD6 @   20 : 28 fb 03 05       LdaNamedProperty r0, [3], [5]
         0000022E1C7D6DDA @   24 : 26 f8             Star r3
   54 E> 0000022E1C7D6DDC @   26 : 57 fa f9 f8 07    CallProperty1 r1, r2, r3, [7]
         0000022E1C7D6DE1 @   31 : 0d                LdaUndefined 
   66 S> 0000022E1C7D6DE2 @   32 : a4                Return 
Constant pool (size = 4)
Handler Table (size = 0)
复制代码

比较一下二者的Bytecode,你会发现它们彻底相同,这就说明,这两种写法在Bytecode层及如下的执行,性能是没有差异的。事实上,它们有差异,就只能往上找,上面就只有Parser阶段了。咱们再来看看它们的AST有什么区别。

  • a.b的AST代码以下:
{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "MemberExpression",
                "computed": false,
                "object": {
                    "type": "Identifier",
                    "name": "a"
                },
                "property": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}
复制代码
  • a['b']的AST代码以下:
{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "MemberExpression",
                "computed": true,
                "object": {
                    "type": "Identifier",
                    "name": "a"
                },
                "property": {
                    "type": "Literal",
                    "value": "b",
                    "raw": "'b'"
                }
            }
        }
    ],
    "sourceType": "script"
}
复制代码

咱们发现惟一的区别就是"computed"属性,a.b是false,a['b']是true,说明在解析成AST时,a['b']比a.b多了一个计算的过程。由此咱们判定,二者微小的差别应该就在这里。好了,证据找到了,如今应该没有疑问了吧。

收尾

看到这里,你不但了解了AST的相关知识,还知道了V8引擎是如何解析js代码的,是否是有所收获呢?若是你以为这篇文章对你有用,还请顺便点个赞,很是感谢(90度鞠躬)。

想了解skFeTeam更多的分享文章,能够点这里,谢谢~

相关文章
相关标签/搜索