1. AST 是什么?
Abstract Syntax Tree,抽象语法树,简称 AST。
在 Javascript 代码执行流程中,它位于词法和语法分析之后。
通过对源代码进行词法和语法分析,生成一颗「树」。
打开 astexplorer.net,随便输入一段 Javascript 代码,就可以看到其对应的 AST。
2. AST 有什么用?
点击右边的 JSON tab 切换到 JSON 视图,program 部分:
就算不借助任何其他工具,我们也可以通过遍历这个 JSON 结构还原出它对应的源码。
所以也可以在还原的时候顺便把 const 的部分修改为 var,这样我们的代码就能在旧版浏览器上跑了,这就是 babel 所做的事情。
借助现有的工具,我们不需要自己去遍历和生成代码,对 AST 的增删改查变得很简单。
3. @babel/parser、@babel/traverse 和 @babel/generator
@babel/parser:通过源码生成 AST,上面在 astexplorer 里用的就是它。
const parse = require('@babel/parser').parse
const code = `const a = 1`
const ast = parse(code)
我们可以在这里找到它的文档,也可以在 node_modules 下面找到它的包,里面有提供 typings 能查看它接收的参数。
@babel/traverse:遍历 AST 的工具。但是它的官方文档只有install和usage,没有更详细的文档了,node_modules下面的包也没有提供 types 文件。那我们到底要怎样去遍历 AST 呢?
这里就不绕弯子了,虽然我本人在这里绕了挺久。其实很简单,回到上面的 astexplorer.net,每个节点都有一个 type 字段来标识它的类型,比如我们的示例里面 const a = 1 这个节点的 type 值就是 VariableDeclaration,鼠标点击左边代码,会自动把右边对应的节点高亮:
@babel/traverse 第一个参数就是通过 parse 生成的 AST,第二个参数叫做 visitors。traverse 会遍历节点,每遇到一个节点,调用 visitors 里面对应的处理函数,而这个函数的 key 就是节点的 type。
const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default
const code = `const a = 1`
const ast = parse(code)
traverse(ast, {
VariableDeclaration(path) {
console.log(Object.keys(path))
}
})
传入参数 path 并不是节点本身,而是一个包含节点信息的对象,我们看一下它的 key 有哪些:
其中 path.node 才是节点,打印出来和 astexplorer 上面是一样的。这里我们要关注的其他 key 是 parent 和 parentPath,parent 是父节点,在这里它是 type 为 Program 的节点,而 parentPath 就是包含父节点信息的对象了。他们的关系就和 path 和 node 的关系一样:
traverse(ast, {
VariableDeclaration(path) {
console.log(path.parent === path.parentPath.node) // true
}
})
简单来说,在 AST 上看到的所有 type 值,你都可以写在 visitors 里从而访问到对应的节点。
下面我们把 const 换成 var。首先,我们已经知道 const a = 1 的 AST 了,那 var a = 1 的 AST 长什么样呢?直接在 astexplorer 里面看看不就知道了:
有了 AST 的对比,我们就可以放开手脚开始改了,这里我们直接把 kind 的值从 const 改成 var:
traverse(ast, {
VariableDeclaration(path) {
path.node.kind = 'var'
}
})
AST 修改好了,接下来就是要从 AST 还原为代码,然后就可以放到旧版浏览器上跑了。
@babel/generator:根据 AST 生成代码。这里是它的文档,根据文档来看,我们甚至可以把两棵 AST 合并生成一个文件,并且生成 sourceMaps。
将我们上面修改的 AST 生成代码:
const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const code = `const a = 1`
const ast = parse(code)
traverse(ast, {
VariableDeclaration(path) {
path.node.kind = 'var'
}
})
const result = generate(ast)
console.log(result.code) // var a = 1;
4. AST 增删改查
@babel/types:操作 AST 节点,离不开 @babel/types 这个包。在这里你能找到它的文档,我们要用它来创建节点和判断节点类型。
- 查
就如上面所说,通过 visitors 可以访问到各种 type 的节点。除了 type 之外,还有一些访问节点的方法。
enter 和 exit。它们不是 type 类型,但是每个节点都会调用 enter 和 exit 各一次,所以不要轻易使用,它们很消耗性能,尽量用 type 去遍历特定的节点。
traverse(ast, {
enter(path) {
console.log("Entered!");
},
exit(path) {
console.log("Exited!");
}
})
alias 别名可以访问拥有相同别名的节点,比如用 Function 可以访问到FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, ObjectMethod 和 ClassMethod这些节点。那节点的别名在哪里看呢?没找到文档,但是你可以去查看源码定义了哪些别名,比如 ES2015 里 ClassMethod 定义的别名就有:
另外在访问节点时,我们经常需要判断子节点的类型。通过 path 提供的方法 get 可以快速获取到子节点的 path 信息(注意不是子节点 node)。配合 @babel/types 提供的类型判断方法可以更简单获取节点类型:
traverse(ast, {
VariableDeclaration(path) {
const node = path.node
// 以 . 连接的形式快速获取子节点 path
const arrowPath = path.get('declarations.0.init')
// 判断节点类型的函数命名为 is+节点类型
const isArrow = t.isArrowFunctionExpression(arrowPath)
}
})
同时也提供了查找父节点 path 的方法 path.findParent():
traverse(ast, {
VariableDeclaration(path) {
const objectParent = path.findParent(path => t.isObjectProperty(path))
}
}
})
- 删
删除永远都很简单,打开 astexplorer.net/,输入你的源代码,找到要删除的代码对应的节点:
然后 traverse 访问节点调用 path.remove()即可:
const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const t = require('@babel/types')
const code = `
const a = 1;
console.log(a);
const b = 2;
`
const ast = parse(code)
traverse(ast, {
CallExpression(path) {
const callee = path.node.callee
if (callee.object.name === 'console' && callee.property.name === 'log') {
// 调用 path.remove() 最后出来的结果是一样的,但是会留下一个空 prarent 节点
path.parentPath.remove()
}
},
// 下面的代码是一样的效果,但是看起来更直观一点
MemberExpression(path) {
if (path.node.object.name === 'console' && path.node.property.name === 'log') {
const parent = path.findParent(path => t.isExpressionStatement(path))
parent.remove()
}
}
})
const result = generate(ast)
console.log(result.code)
// const a = 1;
// const b = 2;
- 改和增
如果只是改节点现有的 key,比如函数名和变量名,那直接覆盖原来的值就行。但是形如把箭头函数改成 function 这种就涉及到新增 AST 节点了,所以把改和增放在一起讲。
这里我们尝试构建一个 function a() { return 1 }的节点。
首先,打开 astexplorer.net/,我们在操作 AST 的过程中通过它来预览是最便捷的。输入我们要构建的节点,然后看生成的树:
可以看到节点类型是 FunctionDeclaration,然后去 @babel/types的文档找到 functionDeclaration函数,函数名字对应 type 的小驼峰命名,通过它来生成我们要的节点:
开始构建节点,根据函数签名和 AST 结构,我们需要一层层生成对应的子节点,而每种节点都有对应的生成函数:
const generate = require('@babel/generator').default
const t = require('@babel/types')
const node = t.functionDeclaration(
t.identifier('a'),
[],
t.blockStatement([
t.returnStatement(t.numericLiteral(1))
])
)
const result = generate(node)
console.log(result.code)
/*
function a() {
return 1;
}
*/
现在,我们终于可以尝试把箭头函数修改成 function 声明了:
const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const t = require('@babel/types')
const code = `
const a = (b, c) => b + c;
`
const ast = parse(code)
traverse(ast, {
// 遍历变量声明节点
VariableDeclaration(path) {
const node = path.node
// 判断声明类型是箭头函数
if (t.isArrowFunctionExpression(path.get('declarations.0.init'))) {
const declaration = node.declarations[0]
// 直接复制原来可用的节点,免去构造步骤
const name = declaration.id
const params = declaration.init.params
const returnBlock = declaration.init.body
const newNode = t.functionDeclaration(
name,
params,
t.blockStatement([
t.returnStatement(returnBlock)
])
)
path.replaceWith(newNode)
}
}
})
const result = generate(ast)
console.log(result.code)
/*
function a(b, c) {
return b + c;
}
*/
替换节点 path.replaceWith(newNode),替换为多个节点 path.replaceWithMultiple([node1, node2])。
插入节点的方法 path.insertBefore(node) path.insertAfter(node)。工欲善其事必先利其器,知道了节点的操作方法才能更高效的修改 AST。比如我在知道 path.replaceWidth 之前都是先插入新的节点再 remove 当前节点 。
5. 写一个 babel-plugin
现在来看 babel-plugin 官方文档的例子就可以理解了:
export default function() {
return {
visitor: {
Identifier(path) {
const name = path.node.name;
// reverse the name: JavaScript -> tpircSavaJ
path.node.name = name
.split("")
.reverse()
.join("");
},
},
};
}
现在尝试实现一个自己的 babel-plugin,它的功能是把 [1, '..', 4] 替换为 [1, 2, 3, 4],我们要做的步骤有:
- 去 astexplorer.net/ 对比两个 AST 结构差异
- 修改 AST
// 入参为 babel 对象,其中 babel.types 是我们需要用到的
export default function({ types: t }) {
return {
visitor: {
ArrayExpression(path) {
const elements = path.node.elements
if (elements.length !== 3) return
if (!(
t.isNumericLiteral(elements[0]) && t.isNumericLiteral(elements[2]) && t.isStringLiteral(elements[1])
)) return
if (elements[2].value - elements[0].value <= 1) return
if (elements[1].value !== '..') return;
const newNode = [
elements[0]
]
let right = elements[2].value
let left = elements[0].value + 1
while (left <= right) {
newNode.push(t.numericLiteral(left))
left++
}
// 节点没有类型而是数组则可以直接赋值
path.node.elements = newNode
}
},
};
}
- 打包发布到 npm
现在你可安装尝试一下了:
npm install @linghucq/babel-plugin-range-array
修改 babel 配置文件:
{
"presets": [
[
"@babel/preset-env",
{
"modules": false,
"useBuiltIns": "entry",
"corejs": 3
}
]
],
"plugins": [
"@linghucq/babel-plugin-range-array",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-optional-chaining"
]
}
注意:babel-plugin 执行优先于 preset,其中 plugin 是按配置顺序执行,preset 执行顺序与配置顺序相反,参见官方文档。
抛砖引玉,如有错漏,还望不吝指正。
PS:这是一年前写的文章了,今天翻出来看看颇有感触。之前一直认为 AST 这东西太过高深,自己工作中又用不到,没必要去学。其实真正学下来发现并没有想象中那么难,造工具是为了提高效率,要让使用它的人能上手(其实 babel 的文档还是差了点,不过是开源的如果嫌弃可以贡献自己的力量)。所以想学什么东西就去学,别怕没用也别怕难,就算最后真的学不会或者学会了也没处用,学习的过程也会让我们受益良多。下一步准备重新捡起大学挂了四年的高数-.-
参考文章:
- 浏览器工作原理与实践
- babel-handbook (推荐阅读)