使用ts-morph进行TypeScript AST操作
简介
在 TypeScript 中引入了 TS Compiler API,使得开发人员可以通过编程的方式访问和操作 TypeScript 抽象语法树(AST)。TS Compiler API 提供了一系列的 API 接口,可以帮助我们分析、修改和生成 TypeScript 代码。不过 TS Compiler API 的使用门槛较高,需要比较深厚的 TypeScript 知识储备。
为了简化这个过程,社区出现了一些基于 TS Compiler API 的工具,如 ts-simple-ast、dprint、ttypescript 等,而 ts-morph 就是其中之一,它提供了更加友好的 API 接口,并且可以轻松地访问 AST,完成各种类型的代码操作,例如重构、生成、检查和分析等。
除此之外,还有在线 TS AST 工具:AST Viewer 可以用来快速查看 TypeScript 代码的 AST 结构。
本文将介绍如何使用ts-morph进行TypeScript AST操作,包括以下几个方面:
- 安装ts-morph
- ts-morph的一些常见的基本应用
- 自动生成文档
安装ts-morph
安装ts-morph非常简单,只需要执行以下命令即可:
npm install ts-morph --save-dev
常见的基本应用
创建TypeScript项目
在使用ts-morph之前,我们首先需要创建一个TypeScript项目,并将所有的源文件添加到项目中。假设我们已经有了一个tsconfig.json文件,其中包含了项目中所有的源文件路径,我们可以使用以下代码将这些源文件加载到ts-morph项目中:
import { Project } from "ts-morph";
// 创建一个TypeScript项目对象
const project = new Project();
// 从文件系统加载tsconfig.json文件,并将其中的所有源文件添加到项目中
project.addSourceFilesAtPaths("./tsconfig.json");
// 获取项目中的所有源文件
const sourceFiles = project.getSourceFiles();
现在我们就可以通过sourceFiles变量来访问项目中的所有源文件。
访问基本元素
在访问TypeScript代码中的基本元素时,ts-morph提供了很多方便的API接口,例如getSourceFiles()、getClasses()、getFunctions()等方法都可以帮助我们快速定位到目标元素的位置,并获取其具体属性和信息。
以下是一些常见的代码示例:
import { Project, SyntaxKind } from "ts-morph";
const project = new Project();
project.addSourceFilesAtPaths("./src/**/*.ts");
const sourceFiles = project.getSourceFiles();
// 获取所有类名
const classNames = sourceFiles.flatMap((sourceFile) =>
sourceFile.getClasses().map((classDecl) => classDecl.getName())
);
console.log(classNames);
// 获取所有函数名
const functionNames = sourceFiles.flatMap((sourceFile) =>
sourceFile.getFunctions().map((funcDecl) => funcDecl.getName())
);
console.log(functionNames);
// 获取所有import语句
const imports = sourceFiles.flatMap((sourceFile) => sourceFile.getImportDeclarations());
imports.forEach((importDecl) => {
console.log(importDecl.getModuleSpecifierValue());
});
// 获取所有变量声明
const variables = sourceFiles.flatMap((sourceFile) => sourceFile.getVariableDeclarations());
variables.forEach((variable) => {
console.log(variable.getName());
});
以上代码演示了如何使用ts-morph访问TypeScript代码中的基本元素,包括类、函数、import语句和变量声明等。
分析依赖关系和调用链
在分析TypeScript项目时,了解源文件之间的依赖关系和调用链非常重要。ts-morph提供了getDependencyGraph()和getCallGraph()两个方法,可以帮助我们分析项目中的依赖关系和调用链信息。
以下是一个分析依赖关系和调用链的代码示例:
import { Project } from "ts-morph";
const project = new Project();
project.addSourceFilesAtPaths("./src/**/*.ts");
// 获取依赖关系
const dependencyGraph = project.getDependencyGraph();
console.log(dependencyGraph.toString());
// 获取调用链
const callGraph = project.getCallGraph();
callGraph.forEach((value, key) => {
console.log(`Function ${key} is called by:`);
value.forEach((caller) => {
console.log(` ${caller}`);
});
});
以上代码演示了如何使用ts-morph分析TypeScript项目中的依赖关系和调用链。在这个示例中,我们首先获取了项目的依赖关系图,并将其转化为一个字符串进行输出;接着,我们获取了项目的调用链信息,并按照函数名逐一输出其调用者。
修改TypeScript代码
在使用ts-morph进行TypeScript AST操作时,最常见的需求之一就是修改已有的TypeScript代码。ts-morph提供了各种API接口,可以帮助我们定位到目标元素,修改其属性或内容,并将修改后的结果保存到文件系统中。
以下是一个修改TypeScript代码的代码示例:
import { Project } from "ts-morph";
const project = new Project();
project.addSourceFilesAtPaths("./src/**/*.ts");
const sourceFiles = project.getSourceFiles();
sourceFiles.forEach((sourceFile) => {
sourceFile.getClasses().forEach((classDecl) => {
classDecl.getMethods().forEach((methodDecl) => {
if (methodDecl.getName() === "doSomething") {
const block = methodDecl.getBody();
const statements = block.getStatements();
const firstStatement = statements[0];
const secondStatement = statements[1];
// 将原来的两行代码合并成一行,并添加注释
firstStatement.replaceWithText(`console.log("Hello, world!"); // Modified by ts-morph`);
secondStatement.remove();
}
});
});
// 将修改后的源文件保存到文件系统中
sourceFile.saveSync();
});
以上代码演示了如何使用ts-morph修改一个TypeScript源文件中的方法体内容。在这个示例中,我们首先遍历所有类和方法,找到包含名为“doSomething”的方法,并将其第一行和第二行代码修改为一行代码和一个注释;接着,我们将修改后的源文件保存到文件系统中。
生成TypeScript代码
除了修改已有的TypeScript代码之外,有时候我们还需要生成全新的TypeScript代码。ts-morph也提供了非常方便的API接口,可以帮助我们快速生成任意类型的TypeScript代码片段,并将其保存到文件系统中。
以下是一个生成TypeScript代码的代码示例:
import { Project } from "ts-morph";
const project = new Project();
project.createSourceFile(
"./src/generated/HelloWorld.ts",
`export function helloWorld(): void {
console.log("Hello, world!");
}`
);
// 将新生成的源文件保存到文件系统中
project.saveSync();
以上代码演示了如何使用ts-morph生成一个新的TypeScript源文件。在这个示例中,我们通过createSourceFile()方法创建了一个包含打印“Hello, world!”函数定义的TypeScript源文件,并将其保存到文件系统中。
自动生成文档
使用 ts-morph 可以自动生成文档。例如,可以使用 ts-morph 分析 TypeScript 中的 JSDoc,最终生成包含函数名、描述和参数信息的 Markdown 或者 HTML 文档。
下面我们将演示如何使用 ts-morph 自动生成文档。首先,我们需要有一段包含了用户定义的接口和类的 TypeScript 代码,并在其中添加上注释。例如,我们写了一个 Person 接口和一个 Greeter 类,并给它们添加了 JSDoc 注释:
// src/example.ts
/**
* 这是一个用于演示的类
*/
class ExampleClass {
/**
* 这是一个用于演示的方法
* @param name - 姓名
* @param age - 年龄
* @returns 返回一个字符串,表示问候语和年龄
*/
sayHello(name: string, age: number): string {
return `Hello, ${name}! You are ${age} years old.`;
}
}
/**
* 这是一个用于演示的接口
*/
interface ExampleInterface {
/**
* 这是一个用于演示的属性
*/
readonly id: number;
/**
* 这是一个用于演示的方法
* @param x - 第一个参数
* @param y - 第二个参数
* @returns 返回两个参数的和
*/
add(x: number, y: number): number;
}
把上述代码存放在src
目录下,并执行下面的脚本来解析此文件,能够输出相应的API文档:
import * as fs from "fs";
import { Project } from "ts-morph";
const project = new Project({
tsConfigFilePath: "./tsconfig.json",
});
project.addSourceFilesAtPaths("./src/example.ts");
const data = project.getSourceFiles().map((file) => {
const classes = file.getClasses();
const classList = classes.map((cls) => {
const doc = cls.getJsDocs()[0]?.getDescription().trim() || "";
// Get methods
const methodList = cls.getMethods().map((method) => {
const signature = `### ${method.getName()}(${method.getParameters().map((p) => `${p.getName()}: ${p.getType().getText()}`).join(", ")})`;
const description = method.getJsDocs()[0]?.getDescription().trim() || "";
const parameters = method.getParameters().map((p) => {
const paramStructure = p.getStructure();
const paramName = paramStructure.name;
const paramTags = method.getJsDocs()[0]?.getTags()
.filter(tag => tag.getTagName() === "param" && tag.getComment());
const paramJSDoc = paramTags?.map(tag => {
const parts = (tag.getComment() as string).split(/\s+/) ?? [];
const type = parts[0];
const description = parts.slice(1).join(" ");
return `${type}: ${description}`;
})[0] ?? '';
return `\`${paramName}\`: ${paramJSDoc}`;
}).join('\n');
const returnType = method.getReturnTypeNode() ? `\n\n**Return Type:** \`${method.getReturnTypeNode()?.getText()}\`` : "";
return [signature, description, parameters, returnType].filter(Boolean).join("\n");
});
// Get properties
const propertyList = cls.getProperties().map((property) => {
const signature = `### ${property.getName()}`;
const description = property.getJsDocs()[0]?.getDescription().trim() || "";
const type = property.getTypeNode() ? `\n\n**Type:** \`${property.getTypeNode()?.getText()}\`` : "";
return [signature, description, type].filter(Boolean).join("\n");
});
// Combine methods and properties
const memberList = [...methodList, ...propertyList].join("\n\n");
return [`## ${cls.getName()} \n\n${doc}`, memberList].join("\n\n");
});
const interfaces = file.getInterfaces();
const interfaceList = interfaces.map((intf) => {
const doc = intf.getJsDocs()[0]?.getDescription().trim() || "";
// Get methods
const methodList = intf.getMethods().map((method) => {
const signature = `### ${method.getName()}(${method.getParameters().map((p) => `${p.getName()}: ${p.getType().getText()}`).join(", ")})`;
const description = method.getJsDocs()[0]?.getDescription().trim() || "";
const parameters = method.getParameters().map((p) => {
const paramStructure = p.getStructure();
const paramName = paramStructure.name;
const paramTags = method.getJsDocs()[0]?.getTags()
.filter(tag => tag.getTagName() === "param" && tag.getComment());
const paramJSDoc = paramTags?.map(tag => {
const parts = (tag.getComment() as string).split(/\s+/) ?? [];
const type = parts[0];
const description = parts.slice(1).join(" ");
return `${type}: ${description}`;
})[0] ?? '';
return `\`${paramName}\`: ${paramJSDoc}`;
}).join('\n');
const returnType = method.getReturnTypeNode() ? `\n\n**Return Type:** \`${method.getReturnTypeNode()?.getText()}\`` : "";
return [signature, description, parameters, returnType].filter(Boolean).join("\n");
});
// Get properties
const propertyList = intf.getProperties().map((property) => {
const signature = `### ${property.getName()}`;
const description = property.getJsDocs()[0]?.getDescription().trim() || "";
const type = property.getTypeNode() ? `\n\n**Type:** \`${property.getTypeNode()?.getText()}\`` : "";
const readonly = property.isReadonly() ? "\n\n**Readonly**" : "";
return [signature, description, type, readonly].filter(Boolean).join("\n");
});
// Combine methods and properties
const memberList = [...methodList, ...propertyList].join("\n\n");
return [`## ${intf.getName()} \n\n${doc}`, memberList].join("\n\n");
});
return [...classList, ...interfaceList].join("\n\n");
});
fs.writeFileSync("output.md", data.join("\n"));
在这个示例中,我们首先创建了一个项目对象,并添加了 TypeScript 文件。然后,通过调用 getSourceFiles()
方法获取所有源文件,并使用 flatMap()
方法来遍历每个类和接口定义,解析其中的 JSDoc 信息,并格式化成一个通用的 API 文档格式。
然后,我们将 apiDocs
数组转换为 Markdown 格式的文档。对于每个类或接口,我们使用其名称、描述、方法和属性等信息生成 Markdown 文档的各个部分。具体地,我们按照如下格式生成 Markdown 文档:
## [类/接口名]
[类/接口描述]
[方法列表]
[属性列表]
其中,方法列表和属性列表会根据不同的类型生成不同的格式:
- 对于类,方法列表和属性列表都是属于类的,因此我们将它们分别生成为
### [方法名]
和-
[属性名]: [类型][描述]`` 的格式,并按照顺序罗列出来。 - 对于接口,方法列表不存在,我们只需要生成属性列表即可。与类不同的是,由于接口中的属性没有默认值,因此我们不需要在 Markdown 中展示其类型。
最后,我们将生成的 Markdown 文档保存到文件系统中,以便于查看和分享。您可以使用其他工具将 Markdown 转换成 HTML 或者其他格式的文档。
总结
ts-morph是一个非常有用的TypeScript库,它提供了一个简单且直观的API,用于分析、生成和转换TypeScript代码。使用ts-morph,您可以轻松地创建自定义代码生成器、重构工具或其他自动化任务。该库还提供了丰富的类型信息,包括类型检查、符号解析和语法分析等功能,这些都可以帮助您更好地理解和操作TypeScript代码。如果你需要对TypeScript进行重构、格式化、分析、自动生成API文档等操作,ts-morph是一个非常有用的工具。它提供了一组功能强大的API,可以让您轻松地执行这些任务,并且不需要手动处理代码。总之,ts-morph是一个功能强大的TypeScript库,可以帮助您更轻松地管理和操作代码。
转载自:https://juejin.cn/post/7213277254183845945