玩转ast- 手写babel插件篇
AST
抽象语法树是什么?
- 抽象语法树(Abstract Syntax Tree,AST)是源代码语法结构的一种抽象表示
- 它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构
- 每个包含type属性的数据结构,都是一个AST节点;
以下是普通函数function ast(){},转换为ast后的格式:
抽象语法树用途有哪些?
- 代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码错误提示、代码自动补全、实现一套代码适配多端运行等等
- 优化变更代码,改变代码结构使达到想要的结构
webpack、Lint等这些工具的原理都是通过 JavaScript Parser 把源代码转化为一颗抽象语法树(AST),通过操纵这颗树,我们可以精准的定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化、变更等操作。
JavaScript解析器
那什么是JavaScript解析器呢? JavaScript解析器的作用,把JavaScript源码转化为抽象语法树。 浏览器会把js源码通过解析器转换为ast,再进一步转换为字节码或者直接生成机器码,进行渲染和执行。 一般来说,每个js引擎都有自己的抽象语法树格式,Chrome的v8引擎, firfox的Spider Monkey引擎等。
JavaScript解析器通常可以包含四个组成部分。
- 词法分析器(Lexical Analyser)
- 语法解析器(Syntax Parser)
- 字节码生成器(Bytecode generator)
- 字节码解释器(Bytecode interpreter)
词法分析器(Lexical Analyser)
首先词法分析器会扫描(scanning)代码,将一行行源代码,通过switch case 把源码?“/为一个个小单元,jS代码有哪些语法单元呢?大致有以下这些:
- 空白:JS中连续的空格、换行、缩进等这些如果不在字符串里,就没有任何实际逻辑意义,所以把连续的空白符直接组合在一起作为一个语法单元。
- 注释:行注释或块注释,虽然对于人类来说有意义,但是对于计算机来说知道这是个“注释”就行了,并不关心内容,所以直接作为一个不可再拆的语法单元
- 字符串:对于机器而言,字符串的内容只是会参与计算或展示,里面再细分的内容也是没必要分析的
- 数字:JS语言里就有16、10、8进制以及科学表达法等数字表达语法,数字也是个具备含义的最小单元
- 标识符:没有被引号扩起来的连续字符,可包含字母、_、$、及数字(数字不能作为开头)。标识符可能代表一个变量,或者true、false这种内置常量、也可能是if、return、function这种关键字,是哪种语义,分词阶段并不在乎,只要正确切分就好了。
- 运算符:+、-、*、/、>、<等等
- 括号:(...)可能表示运算优先级、也可能表示函数调用,分词阶段并不关注是哪种语义,只把“(”或“)”当做一种基本语法单元
就是一个字符一个字符地遍历,然后通过switch case分情况讨论,整个实现方法就是顺序遍历和大量的条件判断。以@babel/parser源码为例:
getTokenFromCode(code) {
switch (code) {
case 46:
this.readToken_dot();
return;
case 40:
++this.state.pos;
this.finishToken(10);
return;
case 41:
++this.state.pos;
this.finishToken(11);
return;
case 59:
++this.state.pos;
this.finishToken(13);
return;
// 此处省略...
case 92:
this.readWord();
return;
default:
if (isIdentifierStart(code)) {
this.readWord(code);
return;
}
}
}
readToken_dot() {
// charCodeAt一个一个向后移
const next = this.input.charCodeAt(this.state.pos + 1);
if (next >= 48 && next <= 57) {
this.readNumber(true);
return;
}
if (next === 46 && this.input.charCodeAt(this.state.pos + 2) === 46) {
this.state.pos += 3;
this.finishToken(21);
} else {
++this.state.pos;
this.finishToken(16);
}
}
语法解析器
将上一步生成的数组,根据语法规则,转为抽象语法树(Abstract Syntax Tree,简称AST)。 以 const a = 1 为例,词法分析中获得了 const 这样一个 token,并判断这是一个关键字,根据这个 token 的类型,判断这是一个变量声明语句。以@babel/parser源码为例,执行 parseVarStatement 方法。
parseVarStatement(node, kind, allowMissingInitializer = false) {
const {
isAmbientContext
} = this.state;
const declaration = super.parseVarStatement(node, kind, allowMissingInitializer || isAmbientContext);
if (!isAmbientContext) return declaration;
for (const {
id,
init
} of declaration.declarations) {
if (!init) continue;
if (kind !== "const" || !!id.typeAnnotation) {
this.raise(TSErrors.InitializerNotAllowedInAmbientContext, {
at: init
});
} else if (init.type !== "StringLiteral" && init.type !== "BooleanLiteral" && init.type !== "NumericLiteral" && init.type !== "BigIntLiteral" && (init.type !== "TemplateLiteral" || init.expressions.length > 0) && !isPossiblyLiteralEnum(init)) {
this.raise(TSErrors.ConstInitiailizerMustBeStringOrNumericLiteralOrLiteralEnumReference, {
at: init
});
}
}
return declaration;
}
经过这一步的处理,最终 const a = 1 会变成如下ast结构:
字节码生成器
字节码生成器的作用,是将抽象语法树转为JavaScript引擎可以执行的二进制代码。目前,还没有统一的JavaScript字节码的格式标准,每种JavaScript引擎都有自己的字节码格式。最简单的做法,就是将语义单位翻成对应的二进制命令。
字节码解释器
几种常见的解析器
Esprima
这是第一个用JavaScript编写的符合EsTree规范的JavaScript的解析器,后续多个编译器都是受它的影响
acorn
acorn 和 Esprima 很类似,输出的ast都是符合 EsTree 规范的,目前webpack的AST解析器用的就是acorn
@babel/parser(babylon)
babel官方的解析器,最初fork于acorn,后来完全走向了自己的道路,从babylon改名之后,其构建的插件体系非常强大
uglify-js
用于混淆和压缩代码, 因为一些原因,uglify-js自己[内部实现了一套AST规范,也正是因为它的AST是自创的,不是标准的ESTree,es6以后新语法的AST,都不支持,所以没有办法压缩最新的es6的代码,如果需要压缩,可以用类似babel这样的工具先转换成ES5。
esbuild
esbuild是用go编写的下一代web打包工具,它拥有目前最快的打包记录和压缩记录,snowpack和vite的也是使用它来做打包工具,为了追求卓越的性能,目前没有将AST进行暴露,也无法修改AST,无法用作解析对应的JavaScript。
babel
下面先介绍下babel相关工具库,以及一些API,了解完这些基础概念后,我们会利用这些工具操作AST来编写一个babel插件。
- @babel/parser 可以把源码转换成AST
- @babel/traverse用于对 AST 的遍历,维护了整棵树的状态,并且负责替换、移除和添加节点
- @babel/generate 可以把AST生成源码,同时生成sourcemap
- @babel/types 用于 AST 节点的 Lodash 式工具库, 它包含了构造、验证以及变换 AST 节点的方法,对编写处理 AST 逻辑非常有用
- @babel/template可以简化AST的创建逻辑
- @babel/code-frame可以打印代码位置
- @babel/core Babel 的编译器,核心 API 都在这里面,比如常见的 transform、parse,并实现了插件功能
- babylon Babel 的解析器,以前叫babel parser,是基于acorn扩展而来,扩展了很多语法,可以支持es2020、jsx、typescript等语法
- babel-types-api
- Babel 插件手册
Visitor
- 访问者模式 Visitor 对于某个对象或者一组对象,不同的访问者,产生的结果不同,执行操作也不同
- Visitor 的对象定义了用于 AST 中获取具体节点的方法
- Visitor 上挂载以节点 type 命名的方法,当遍历 AST 的时候,如果匹配上 type,就会执行对应的方法
path
- node 当前 AST 节点
- parent 父 AST 节点
- parentPath 父AST节点的路径
- scope 作用域
- get(key) 获取某个属性的 path
- **set(key, node) **设置某个属性
- is类型(opts) 判断当前节点是否是某个类型
- find(callback) 从当前节点一直向上找到根节点(包括自己)
- **findParent(callback) **从当前节点一直向上找到根节点(不包括自己)
- insertBefore(nodes) 在之前插入节点
- insertAfter(nodes) 在之后插入节点
- replaceWith(replacement) 用某个节点替换当前节点
- replaceWithMultiple(nodes) 用多个节点替换当前节点
- replaceWithSourceString(replacement) 把源代码转成AST节点再替换当前节点
- remove() 删除当前节点
- **traverse(visitor, state) **遍历当前节点的子节点,第1个参数是节点,第2个参数是用来传递数据的状态
- skip() 跳过当前节点子节点的遍历
- stop() 结束所有的遍历
- getSibling(key) 获取某个下标的兄弟节点
- getNextSibling() 获取下一个兄弟节点
- **getPrevSibling() ** 获取上一个兄弟节点
- getAllPrevSiblings() 获取之前的所有兄弟节点
- getAllNextSiblings() 获取之后的所有兄弟节点
scope
• **scope.bindings **当前作用域内声明所有变量 • **scope.path **生成作用域的节点对应的路径 • **scope.references **所有的变量引用的路径 • getAllBindings() 获取从当前作用域一直到根作用域的集合 • **getBinding(name) **从当前作用域到根使用域查找变量 • getOwnBinding(name) 在当前作用域查找变量 • parentHasBinding(name, noGlobals) 从当前父作用域到根使用域查找变量 • removeBinding(name) 删除变量 • hasBinding(name, noGlobals) 判断是否包含变量 • **moveBindingTo(name, scope) **把当前作用域的变量移动到其它作用域中 • generateUid(name) 生成作用域中的唯一变量名,如果变量名被占用就在前面加下划线 • scope.dump() 打印自底向上的 作用域与变量信息到控制台
实现eslint插件
上面这些概念在我们编写babel插件时会用到,接下来我们来实现一个eslint移除console.log()插件吧!
先看下 console.log('a') 的AST长什么样子?
可以看到 console.log('a') 是一个type为 “ExpressionStatement”的节点,所以我们只需要遍历AST,当遇到type=ExpressionStatement的节点删除即可!来一起实现吧~
- 引入基础包
var fs = require("fs");
//babel核心模块,里面包含transform方法用来转换源代码。
const core = require('@babel/core');
//用来生成或者判断节点的AST语法树的节点
let types = require("@babel/types");
- 遍历AST节点,babel插件的语法都是固定的,里面包含visitor,我们只需在visitor里面处理ExpressionStatement即可,
//no-console 禁用 console
const eslintPlugin = ({ fix }) => {
// babel插件的语法都是固定的,里面包含visitor
return {
pre(file) {
file.set('errors', []);
},
// 访问器
visitor: {
CallExpression(path, state) {
const errors = state.file.get('errors');
const { node } = path
if (node.callee.object && node.callee.object.name === 'console') {
errors.push(path.buildCodeFrameError(`代码中不能出现console语句`, Error));
if (fix) {
path.parentPath.remove();
}
}
}
},
post(file) {
// console.log(...file.get('errors'));
}
}
};
- 完整实现:
var fs = require("fs");
//babel核心模块,里面包含transform方法用来转换源代码。
const core = require('@babel/core');
//用来生成或者判断节点的AST语法树的节点
let types = require("@babel/types");
//no-console 禁用 console
const eslintPlugin = ({ fix }) => {
// babel插件的语法都是固定的,里面包含visitor
return {
pre(file) {
file.set('errors', []);
},
// 访问器
visitor: {
CallExpression(path, state) {
const errors = state.file.get('errors');
const { node } = path
if (node.callee.object && node.callee.object.name === 'console') {
errors.push(path.buildCodeFrameError(`代码中不能出现console语句`, Error));
if (fix) {
path.parentPath.remove();
}
}
}
},
post(file) {
// console.log(...file.get('errors'));
}
}
};
core.transformFile("eslint/source.js",{
plugins: [eslintPlugin({ fix: false })]
}, function(err, result) {
result; // => { code, map, ast }
console.log(result.code);
})
转载自:https://juejin.cn/post/7179481462050127931