团队:skFeTeam 本文做者:李世伟html
做为前端程序员,webpack,rollup,babel,eslint这些是否是常常用到?他们是打包工具,代码编译工具,语法检查工具。他们是如何实现的呢?本文介绍的抽象语法树,就是他们用到的技术,是否是应该了解一下呢?前端
本文没有晦涩难懂的理论,也没有大段大段的代码,彻底从零开始,小白阅读也无任何障碍。经过本文的阅读,您将会了解AST的基本原理以及使用方法。node
什么是抽象语法树?webpack
- AST(Abstract Syntax Tree)是源代码的抽象语法结构树状表现形式。下面这张图示意了一段JavaScript代码的抽象语法树的表现形式。
抽象语法树有什么用呢?git
- IDE的错误提示、代码格式化、代码高亮、代码自动补全等
- JSLint、JSHint、ESLint对代码错误或风格的检查等
- webpack、rollup进行代码打包等
- Babel 转换 ES6 到 ES5 语法
- 注入代码统计单元测试覆盖率
AST是如何生成的?程序员
- 可以将JavaScript源码转化为抽象语法树(AST)的工具叫作JS Parser解析器。
JS Parser的解析过程包括两部分github
- 词法分析(Lexical Analysis):将整个代码字符串分割成最小语法单元数组
- 语法分析(Syntax Analysis):在分词基础上创建分析语法单元之间的关系
常见的AST parserweb
- 早期有uglifyjs和esprima
- Espree,基于esprima,用于eslint
- Acorn,号称是相对于esprima性能更优,体积更小
- Babylon,出自acorn,用于babel
- Babel-eslint,babel团队维护,用于配合使用ESLint
语法单元是被解析语法当中具有实际意义的最小单元,简单的来理解就是天然语言中的词语。chrome
Javascript 代码中的语法单元主要包括如下这么几种:express
- 关键字:例如 var、let、const等
- 标识符:没有被引号括起来的连续字符,多是一个变量,也多是 if、else 这些关键字,又或者是 true、false 这些内置常量
- 运算符: +、-、 *、/ 等
- 数字:像十六进制,十进制,八进制以及科学表达式等
- 字符串:由于对计算机而言,字符串的内容会参与计算或显示
- 空格:连续的空格,换行,缩进等
- 注释:行注释或块注释都是一个不可拆分的最小语法单元
- 其余:大括号、小括号、分号、冒号等
组合分词的结果,肯定词语之间的关系,肯定词语最终的表达含义,生成抽象语法树。
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"
}
复制代码
- 经典的JavaScript抽象语法树解析器,网站提供的功能也很是丰富
- 能够在线查看分词和抽象语法树
- Syntax展现抽象语法树,Tokens展现分词
![]()
- 还提供了各类parse的性能比较,看起来Acorn的性能更优秀一点。
![]()
- AST的可视化工具网站,可使用各类parse对代码进行AST转换
![]()
AST解析规范(The Estree Spec)
- 相同的JavaScript代码,经过各类parser解析的AST结果都是同样的,这是由于他们都参照了一样的AST解析规范
- The Estree Spec 规范是 Mozilla 的工程师给出的 SpiderMonkey 引擎输出的 JavaScript AST 的规范文档,也能够参考:SpiderMonkey in MDN
前面已经介绍了AST的内容,下面咱们来看看babel是如何使用AST的。
Babel的工做过程通过三个阶段,parse、transform、generate
- parse阶段,将源代码转换为AST
- transform阶段,利用各类插件进行代码转换
- generator阶段,再利用代码生成工具,将AST转换成代码
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。
了解了babel的运行原理,咱们根据babel的三个步骤来动手写一个demo,加深对AST的理解。
- 把 == 改成全等 ===
- 把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');
}
}
复制代码
//引入工具包
const esprima = require('esprima');//JS语法树模块
const estraverse = require('estraverse');//JS语法树遍历各节点
const escodegen = require('escodegen');//JS语法树反编译模块
const fs = require('fs');//读写文件
复制代码
const before = fs.readFileSync('./before.js', 'utf8');
const ast = esprima.parseScript(before);
复制代码
estraverse.traverse(ast, {
enter: (node) => {
toEqual(node);//把 == 改成全等 ===
setParseInt(node); //把 parseInt(a) 改成 parseInt(a,10)
}
});
复制代码
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"
});
}
}
复制代码
//生成目标代码
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应该彻底理解了吧。
看到这里,你已经明白了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引擎提及了。
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解析器
Ignition-解释器
TurboFan-编译器
如今,咱们就来比较一下a.b和a['b']在V8的解析下,到底有什么不一样
function test001() {
var a = { b: 1 };
console.log(a.b)
}
test001();
复制代码
function test002() {
var a = { b: 1 };
console.log(a['b'])
}
test002();
复制代码
先看下他们生成的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)
复制代码
[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有什么区别。
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "MemberExpression",
"computed": false,
"object": {
"type": "Identifier",
"name": "a"
},
"property": {
"type": "Identifier",
"name": "b"
}
}
}
],
"sourceType": "script"
}
复制代码
{
"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度鞠躬)。