使用ts-morph进行TypeScript AST操作

2,084 阅读8分钟

简介

在 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库,可以帮助您更轻松地管理和操作代码。