前言
作为一个前端切图仔,工作中少不了用上 Babel,ESLint,Prettier 这些工具,但是这些工具背后的工作原理却不求甚解😅 ,最近才尝试去了解一番,没想到发现一个全新的世界,那就是 AST
中文名抽象语法树。
初识
在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法架构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真是语法中出现的每个细节。比如嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;二类似于
if-condition-then
这样的条件跳转语句,可以使用带有三个分支的节点来表示。 和抽象语法树相对的是具体语法树(通常称作分析树)。一般的,在源代码的翻译和编译过程中,语法分析器创建出分析树,然后从分析树生成AST。一旦AST被创建出来,在后续的处理过程中,比如语义分析阶段,会添加一些信息。
让我们从一个全能的函数开始,它返回生命、宇宙以及任何事情的终极答案
function ask() {
const answer = 42
return answer
}
首先它是一个函数声明,函数名称为 ask
,函数体内定义了一个值为 42 常量 answer
,最后返回 answer
。把它输入到一个神奇的网站 astexplorer,神秘的 AST
终于被揭开了面纱。
可以看出 AST
就是一个自上而下的树形结构,每一层有一个或多个节点组成,每个节点由一个 type
属性表示节点的类型如 FunctionDeclaration
, BlockStatement
, VariableDeclaration
,以及其他属性组成。(节点的类型定义在 ESTree 这个仓库,它包括了 es5 到最新的 js 语法类型定义)。下图可以更清晰的看出这个函数 AST
的结构。
至此我们对 AST
有了初步的认识
解析
要得到代码的 AST
,首先需要对代码进行解析。解析阶段接受源码并输出 AST
,它使用一个解析器对源码进行词法分析和语法分析。词法分析 将字符串形式的代码转换为一个语法片段数组 Tokens ,语法分析阶段把 Tokens 转换成 AST
形式。
词法分析
Tokens 是一个由代码语句碎片组成的数组,内部可以是数字、标签、标点符号、运算符,或者其它任何东西。
// 示例源码
a + b
// Tokens
[
{ type: { ... }, value: "a", start: 0, end: 1, loc: { ... } },
{ type: { ... }, value: "+", start: 2, end: 3, loc: { ... } },
{ type: { ... }, value: "b", start: 4, end: 5, loc: { ... } },
]
语法分析
语法分析阶段把 Tokens 数组转换成 AST
的形式便于后续操作,大致操作可以参考这里的代码。
遍历
有了 AST
就可以对它进行从上到下的递归遍历,过程中使用一种名为 访问者模式 的设计模式访问树里的节点。这种模式通过创建一个包含一些方法的对象 visitor
,在遍历 AST
过程中匹配 visitor
里的方法名,匹配成功就调用此方法。
通过访问 AST
节点可以对源码进行语法检查,ESLint 就是基于此工作的。
以下是检测语法的规则示例(关于编写 ESLint 规则的详细内容可以查看此链接)
限制函数参数数量
思路是匹配类型为 FunctionDeclaration
的函数声明节点,如果函数的参数数量大于 3 个就进行提示。
export default function (context) {
return {
// 访问 FunctionDeclaration 节点
FunctionDeclaration: (node) => {
// 判断函数参数个数
if (node.params.length > 3) {
context.report({
node,
message: "参数最多不能超过3个"
});
}
}
};
}
效果如下
限制嵌套的条件语句
匹配类型为 IfStatement
的节点,如果它的第一个子节点还是 IfStatement
就进行提示。
export default function (context) {
return {
IfStatement(node) {
const { consequent } = node;
const { body } = consequent;
// 判断第一个子节点类型是否是 IfStatement
if (body[0] && body[0].type === "IfStatement") {
context.report({
node: body[0],
message: "不允许嵌套的条件语句"
});
}
}
};
}
修改
在遍历 AST
过程中可以对树的节点进行添加,移动和替换,也可以生成全新的 AST
。
Babel 的工作原理就是修改代码 AST
上的节点,从而到达修改代码的目的。
一个 Babel 插件是一个接收 babel
对象作为参数的函数,返回一个带有 visitor
属性的对象。visitor
对象中的每个函数接受 path
和 state
参数。
以下是编写 Babel 插件示例,关于编写 Babel 插件详细内容可以查看此链接。
// 一个 Babel 插件
export default function(babel) {
return {
visitor: {
Identifier(path, state) {},
// ...
}
};
}
将 **
语法转换为 Math.pow
// Before
const a = 10 ** 2
// After
const a = Math.pow(10, 2)
- 找到
**
语法所在位置 - 获取左右操作数
- 创建
Math.pow
语句,替换原节点
export default function (babel) {
const { types: t } = babel;
return {
visitor: {
// 访问二元表达式
BinaryExpression(path) {
const { node } = path
// 如果操作符不是 ** 就退出
if (node.operator !== '**') return
const { left, right } = node
// 创建调用语句
const newNode = t.callExpression(
t.memberExpression(t.identifier('Math'), t.identifier('pow')),
[left, right]
)
// 替换原节点
path.replaceWith(newNode)
},
}
};
}
修改工具函数引入方式
// Before
import { get, isFunction } from 'lodash'
// After
import get from "lodash/get";
import isFunction from "lodash/isFunction";
- 找到
lodash
的import
节点 - 遍历所有的引入值,获取引用的
name
属性 - 插入新生成的
import
节点 - 删除原节点
export default function (babel) {
const { types: t } = babel;
return {
visitor: {
// 访问导入声明
ImportDeclaration(path) {
let { node } = path
if (node.source.value !== 'lodash') return
const val = node.source.value
node.specifiers.forEach((spec) => {
if (t.isImportSpecifier(spec)) {
const { local } = spec
// 插入新的导入节点
path.insertBefore(
t.importDeclaration(
[t.importDefaultSpecifier(local)],
t.stringLiteral(`${val}/${local.name}`)
)
)
}
})
// 删除原节点
path.remove()
},
}
};
}
生成
根据 AST
输出代码,下面通过两个工具说明。
Jscodeshift
jscodeshift 是一个 Facebook 开源的用来对 JavaScript 或者 TypeScript 文件运行转换的工具,它的目的是更方便的批量修改代码。它通过 transformer 对源码进行转换,一个 transformer 就是一个接受 fileInfo
, api
, options
参数并返回源码的函数。
module.exports = function(fileInfo, api, options) {
// transform `fileInfo.source` here
// ...
// return changed source
return source;
};
示例:将 <React.Fragment>
转换成 <></>
语法,思路是找到 name
属性为 Fragment
的节点,然后将它的父节点清除。
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
root.find(j.JSXIdentifier).forEach((p) => {
const { node } = p
// 节点的 name 等于 Fragment 才继续操作
if (node.name !== 'Fragment') return
// 将其父节点移除
j(p.parent).remove()
})
return root.toSource()
}
效果如下
更多示例可查看这个链接
Gogocode
gogocode 是最近发现的一个代码处理工具,号称全网最简单,易上手,可读性最强,提供类似于 jQuery 的 API。
示例:替换变量名
#!/usr/bin/env node
const $ = require('gogocode')
const code = `
function ask() {
const answer = 42
return answer
}
`
const newCode = $(code)
.replace('ask', 'question')
.replace('answer', 'result')
.generate()
console.log(newCode)
// 输出
// function question() {
// const result = 42
// return result;
// }
效果如下
总结
本文解释了什么是 AST
抽象语法树,如何获得代码的 AST
,以及对 AST
进行遍历,修改和生成,利用 AST
我们可以开发的代码工具。
相关链接
Awesome AST
The ESTree Spec
Babel Handbook
The Super Tiny Compiler
Working with Rules
jscodeshift
GOGOCODE