🚀 看你的简历,你说你会AST,来、写个插件

2,074 阅读8分钟

我正在参加「掘金·启航计划」

前言

👮🏻面试官:“你好,看你简历,你说你会AST,请介绍一下你对AST的理解”

👦🏻我:“你好!AST代表抽象语法树,它是源代码的结构化表示。在前端开发中,AST可以用于代码解析、分析和转换。”

👮🏻面试官:“那么你能举一个AST在代码转换方面的具体应用例子吗?”

👦🏻我:“比如使用babel将es6转成es5。”

👮🏻面试官:“除了代码转换,AST还能在哪些方面发挥作用?”

👦🏻我:“AST在静态分析、代码检查和自动化任务中也发挥重要作用。我们可以使用AST进行代码的静态分析,如查找未使用的变量、检测代码风格问题等。另外,AST还可以用于生成文档、创建自定义工具和进行性能优化等方面。”

👮🏻面试官:“来、那你来给我写个插件?”

👦🏻我😳:“额,我觉得。。。先这样,然后这样,再这样。。。嗯,对,就这样。。。”

👮🏻面试官:“好的,回去等消息吧。”

。。。

一脸懵逼。

😳 那么到底怎么使用AST来处理代码呢?虽然我们了解过AST,但是很少用过,毕竟在平时的工作中很少有使用场景。

哎,卷起来吧。

什么是AST

这个问题,大家都知道的,这里就简单描述一下😊:

AST(抽象语法树)是一种表示编程语言语法结构的树形结构,它将代码中的每个语句和表达式转换为树形结构中的一个节点。AST可以看作是源代码的一种抽象表示形式,它可以帮助开发者更好地理解代码的结构和逻辑。

例如,下面是一个简单的JavaScript代码示例:

function greet(name) {
  console.log('Hello, ' + name + '!');
}

greet('World');

上述代码可以转换为以下AST:

Program
|- FunctionDeclaration: greet
|  |- Identifier: name
|  |- BlockStatement
|     |- ExpressionStatement
|        |- CallExpression
|           |- MemberExpression
|           |  |- Identifier: console
|           |  |- Identifier: log
|           |- BinaryExpression
|              |- BinaryExpression
|              |  |- Literal: 'Hello, '
|              |  |- Identifier: name
|              |- Literal: '!'
|- ExpressionStatement
   |- CallExpression
      |- Identifier: greet
      |- Literal: 'World'

可以看到,AST以树形结构表示了代码中的各个语句和表达式,每个节点代表一种语法结构。例如FunctionDeclaration、Identifier、BlockStatement、ExpressionStatement等。

在AST中,节点之间的父子关系代表了它们在代码中的嵌套关系。就好比函数嵌入函数。

概念还是非常简单的。上过学的都看的懂。

那么问题来了,代码是怎么转成AST的呢?我们自己转?

当然不用,有现成的工具可以用。比如:@babel/parser,acorn等JS工具。本篇文章也不会去介绍怎么把代码字符串转成AST,虽然不难,但是没有这个必要自己去写个转换的工具。😂

本文使用了@babel/parser,毕竟最流行嘛。开源就是好。

既然已经有办法转成AST了,那接下来就交给我吧。

插件开发

虽然是插件开发实战,但是本文主要说的是AST的能力,省略了插件的开发过程,关注AST的核心开发能力。

假设我们已经有一个插件开发项目:LegComments(后腿哥注释),一个给js函数添加注释模板的插件。

VSCode扩展商店应该有很多类似的插件了,有的达到了几十万下载量了,所以我们开发的这个插件还是很有市场的。

哈哈!🚀

想想就很激动,我们再开发一个下载量几十万的插件。

准备工作

先来分析一下,这个插件的功能

  1. 获取当前js文件中的所有内容、转成AST
  2. 遍历AST
  3. 找到函数声明
  4. 找到函数的参数
  5. 按照固定的模板生成多行注释
  6. 在函数前面添加注释
  7. 将处理后的AST生成代码文本
  8. 替换当前js文件内容

因为我们使用babel来转换,所以需要安装以下依赖:

npm i --save @babel/parser @babel/traverse @babel/types @babel/generator

介绍一下各个包的作用:

@babel/parser

将代码文本字符串转成(AST)。

@babel/parser的主要作用包括:

  • 解析代码:@babel/parser接受一段代码作为输入,并将其解析为相应的AST表示。它可以处理不同版本的JavaScript代码,包括ES5、ES6和更高版本的代码。

  • 生成AST:@babel/parser将代码解析为一棵AST,它是一个树状结构,用于表示代码的语法结构和含义。AST由一系列节点组成,每个节点表示代码的一个部分,如表达式、语句、函数等。

  • 支持扩展:@babel/parser支持插件系统,允许开发者根据需要添加自定义的解析规则或扩展现有的解析功能。这使得@babel/parser可以处理一些非标准语法或特定领域的语言扩展。

@babel/traverse

提供了用于遍历和修改AST的功能。它允许开发者在AST上进行深度遍历,查找特定节点,进行节点替换或修改,以及执行各种其他操作。

@babel/traverse 的主要作用:

  • 遍历AST
  • 查找特定节点
  • 修改节点

@babel/types

用于创建、操作和检查AST节点的功能。它允许开发者以编程方式创建和修改AST节点,而无需手动构建AST节点的数据结构。

@babel/types的主要作用:

  • 创建AST节点:@babel/types提供了一系列的工厂函数,用于创建各种类型的AST节点。例如,可以使用t.identifier(name)创建一个标识符节点,使用t.stringLiteral(value)创建一个字符串字面量节点。
  • 操作AST节点:@babel/types提供了一系列的操作函数,用于在AST节点上进行常见的操作。例如,可以使用t.isIdentifier(node)来检查一个节点是否是标识符节点,使用t.cloneNode(node)来克隆一个节点。
  • 修改AST节点:@babel/types提供了很多操作函数,来对AST节点进行修改、删除或替换。例如,可以使用path.replaceWith(newNode)来用新的节点替换一个节点,使用path.insertBefore(newNode)在当前节点之前插入一个新节点。

@babel/generator

提供了将AST节点转换为字符串表示的功能,使得开发者可以将修改后的AST重新生成为代码。

@babel/generator的主要作用:

生成代码:@babel/generator接受一个AST作为输入,并将其转换为相应的代码字符串表示。它将AST节点逐个遍历,并将节点转换为代码字符串,最终生成完整的代码。 保留代码格式:@babel/generator会尽可能地保留代码的格式,包括缩进、换行和空格等。这样可以确保生成的代码与原始代码在可读性和格式上保持一致。 支持配置:@babel/generator提供了一些配置选项,允许开发者根据需要定制生成的代码的输出。例如,您可以配置是否使用分号来结束语句、是否使用单引号或双引号来表示字符串等。

开发

导入相关模块:

// 导入依赖包
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const generator = require('@babel/generator').default;

解析AST

获取当前js文件内容,解析成ast。

// 获取当前编辑器
let editor = vscode.window.activeTextEditor;
let text=editor.document.getText();
if(!text){
  return 
}
let ast = parser.parse(text);

添加注释

使用@babel/traverse来遍历处理AST。

//添加注释
function addComment(node) {
  const params = node.params;
  if (!(params && params.length > 0)) {
    return
  }
  var comments = ['*', "* 该注释由后腿哥为你生成"]
  params.map(param => {
    comments.push(`* @param {*} ${param.name}`)
  })
  comments.push('');
  t.addComment(node, 'leading', comments.join('\n'))
}

// 遍历AST树
traverse(ast, {
  //查找类方法
  ClassMethod(path) {
    addComment(path.node)
  },
  //查找函数声明
  FunctionDeclaration(path) {
    addComment(path.node)
  }
});

注意

leadingComments 表示头部注释

前面说到@babel/traverse它允许开发者在AST上进行深度遍历,查找特定节点,进行节点替换或修改,以及执行各种其他操作。

上面代码中,我们通过traverse来查找ClassMethod(类成员函数),FunctionDeclaration(函数声明)类型的节点,然后给他们添加多行注释addComment

注意

@babel/traverse有很多遍历器,可以查看文档获取。这些遍历器都是定义在@babel/types中的

添加注释,我们使用@babel/types提供的apiaddComment来实现。

测试一下:

输入:

function add(a, b) {
  return a + b;
}

function output(name){

}

输出:

/**
* 该注释由后腿哥为你生成
* @param {*} a
* @param {*} b
*/
function add(a, b) {
  return a + b;
}
/**
* 该注释由后腿哥为你生成
* @param {*} name
*/
function output(name) {}

可以发现,功能正常。

但是有个问题,每次执行的插件的时候都会生一个注释,和以前的注释重复了,所以我们还得需要一个删除注释的功能。

但是删除也只能删除由插件生成的注释,总不能把之手动写的注释也删除了吧。

添加一个函数用于判断是否已经添加了注释

//判断是否已经添加过注释
function hasAddComment(path){
  const leadingComments = path.node.leadingComments;
  if(leadingComments){
    return leadingComments.find(a=>{
      return a.value.indexOf('该注释由后腿哥为你生成')!=-1
    })
  }else{
    return false
  }
}

上面的代码,我们直接判断函数前是否有leadingComments,并且判断是否由我们的插件生成的。该注释由后腿哥为你生成

这样就可以判断出来了,然后我们还得写一个删除注释的,功能,因为可能觉得插件生成的注释不好,所以得要删除,或者构建生产代码的时候,需要删除注释。

删除注释

节点的函数,最简单了,可以过直接赋值的形式,或者调用node.remove来实现。

//移除通过插件添加的注释
function removeLeadingComments(path){
  const leadingComments = path.node.leadingComments;
  if (leadingComments && leadingComments.length > 0) {
    path.node.leadingComments = [];
  }
}

上面的代码,我们直接将函数节点的leadingComments置空。好了。试试我们的插件吧。

可以看到首次运行插件。函数添加上了注释,再次运行插件,注释删除了。是不是很神奇

屏幕录制2023-05-15-18.06.47.gif

完整的插件代码

将整个extension.js的代码贴出,仅供参考

const vscode = require('vscode');

// 导入Babel/Parse依赖包
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const generator = require('@babel/generator').default;

function activate(context) {

	let disposable = vscode.commands.registerCommand('add-comment-template.add', function () {
		// 获取当前编辑器
		let editor = vscode.window.activeTextEditor;
		let text=editor.document.getText();
		//解析JavaScript代码
		let ast = parser.parse(text);

		function addComment(node) {
			const params = node.params;
			if (!(params && params.length > 0)) {
				return
			}
			var comments = ['*', "* 该注释由后腿哥为你生成"]
			params.map(param => {
				comments.push(`* @param {*} ${param.name}`)
			})
			comments.push('');
			t.addComment(node, 'leading', comments.join('\n'))
		}


		function hasAddComment(path){
			const leadingComments = path.node.leadingComments;
			if(leadingComments){
				return leadingComments.find(a=>{
					return a.value.indexOf('该注释由后腿哥为你生成')!=-1
				})
			}else{
				return false
			}
		}

		function removeLeadingComments(path){
			const leadingComments = path.node.leadingComments;
			if (leadingComments && leadingComments.length > 0) {
				path.node.leadingComments = [];
			}
		}

		//获取整个编辑器的内容范围
		function getFullRange(editor){
			let document = editor.document;
			let lastLine = document.lineCount - 1;
			let range = new vscode.Range(0, 0, lastLine, document.lineAt(lastLine).text.length);
			return range
		}


		// 遍历AST树
		traverse(ast, {
			//查找类方法
			ClassMethod(path) {
				addComment(path.node)
			},
			//查找函数声明
			FunctionDeclaration(path) {
				if(hasAddComment(path)){
					removeLeadingComments(path)
				}else{
					addComment(path.node)
				}
			}
		});

		// 生成JavaScript代码并替换编辑器中的文本
		let newCode = generator(ast).code;


		editor.edit((editBuilder) => {
			editBuilder.replace(getFullRange(editor), newCode);
		});
	});

	context.subscriptions.push(disposable);
}

function deactivate() { }

module.exports = {
	activate,
	deactivate
}

总结

通过本文的示例,我们展示了如何使用AST开发自定义插件。我们从面试的要求开始,逐步实现了一个给JS函数添加注释的插件,并通过Babel进行代码转换。AST作为强大的工具,让我们能够深入分析和修改代码,为我们定制化的需求提供了解决方案。

AST在前端工具和插件开发中有广泛的应用,它可以用于自动化任务、代码检查、性能优化等方面。掌握AST的使用将有助于提升我们的开发效率和代码质量。

希望本文能够对您理解和应用AST有所帮助。通过学习和实践,您可以发现更多有趣和实用的AST应用场景。祝您在前端开发的旅程中取得更多的成就!

也期待与大家的交流!