如果你想了解
Javascript
的编译原理,那么你就得了解 AST(Abstract Syntax Tree),目前前端常用的一些插件或者工具,比如JS
转译、代码压缩、CSS
预处理器、ESLint
、Prettier
等功能的实现,都是建立在 AST 的基础之上的。如果这篇文章对你有帮助,欢迎点赞关注😘~
什么是AST
定义: 在计算机科学中,抽象语法树是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。
从定义中我们只需要知道一件事就行,那就是 AST 是一种树形结构,并且是某种代码的一种抽象表示,Babel
,tsc
,Vue-cli
和EsLint
等很多的工具和库的核心都是通过 AST 抽象语法树这个概念来实现对代码的检查、分析等操作的。
在线可视化网站:astexplorer.net/ ,利用这个网站我们可以很清晰的看到各种语言的 AST 结构。
estree
estree
就是es语法对应的标准 AST
,作为一个前端也比较方便理解。我们以官方文档为例:github.com/estree/estr…
例如console.log('1')
这行代码会被解析为:
{
"type": "Program",
"start": 0, // 起始位置
"end": 16, // 结束位置,字符长度
"body": [
{
"type": "ExpressionStatement", // 表达式语句
"start": 0,
"end": 16,
"expression": {
"type": "CallExpression", // 函数方法调用式
"start": 0,
"end": 16,
"callee": {
"type": "MemberExpression", // 成员表达式 console.log
"start": 0,
"end": 11,
"object": {
"type": "Identifier", // 标识符,可以是表达式或者结构模式
"start": 0,
"end": 7,
"name": "console"
},
"property": {
"type": "Identifier",
"start": 8,
"end": 11,
"name": "log"
},
"computed": false, // 成员表达式的计算结果,如果为 true 则是 console[log], false 则为 console.log
"optional": false
},
"arguments": [ // 参数
{
"type": "Literal", // 文字标记,可以是表达式
"start": 12,
"end": 15,
"value": "1",
"raw": "'1'"
}
],
"optional": false
}
}
],
"sourceType": "module"
}
这里建议自己将本地的测试代码(例如一个简单的a+b函数)复制进上面提到的网站中,理解 estree
的各种节点类型。当然了,我们也不可能看一篇文章就记住那么多类型,只要心里有个大致的概念即可。
AST 的运用
将原代码转化为 AST,修改 AST
,再重新转化为新代码就能完成代码转译。Babel
将最新语法的 JS
代码转化为 ES5
的原理就是这样的。
Babel
操作 AST
会用到以下工具包:
- @babel/parser 用于将代码转换为 AST
- @babel/traverse 用于对 AST 的遍历,包括节点增删改查、作用域等处理
- @babel/generator 用于将 AST 转换成代码
- @babel/types 用于 AST 节点操作的 Lodash 式工具库,各节点构造、验证等
更多 api 详见 Babel手册。
解析过程
源代码 → AST 的转换过程主要分为三步:
-
词法分析:将代码字符串分割成
token
流 -
语法分析:将
token
流转换成AST
-
遍历和修改:对
AST
进行遍历和修改,生成新的代码
让我们看一个简单的例子:
// 源代码
const message = 'Hello World';
// 转换成 AST 后的结构(简化版)
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "message"
},
"init": {
"type": "StringLiteral",
"value": "Hello World"
}
}
],
"kind": "const"
}
实战案例:实现一个简易ESLint
安装依赖
npm install @babel/parser @babel/traverse @babel/types --save-dev
目录结构如下:
编写代码
定义一个MiniEsLint类:
this.rules
定义了默认的规则配置:
'no-console': true
- 禁止使用 console'max-params': [true, 3]
- 函数最多允许 3 个参数...rules
- 使用展开运算符合并用户自定义规则
this.errors
初始化一个空数组,用于收集错误信息
class MiniESLint {
constructor(rules = {}) {
// 默认规则配置
this.rules = {
'no-console': true, // 禁止使用 console
'max-params': [true, 3], // 函数参数最大数量
...rules
};
this.errors = [];
}
/**
* 验证代码的主方法
* @param {string} code - 要检查的代码字符串
* @param {string} filename - 文件名,默认为 anonymous.js
* @returns {Array} 返回错误信息数组
*/
verify(code, filename = 'anonymous.js') {
// 每次验证前清空错误数组
this.errors = [];
return this.errors;
}
/**
* 添加错误信息的辅助方法
* @param {Object} error - 错误信息对象
* @param {string} error.message - 错误描述
* @param {number} error.line - 错误所在行
* @param {number} error.column - 错误所在列
* @param {string} error.filename - 文件名
*/
addError({ message, line, column, filename }) {
this.errors.push({
message, // 错误信息
line, // 行号
column, // 列号
filename // 文件名
});
}
}
module.exports = MiniESLint;
添加代码解析功能
主角登场!为我们的MiniEsLint类添加新装备:
@babel/parser
(用于将代码转换为 AST)@babel/traverse
(用于遍历 AST)
添加 AST 解析功能:
// 1. 引入依赖
// @babel/parser 用于将代码转换为 AST
const parser = require('@babel/parser');
// @babel/traverse 用于遍历 AST
const traverse = require('@babel/traverse').default;
class MiniESLint {
// 构造函数不变
constructor(...}
/**
* 代码验证方法
* @param {string} code - 要检查的代码
* @param {string} filename - 文件名
* @returns {Array} 错误信息数组
*/
verify(code, filename = 'anonymous.js') {
// 清空之前的错误
this.errors = [];
try {
// 解析代码生成 AST
const ast = parser.parse(code, {
sourceType: 'module', // 使用ES模式解析
plugins: ['jsx'], // 支持JSX语法
});
// 遍历 AST
traverse(ast, {
...(暂时不添加新逻辑)
});
return this.errors;
} catch (error) {
console.error('代码解析错误:', error);
return [{
message: `代码解析错误: ${error.message}`,
line: 0,
column: 0,
filename
}];
}
}
}
关键部分说明:
parser.parse
将代码转换为 ASTtraverse
用于遍历 AST 的每个节点且第二个参数是访问者对象,用于定义如何处理不同类型的节点
测试一下!
添加测试代码test.js
:
const MiniESLint = require('./index.js');
// 创建实例
const linter = new MiniESLint();
// 测试语法错误的代码
const errorCode = `function test( {
console.log('Missing parenthesis');
`;
const errors2 = linter.verify(errorCode, 'error.js');
可以发现,我们写的Eslint规则已经生效啦👏~
更进一步:实现no-console检查
在经过了上面的简易实战过后,我们继续优化一下我们当前的MiniESlint✨,来实现 console
的检查规则
但这次我们要来个新装备🔧 - @babel/types
(用于 AST 节点类型判断)
当我们写 console.log('Hello')
时,它的 AST 结构是这样的:
{
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "console"
},
"property": {
"type": "Identifier",
"name": "log"
}
}
所以我们需要实现一个检查方法,大体如下:
checkConsole(path, filename) {
const { node } = path;
if (
// 对象名是否为 "console"
t.isIdentifier(node.object, { name: 'console' }) &&
t.isIdentifier(node.property)
// 是否访问了该对象的属性
) {
this.addError({/*...*/});
}
}
path
包含了当前节点及其上下文信息node
是当前 AST 节点t.isIdentifier()
用于检查节点是否为标识符
之前MiniEslint里的遍历方法大家还记得吗?要派上用场啦!
在遍历中使用检查方法:
traverse(ast, {
MemberExpression: (path) => {
if (this.rules['no-console']) {
this.checkConsole(path, filename);
}
}
});
-
MemberExpression
匹配所有的成员访问表达式 -
只有当
no-console
规则启用时才进行检查
测试一下:
const MiniESLint = require('./index.js');
const linter = new MiniESLint({
'max-params': [true, 2]
});
const code = `
function test() {
console.log('Debug info');
console.error('Error info');
console.warn('Warning info');
return true;
}
`;
const errors = linter.verify(code, 'test.js');
console.log(errors);
成功实现!🎉
课后实践 & 总结
为了兼容低版本浏览器 我们也通常会使用 webpack
打包编译我们的代码将 ES6
语法降低版本,比如箭头函数变成普通函数。将 const
、let
声明改成 var
等等,他都是通过 AST
来完成的,只不过实现的过程比较复杂,精致。不过也都是这三板斧:
- js 语法解析成
AST
;- 修改
AST
;AST
转成 js 语法;
经过了上面你对于MiniEslint
的实践,相信你对AST在代码解析和结构化的原理里有了一定的认识,如果你想要检验你的学习成果,不妨自己动手实现一个变量命名规范检查
(例如让所有函数使用驼峰命名)的功能来加深理解!