快来享受AST转换的乐趣
如果你经常关注前端基础建设,相信你一定对抽象语法树(Abstract Syntax Tree,AST)技术并不陌生。在 Babel、Webpack、Eslint 等工具中,AST 都发挥着重要的作用,可以说你无时无刻不在享受 AST 技术带来的便利。本文将带你走进 AST 这个前端基建和前端工程化利器。
抽象语法树
抽象语法树是源代码语法结构的一种抽象表示,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
看完定义,我们直接通过 AST explorer 来看看抽象语法树是什么样的。
左侧的源码是一个变量的定义和赋值,右侧是对应的 AST,可以看到它是一个树状结构,树上的每个节点都可以对应到源代码。
再来看个例子:
可以看出 AST 不会记录左侧源码中的括号和分隔符等语法信息。
这边引入一个新概念:解析树,也被成为具象语法树(Concret Syntax Tree, 简称 CST),一棵解析树是包含代码所有语法信息的树型结构,它是代码的直接翻译。
而 AST 是抽象的,它无关语法结构,不会记录源语言真实语法中的每个细节,比如分隔符,空白符释等。
为什么会有 AST? 因为现在各种语言语法种类繁多,虽然最终落到计算机的眼中都是 0 和 1,但是编译器需要识别语言,这个时候就需要使用一种通用的数据结构来描述,而 AST 就是那个东西,因为 AST 是真实存在且存在一定逻辑规则的,它小而美,相对 CST 更便于操作。
AST 的生成需要经过词法分析和语法分析,如下图:
词法分析,也称之为扫描(scanner),一个个读取字符,然后与定义好的 JavaScript 关键字符做比较,生成对应的 Token。
Token 是一个不可分割的最小单元: 每个关键字是一个 Token ,每个标识符是一个 Token,每个操作符是一个 Token,每个标点符号也都是一个 Token。例如,const foo = 42;
。这段程序通常会被分解成为下面这些词法单元:const
, foo
, =
, 42
, ;
语法分析会将词法分析出来的 Token 转化成有语法含义的抽象语法树结构。同时,验证语法,语法如果有错的话,抛出语法错误。
JS Parser
在介绍 JS 解析器前,我们先了解下 Estree,它参考了 Mozilla 浏览器的 SpiderMonkey 引擎的 AST 的标准,然后做了扩充,形成了现在的 AST 标准。
接下来我们看下一些常见的解析器:
- Esprima:第一个用 JavaScript 编写的符合 EsTree 规范的 JavaScript 的解析器
- Acorn:符合 EsTree 规范,性能和效率比 Esprima 更胜一筹,支持插件机制来扩充语法
- @babel/parser: babel 官方的解析器,最初 fork 于 acorn,其构建的插件体系非常强大
- Espree: eslint、prettier 的默认解析器,最初 fork 于 Esprima ,后来因为 ES6 的快速发展,但 Esprima 短时间内又不支持,后面就基于 acorn 开发了
- recast:支持解析和代码重新生成,默认解析器为 Esprima,可使用其他解析器,集成了 ast-types(类似 lodash 的 AST 工具库)
- jscodeshift: 是一个基于 codemod 理念的重构工具,底层依赖 recast, 提供了类似 jQuery API 一样的方式来遍历和操作 AST
实例
实例部分将分别使用 acorn、jscodeshift、@babel/parser 这三种解析器来体验下操作 AST 来完成特定的功能。
在操作过程中,我们需要打开 AST explorer 来查看 AST 转换前后的结构。
var 替换成 let
在这个例子中,我们将使用 acorn 来解析生成 AST,acorn-walk 来遍历 AST, 最后使用 escodegen 将 AST 重新生成代码。
AST 中的各个节点我们可以查看 Estree 文档来查看,或者直接在 AST explorer 中查看。
这个例子的目标是把 var foo = 42
转换成 let foo = 42
,在刚接触 AST 的时候,我们一般会先对比 AST 转换前后的结构,找出不同后,再进行代码编码。
先看下 var foo = 42
的 AST 结构:
再来看下 let foo = 42
的 AST 结构:
对比后发现前后只是在 VariableDeclaration 节点的 kind 属性有差别,故我们只需遍历 AST,找到 VariableDeclaration 节点,调整它的 kind 属性。
// ast.js,node ast 即可输出结果
const { parse } = require('acorn')
const { simple } = require('acorn-walk')
const { generate } = require('escodegen')
const str = 'var foo = 42'
const ast = parse(str, {
ecmaVersion: 6
})
simple(ast, {
VariableDeclaration(node) {
node.kind = 'let'
}
})
console.log(generate(ast))
acorn 的 parse
方法可以将源码解析为 AST,第一个参数为源码,第二个参数支持解析时一些配置,比如 ecmaVersion,我们将其设置为 6,代码我们源码是 ES6 版本。
解析出 AST 后,我们使用 acorn-walk 中的 simple
方法去遍历,simple
方法支持访问 AST 中的特定节点,除了 simple
方法, acorn-walk 还提供 full
方法,支持完整遍历 AST。
在这个例子中,我们只需访问 VariableDeclaration 节点,故我们配置个 VariableDeclaration 节点,将其 kind 属性调整为 let。
最后我们通过 escodegen 的 generate
方法将 AST 转成代码。
代码重构
这个例子是将没有重新赋值的 let 声明改成 const 声明,我们将使用 jscodeshift 来进行代码重构。上述有介绍过 jscodeshift 基于 recast, 并提供了类似 jQuery API 一样的方式来遍历和操作 AST。
同样的我们先看看对应的 AST 结构:
左侧是源码,右侧 body 数组中 4 个对应源码的每一行,这次转换的关键在于要找出所有 let 变量,然后再遍历赋值语句,找出有重新赋值的变量,最后将没重新赋其变量声明改成 const。变量重新赋值的节点如下:
接下来使用 jscodeshift 来生成 AST,遍历操作,并重新生成代码。
const j = require('jscodeshift')
const renameVars = []
const code = `
let foo = 2;
let bar = 3;
foo = 3;
console.log(foo, bar)
`
j(code)
.find(j.AssignmentExpression)
.forEach(path => renameVars.push(path.node.left.name))
const ast = j(code)
.find(j.VariableDeclaration)
.filter(path => !renameVars.includes(path.node.declarations[0].id.name))
.forEach(path => {
path.node.kind = 'const'
})
console.log(ast.toSource())
代码解析:
- 调用
j(code)
生成 AST - 找到所有赋值节点 AssignmentExpression,记下所有赋值操作左侧变量。
- 接着重新遍历 AST,找到变量声明 VariableDeclaration 节点,过滤出未被重新赋值的节点,把节点的 kind 值调整为 const
- 最后调用
ast.toSource()
重新生成代码,即完成了代码重构。
转换后的代码:
let foo = 2;
const bar = 3;
foo = 3;
console.log(foo, bar)
React 版本升级中一些生命周期命名调整等,官方就是通过 jscodeshift 来写工具的提供自动化升级,有兴趣的可以更一步去研究。
箭头函数转换
箭头函数转换成普通函数是 babel 中最常见的一个能力,现在我们通过 babel 的相关工具来完成一个简单版本。
babel 的工具链:
- @babel/parser:babel 解析器
- @babel/traverse:支持遍历 AST
- @babel/generator:支持 AST 转换回代码
- @babel/types:提供节点构建器和节点类型检测方法
工具链的使用可以参考文档或者 Babel Plugin Handbook
先看下转换前 AST:
这次要把 ArrowFunctionExpression 节点转换成普通函数节点,接着看下转换后的 AST 结构:
const { parse } = require("@babel/parser")
const generate = require("@babel/generator")
const traverse = require("@babel/traverse")
const t = require("@babel/types")
const code = 'const sum = (a, b) => a + b'
const ast = parse(code)
traverse.default(ast, {
ArrowFunctionExpression(path) {
const funcExpress = t.functionExpression(
null,
path.node.params,
t.blockStatement([
t.returnStatement(path.node.body)
])
)
path.replaceWith(funcExpress)
}
})
const output = generate.default(ast)
console.log(code)
console.log(output.code)
代码分析:
- 首先使用
parse
方法把源码解析成 AST - @babel/traverse 提供了访问者模式遍历,故只需访问 ArrowFunctionExpression
- 使用 @babel/types 构建函数表达式,functionExpression 的三个参数为 id,入参,函数体。入参直接使用箭头函数入参 path.node.params,函数体这边构建一个块级声明,里面还需构建返回声明节点,内容就是箭头函数的返回。
- 使用
replaceWith
方法替换节点。 - 最后使用 @babel/generator 重新生成代码。
tree-shaking
tree-shaking 用于移除未使用到的代码,构建工具像 webpack,vite 都会支持。这个例子带你实现一个简单的 tree-shaking。
需要优化的代码和对应的 AST:
函数 subtract
和变量 num3
都未使用到,需要移除掉。实现思路为遍历 AST,记录下表达式中使用到的变量和函数,接着重新遍历 AST,把未使用到的变量和函数移除即可。
const { parse } = require("@babel/parser")
const generate = require("@babel/generator")
const traverse = require("@babel/traverse")
const t = require("@babel//types")
const code = `
const add = (a, b) => a + b;
function subtract(a, b) { return a - b }
const num1 = 1;
const num2 = 10;
const num3 = 100;
add(num1, num2);
`
const ast = parse(code)
const calledDecls = []
traverse.default(ast, {
ExpressionStatement(path) {
path.traverse({
Identifier(path) {
calledDecls.push(path.node.name)
}
})
}
})
traverse.default(ast, {
VariableDeclaration(path) {
for(let declaration of path.node.declarations) {
if (!calledDecls.includes(declaration.id.name)) {
path.remove()
}
}
},
FunctionDeclaration(path) {
if (!calledDecls.includes(path.node.id.name)) {
path.remove()
}
},
})
const output = generate.default(ast)
console.log(code)
console.log(output.code)
代码分析:
- 首先使用
parse
方法把源码解析成 AST - 遍历所有 ExpressionStatement,找到节点下所有的函数和变量名
- 重新遍历 AST,访问变量声明和函数声明节点,如果未在表达式中使用到,移除节点。
- 最后使用 @babel/generator 重新生成代码。
最后执行结果:
const add = (a, b) => a + b;
const num1 = 1;
const num2 = 10;
add(num1, num2);
babel-import-plugin
这个例子中我们去简单实现下 babel-import-plugin 能力,babel 插件的指南可以看这个文档:Babel Plugin Handbook,核心也是操作 AST。
babel 环境搭建:
- 创建文件夹 babel-demo
- 安装 babel 包
npm install --save-dev @babel/core @babel/cli
- 创建 babel 配置文件 babel.config.json 内容如下
{
"plugins": [
"./plugin.js"
]
}
- 创建 plugin.js 文件,后续实现插件能力
- 在 src 目录下创建 index.js,内容如下
import { flatten } from 'lodash'
flatten([1, [2, [3, [4]], 5]])
- 最后执行
npx babel src --out-dir lib
即可在 lib 下看的转换的代码。(插件未实现情况下可先去掉配置,不然会报错。因为没有配置 presets,转换代码和源码差不多)
照例先看下转换前后的 AST,转换前如下:
转换后如下:
可以看到主要是 ImportDeclaration 下的 specifiers 和 source 属性调整。
// plugins
module.exports = function({ types: t }) {
return {
visitor: {
ImportDeclaration(path, state) {
if (t.isImportSpecifier(path.node.specifiers[0])) {
const declaration = t.importDeclaration(
[t.importDefaultSpecifier(t.Identifier(path.node.specifiers[0].imported.name))],
t.StringLiteral(path.node.source.value + '/' + path.node.specifiers[0].imported.name)
)
path.replaceWith(declaration)
}
}
}
}
}
插件也是访问者模式,我们可以传入要访问的节点,同时插件中可以使用 @babel/types 能力。
代码分析:
- 在访问器中指定了访问 ImportDeclaration 节点
- 判断导入语句是大括号形式导入,例如
import { flatten } from 'lodash'
,如果是就构建新的导入语句替换它 - 构建新的 importDeclaration 节点,第一个参数为导入的标识,第二个参数为引入的库
- 第一个参数传入新构建的默认导入节点 importDefaultSpecifier,参数为括号形式导入的方法名;第二个参数直接用原有的库名再拼上方法名。
- 替换原节点
上述实现是替换了导入声明节点,你也可以遍历导入声明节点,只修改导入标识和导入库节点。
最后的输出:
import flatten from "lodash/flatten";
flatten([1, [2, [3, [4]], 5]]);
总结
通过以上的学习,相信你对 AST 有了一定认识。AST 的学习必须要自己去实操,才能更快地入门和掌握。除了一些简单转换,后续可以编写 Babel、ESlint 等插件来进一步学习这块知识,当然你也需要学习 JS 是怎么运行起来的,AST 在 JS 运行中处在什么位置。
转载自:https://juejin.cn/post/7211346278289899579