Jacleklm's Blog

AST详解

2020/06/25

TODO:还有这篇 字节分享
几乎完全转自 AST 团队分享 ,可以理解为 读后 & 自己敲一遍 的笔记

What & Why

  • What: 抽象语法树(Abstract Syntax Tree,简称 AST)是源代码的抽象语法结构的树状表现形式
  • Why:
    • webpack、eslint 等很多工具库的核心都是通过抽象语法树来实现对代码的检查、分析等操作
    • 浏览器就是通过将 js 代码转化为抽象语法树来进行下一步的分析等其他操作,所以将 js 转化为抽象语法树更利于程序的分析
  • 一个简单的例子:
    • 首先一段代码转换成的抽象语法树是一个对象,该对象会有一个顶级的 type 属性 Program;第二个属性是 body 是一个数组
    • body 数组中存放的每一项都是一个对象,里面包含了所有的对于该语句的描述信息
1
2
3
4
5
6
7
8
9
10
11
type: 描述该语句的类型  --> 变量声明的语句
kind: 变量声明的关键字 --> var
declaration: 声明内容的数组,里面每一项也是一个对象
type: 描述该语句的类型
id: 描述变量名称的对象
type: 定义
name: 变量的名字
init: 初始化变量值的对象
type: 类型
value: 值 "is tree" 不带引号
row: "\"is tree"\" 带引号

词法分析和语法分析

JavaScript解释型语言,一般通过 词法分析 -> 语法分析 -> 语法树,就可以开始解释执行了。从 code 分析成 AST ,我们可以在这里看到效果

  • 词法分析:也叫扫描 (scans),是将字符流转换为记号流 (tokens),它会读取我们的代码然后按照一定的规则合成一个个的标识 (scans: code => tokens)
    。当词法分析源代码的时候,它会一个一个字符的读取代码;当它遇到空格、操作符,或者特殊符号的时候,它会认为一个话已经完成了。比如说:var a = 2 ,这段代码通常会被分解成 var、a、=、2
1
2
3
4
5
6
;[
{ type: 'Keyword', value: 'var' },
{ type: 'Identifier', value: 'a' },
{ type: 'Punctuator', value: '=' },
{ type: 'Numeric', value: '2' },
]
  • 语法分析:也称解析器,将词法分析出来的数组转换成树的形式,同时验证语法。语法如果有错的话,抛出语法错误 (analyze: tokens => syntax)
1
2
3
4
5
6
7
8
9
{
...
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
...
}
  • syntax => tree

What can AST do

  • 语法检查、代码风格检查、格式化代码、语法高亮、错误提示、自动补全等
  • 代码混淆压缩
  • 优化变更代码,改变代码结构等

eg. 有个函数 function a() {} 我想把它变成 function b() {}
eg. 在 webpack 中代码编译完成后 require('a') –> __webapck__require__("*/**/a.js")

The analysis process of AST

How to analysis

先说结果:AST 是深度优先遍历
比如说一段代码 function getUser() {},我们把函数名字更改为 hello
推荐另一个常用的 AST 在线转换网站ast explorer;可以在网站看转换效果,也可以用下面的工具看效果

  • 工具 (这三个是操作 AST 的三个重要模块,也是实现 babel 的核心依赖)
    • esprima:code => ast
    • estraverse: traverse ast
    • escodegen: ast => code
  • 实例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const esprima = require('esprima')
const estraverse = require('estraverse')
const code = `function getUser() {}`
// 生成 AST
const ast = esprima.parseScript(code)
// 转换 AST,只会遍历 type 属性
// traverse 方法中有进入和离开两个钩子函数
estraverse.traverse(ast, {
enter(node) {
console.log('enter -> node.type', node.type)
},
leave(node) {
console.log('leave -> node.type', node.type)
},
})
  • 打印结果
  • 所以是深度优先遍历

Demo: 修改函数名字

此时我们发现函数的名字在 typeIdentifier 的时候就是该函数的名字,我们就可以直接修改它便可实现一个更改函数名字的 AST 工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 转换树
estraverse.traverse(ast, {
// 进入离开修改都是可以的
enter(node) {
console.log('enter -> node.type', node.type)
if (node.type === 'Identifier') {
node.name = 'hello'
}
},
leave(node) {
console.log('leave -> node.type', node.type)
},
})
// 生成新的代码
const result = escodegen.generate(ast)
console.log(result)
// function hello() {}

Application of AST

babel

The working mechanism of babel

babel 的主要作用就是 Es6 代码转换为 Es5 的代码,以兼容所有浏览器,主要原理就是运用了 AST。有个 class 语法转为 ES5 语法的 demo,见博客 AST 抽象语法树

babel两个工具包重要的包: @babel/core (打通webpack和babel的一个通道)、@babel/preset-env (翻译器) (还有一个@babel/polyfill(添加ES6中的新函数新变量,eg. Promise和map))。当我们配置 babel 的时候,不管是在 .babelrc 或者 babel.config.js 文件里面配置的都有 presets 和 plugins 两个配置项

presets和plugins的区别

1
2
3
4
5
// .babelrc
{
"presets": ["@babel/preset-env"],
"plugins": []
}

当我们配置了 presets 中有 @babel/preset-env,那么 @babel/core 就会去找 preset-env 预设的插件包,它是一套预设好的插件配置。

@babel/core并不会去转换代码,只提供一些核心 API,真正的代码转换工作由插件或者预设来完成,比如要转换箭头函数,会用到这个 plugin:@babel/plugin-transform-arrow-functions,当需要转换的要求增加时,我们不可能去一一配置相应的 plugin,这个时候就可以用到预设了,也就是 presets。presets 是 plugins 的集合,一个 presets 内部包含了很多 plugin

babel中转换代码一个demo

现在我们有一个箭头函数,要想把它转成普通函数,我们就可以直接这么写:

1
2
3
4
5
6
7
8
9
10
const babel = require('@babel/core')
const code = `const fn = (a, b) => a + b`
// babel 有 transform 方法会帮我们自动遍历,使用相应的预设或者插件转换相应的代码
const r = babel.transform(code, {
presets: ['@babel/preset-env'],
})
console.log(r.code)
// 打印结果如下
// "use strict";
// var fn = function fn() { return a + b; };

此时我们可以看到最终代码会被转成普通函数,但是我们,只需要箭头函数转通函数的功能,不需要用这么大一套包,只需要一个箭头函数转普通函数的包,我们其实是可以在 node_modules 下面找到有个叫做 plugin-transform-arrow-functions 的插件,这个插件是专门用来处理 箭头函数的,我们就可以这么写:

1
2
3
4
5
6
const r = babel.transform(code, {
plugins: ['@babel/plugin-transform-arrow-functions'],
})
console.log(r.code)
// 打印结果如下
// const fn = function () { return a + b; };

我们可以从打印结果发现此时并没有转换我们变量的声明方式还是 const 声明,只是转换了箭头函数

编写一个箭头函数转普通函数的babel插件

首先应该看 Babel插件开发入门指南
现在我们来个实战把 const fn = (a, b) => a + b 转换为 const fn = function(a, b) { return a + b }

分析 AST 结构

首先我们在在线分析 AST 的网站上分析 const fn = (a, b) => a + bconst fn = function(a, b) { return a + b } 看两者语法树的区别

分析可得:

  • 变成普通函数之后他就不叫箭头函数了 ArrowFunctionExpression,而是函数表达式了 FunctionExpression
  • 所以首先我们要把 箭头函数表达式(ArrowFunctionExpression) 转换为 函数表达式(FunctionExpression)
  • 要把 二进制表达式(BinaryExpression) 放到一个 代码块中(BlockStatement)的 ExpressionStatement/ ReturnStatement 中。
  • 其实我们要做就是把一棵树变成另外一颗树,说白了其实就是拼成另一颗树的结构,然后生成新的代码,就可以完成代码的转换

访问者模式

在 babel 中,我们开发 plugins 的时候要用到访问者模式,就是说在访问到某一个路径的时候进行匹配,然后在对这个节点进行修改。这里的节点指的是AST树中的节点,比如下面是访问到 ArrowFunctionExpression 节点就执行下面的 ArrowFunctionExpression() 函数

那么我们就可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const babel = require('@babel/core')
const code = `const fn = (a, b) => a + b` // 转换后 const fn = function(a, b) { return a + b }
const arrowFnPlugin = {
// 访问者模式
visitor: {
// 当访问到某个路径的时候进行匹配,比如下面是访问到 ArrowFunctionExpression 就执行下面的 ArrowFunctionExpression() 函数
ArrowFunctionExpression(path) {
// 拿到节点
const node = path.node
console.log('ArrowFunctionExpression -> node', node)
},
},
}

const r = babel.transform(code, {
plugins: [arrowFnPlugin],
})

console.log(r)

修改 AST 结构

此时我们拿到的结果是这样的节点结果是 这样的,其实就是 ArrowFunctionExpression 的 AST,此时我们要做的是把 ArrowFunctionExpression 的结构替换成 FunctionExpression的结构。但要我们自己手写替换得毫无差错是很麻烦的,所以 babel 为我们提供了一个神仙工具叫做 @babel/types 中文版

@babel/types 有两个作用:

  • 判断当前节点是不是某种特定节点(eg. 相当于之前的 node.type === ‘ArrowFunctionExpression’ )
  • 生成对应的表达式

那么接下来我们就开始生成一个 FunctionExpression,然后把之前的 ArrowFunctionExpression 替换掉,我们可以看 @babel/types 文档,找到 functionExpression 方法,该方法接受相应的参数我们传递过去即可生成一个 FunctionExpression

t.functionExpression(id, params, body, generator, async)

  • id: Identifier (default: null) id 可传递 null
  • params: Array (required) 函数参数,可以把之前的参数拿过来
  • body: BlockStatement (required) 函数体,接受一个 BlockStatement 我们需要生成一个
  • generator: boolean (default: false) 是否为 generator 函数,当然不是了
  • async: boolean (default: false) 是否为 async 函数,肯定不是了

还需要生成一个 BlockStatement,我们接着看文档找到 BlockStatement 接受的参数

t.blockStatement(body, directives)

看文档说明,blockStatement 接受一个 body,那我们把之前的 body 拿过来就可以直接用,不过这里 body 接受一个数组

我们细看 AST 结构,函数表达式中的 BlockStatement 中的 body 是一个 ReturnStatement,所以我们还需要生成一个 ReturnStatement

现在我们就可以改写 AST 了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const babel = require('@babel/core')
const t = require('@babel/types')
const code = `const fn = (a, b) => a + b` // const fn = function(a, b) { return a + b }
const arrowFnPlugin = {
// 访问者模式
visitor: {
// 当访问到某个路径的时候进行匹配
ArrowFunctionExpression(path) {
// 拿到节点然后替换节点
const node = path.node
console.log('ArrowFunctionExpression -> node', node)
// 拿到函数的参数
const params = node.params
const body = node.body
const functionExpression = t.functionExpression(
null,
params,
t.blockStatement([body])
)
// 替换原来的函数
path.replaceWith(functionExpression)
},
},
}
const r = babel.transform(code, {
plugins: [arrowFnPlugin],
})
console.log(r.code) // const fn = function (a, b) { return a + b; };

特殊情况

我们知道在剪头函数中是可以省略 return 关键字,我们上面是处理了省略关键字的写法,但是如果用户写了 return 关键字后,我们写的这个插件就有问题了,所以我们可以在优化一下

1
const fn = (a, b) => { retrun a + b } -> const fn = function(a, b) { return a + b }

观察代码我们发现,我们就不需要把 body 转换成 blockStatement 了,直接放过去就可以了,那么我们就可以这么写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ArrowFunctionExpression(path) {
// 拿到节点然后替换节点
const node = path.node
console.log("ArrowFunctionExpression -> node", node)
// 拿到函数的参数
const params = node.params
let body = node.body
// 判断是不是 blockStatement,不是的话让他变成 blockStatement
if (!t.isBlockStatement(body)) {
body = t.blockStatement([body])
}
const functionExpression = t.functionExpression(null, params, body)
// 替换原来的函数
path.replaceWith(functionExpression)
}

编写一个claas转ES5的babel插件

AST 抽象语法树
类似的实际插件是 @babel/plugin-transform-classes

按需引入

我们用 element-ui 或 antd 的时候,样式都支持全局引入和按需引入。按需引入需要安装一个 babel-plugin-import 的插件,将全局的写法变成按需引入的写法。其实也是用了AST语法树转换的原理,实现

1
2
3
4
import { Button } from 'antd';
↓ ↓ ↓ ↓ ↓ ↓
var _button = require('antd/lib/button');
require('antd/lib/button/style');

(这个插件详见另一篇博客 按需加载 & 样式自动加载 —— babel-plugin-import)

babylon

在 babel 官网上有一句话 Babylon is a JavaScript parser used in Babel。(即是Babel 的解析引擎)

babel 与 babylon 的关系

from alloyteam-剖析babel
Babel 使用的引擎是 babylon,babylon 并非由 babel 团队自己开发的,而是 fork 的 acorn 项目,acorn 的项目本人在很早之前在兴趣部落 1.0 在构建中使用,为了是做一些代码的转换,是很不错的一款引擎,不过 acorn 引擎只提供基本的解析 ast 的能力,遍历还需要配套的 acorn-travesal, 替换节点需要使用 acorn-,而这些开发,在 Babel 的插件体系开发下,变得一体化了

使用 babylon 编写一个数组扩展运算符 转 Es5 语法的插件

const arr = [ ...arr1, ...arr2 ] 转成 var arr = [].concat(arr1, arr2)

我们使用 babylon 的话就不需要使用 @babel/core 了,只需要用到他里面的 traversegenerator,用到的包有 babylon@babel/traverse@babel/generator@babel/types

分析语法树

看一下两棵语法树的区别

根据上图我们分析得出:

  • 两棵树都是变量声明的方式,不同的是他们声明的关键字不一样
  • 他们初始化变量值的时候是不一样的,一个数组表达式(ArrayExpression)另一个是调用表达式(CallExpression)
  • 那我们要做的就很简单了,就是把 数组表达式转换为调用表达式就可以

分析类型

这段代码的核心生成一个 callExpression 调用表达式,所以对应官网上的类型,我们分析需要用到的 api

  • 先来分析 init 里面的,首先是 callExpression
    1
    2
    3
    4
    5
    /**
    * @param {Expression} callee (required)
    * @param {Array<Expression | SpreadElement | JSXNamespacedName>} source (required)
    */
    t.callExpression(callee, arguments)
  • 对应语法树上 callee 是一个 MemberExpression,所以要生成一个成员表达式
    1
    2
    3
    4
    5
    6
    7
    /**
    * @param {Expression} object (required)
    * @param {if computed then Expression else Identifier} property (required)
    * @param {boolean} computed (default: false)
    * @param {boolean} optional (default: null)
    */
    t.memberExpression(object, property, computed, optional)
  • 在 callee 的 object 是一个 ArrayExpression 数组表达式,是一个空数组
    1
    2
    3
    4
    /**
    * @param {Array<null | Expression | SpreadElement>} elements (default: [])
    */
    t.arrayExpression(elements)
  • 对了里面的东西分析完了,我们还要生成 VariableDeclarator 和 VariableDeclaration 最终生成新的语法树
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * @param {LVal} id (required)
    * @param {Expression} init (default: null)
    */
    t.variableDeclarator(id, init)

    /**
    * @param {"var" | "let" | "const"} kind (required)
    * @param {Array<VariableDeclarator>} declarations (required)
    */
    t.variableDeclaration(kind, declarations)
    其实倒着分析语法树,分析完怎么写也就清晰了,那么我们开始上代码吧

上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const babylon = require('babylon')
// 使用 babel 提供的包,traverse 和 generator 都是被暴露在 default 对象上的
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const t = require('@babel/types')

const code = `const arr = [ ...arr1, ...arr2 ]` // var arr = [].concat(arr1, arr2)

const ast = babylon.parse(code, {
sourceType: 'module',
})

// 转换树
traverse(ast, {
VariableDeclaration(path) {
const node = path.node
const declarations = node.declarations
console.log('VariableDeclarator -> declarations', declarations)
const kind = 'var'
// 边界判定
if (node.kind !== kind && declarations.length === 1 && t.isArrayExpression(declarations[0].init)) {
// 取得之前的 elements
const args = declarations[0].init.elements.map((item) => item.argument)
const callee = t.memberExpression(t.arrayExpression(), t.identifier('concat'), false)
const init = t.callExpression(callee, args)
const declaration = t.variableDeclarator(declarations[0].id, init)
const variableDeclaration = t.variableDeclaration(kind, [declaration])
path.replaceWith(variableDeclaration)
}
},
})

具体语法书

和抽象语法树相对的是具体语法树(Concrete Syntax Tree)简称 CST(通常称作分析树)。一般的,在源代码的翻译和编译过程中,语法分析器创建出分析树。一旦 AST 被创建出来,在后续的处理过程中,比如语义分析阶段,会添加一些信息。可参考抽象语法树和具体语法树有什么区别?

补充

关于 node 类型,全集大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
(parameter) node: Identifier | SimpleLiteral | RegExpLiteral | Program | FunctionDeclaration | FunctionExpression | 
ArrowFunctionExpression | SwitchCase | CatchClause | VariableDeclarator | ExpressionStatement | BlockStatement |
EmptyStatement | DebuggerStatement | WithStatement | ReturnStatement | LabeledStatement | BreakStatement |
ContinueStatement | IfStatement | SwitchStatement | ThrowStatement | TryStatement | WhileStatement | DoWhileStatement |
ForStatement | ForInStatement | ForOfStatement | VariableDeclaration | ClassDeclaration | ThisExpression |
ArrayExpression | ObjectExpression | YieldExpression | UnaryExpression | UpdateExpression | BinaryExpression |
AssignmentExpression | LogicalExpression | MemberExpression | ConditionalExpression | SimpleCallExpression |
NewExpression | SequenceExpression | TemplateLiteral | TaggedTemplateExpression | ClassExpression | MetaProperty |
AwaitExpression | Property | AssignmentProperty | Super | TemplateElement | SpreadElement | ObjectPattern |
ArrayPattern | RestElement | AssignmentPattern | ClassBody | MethodDefinition | ImportDeclaration |
ExportNamedDeclaration | ExportDefaultDeclaration | ExportAllDeclaration | ImportSpecifier |
ImportDefaultSpecifier | ImportNamespaceSpecifier | ExportSpecifier

Babel 有文档对 AST 树的详细定义,可参考这里

参考资料
Babel插件开发入门指南
AST 团队分享
alloyteam-剖析babel
JavaScript 语法解析、AST、V8、JIT
详解AST抽象语法树

CATALOG
  1. 1. What & Why
  2. 2. 词法分析和语法分析
  3. 3. What can AST do
  4. 4. The analysis process of AST
    1. 4.1. How to analysis
    2. 4.2. Demo: 修改函数名字
  5. 5. Application of AST
    1. 5.1. babel
      1. 5.1.1. The working mechanism of babel
        1. 5.1.1.1. presets和plugins的区别
        2. 5.1.1.2. babel中转换代码一个demo
      2. 5.1.2. 编写一个箭头函数转普通函数的babel插件
        1. 5.1.2.1. 分析 AST 结构
        2. 5.1.2.2. 访问者模式
        3. 5.1.2.3. 修改 AST 结构
        4. 5.1.2.4. 特殊情况
      3. 5.1.3. 编写一个claas转ES5的babel插件
    2. 5.2. 按需引入
    3. 5.3. babylon
      1. 5.3.1. babel 与 babylon 的关系
      2. 5.3.2. 使用 babylon 编写一个数组扩展运算符 转 Es5 语法的插件
      3. 5.3.3. 分析语法树
      4. 5.3.4. 分析类型
      5. 5.3.5. 上代码
  6. 6. 具体语法书
  7. 7. 补充