找回密码
 立即注册
首页 业界区 业界 探索 TypeScript 编程的利器:ts-morph 入门与实践 ...

探索 TypeScript 编程的利器:ts-morph 入门与实践

段一璇 前天 18:16
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:贝儿
背景

在开发 web IDE 中生成代码大纲的功能时, 发现自己对 TypeScript 的了解知之甚少,以至于针对该功能的实现没有明确的思路。究其原因,平时的工作只停留在 TypeScript 使用类型定义的阶段,导致缺乏对 TypeScript 更深的了解, 所以本次通过 ts-morph 的学习,对 TypeScript 相关内容初步深入;
基础

TypeScript 如何转译成 JavaScript ?
  1. // typescript -> javascript
  2. // 执行 tsc greet.ts
  3. function greet(name: string) {
  4.   return "Hello," + name;
  5. }
  6. const user = "TypeScript";
  7. console.log(greet(user));
  8. // 定义一个箭头函数
  9. const welcome = (name: string) => {
  10.   console.log(`Welcome ${name}`);
  11. };
  12. welcome(user);
复制代码
  1. // typescript -> javascript
  2. function greet(name) {
  3.   // 类型擦除
  4.   return "Hello," + name;
  5. }
  6. var user = "TypeScript";
  7. console.log(greet(user));
  8. // 定义一个箭头函数
  9. var welcome = function (name) {
  10.   // 箭头函数转普通函数
  11.   // ts --traget 没有指定版本则转译成字符串拼接
  12.   console.log("Welcome ".concat(name)); // 字符串拼接
  13. };
  14. welcome(user);
复制代码
大致的流程:
1.png

tsconfig.json 的作用?

如果一个目录下存在 tsconfig.json 文件,那么它意味着这个目录是 TypeScript 项目的根目录。 tsconfig.json 文件中指定了用来编译这个项目的根文件和编译选项。
  1. // 例如执行: tsc --init, 生成默认 tsconfig.json 文件, 其中包含主要配置
  2. {
  3.   "compilerOptions": {
  4.      "target": "es2016",
  5.      "module": "commonjs",
  6.      "outDir": "./dist",
  7.      "esModuleInterop": true,
  8.      "strict": true,
  9.      "skipLibCheck": true
  10.   }
  11.   // 自行配置例如:
  12.   "includes": ["src/**/*"]
  13.   "exclude": ["node_modules", "dist", "src/public/**/*"],
  14. }
复制代码
什么是 AST?

在计算机科学中,抽象语法树 (Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。
Declaration

声明节点,是特定类型的节点,在程序中具有语义作用, 用来引入新的标识。
  1. function IAmFunction() {
  2.   return 1;
  3. } // ---函数声明
复制代码
2.png

Statement

语句节点, 语句时执行某些操作的一段代码。
  1. const a = IAmFunction(); // 执行语句
复制代码
3.png

Expression
  1. const a = function IAmFunction(a: number, b: number) {
  2.   return a + b;
  3. }; // -- 函数表达式
复制代码
4.png

TypeScript Compiler API 中几乎提供了所有编译相关的 API, 可以进行了类似 tsc 的行为,但是 API 较为底层, 上手成本比较困难, 这个时候就要引出我们的利器: ts-morph , 让 AST 操作更加简单一些。
介绍

ts-morph 是一个功能强大的 TypeScript 工具库,它对 TypeScript 编译器的 API 进行了封装,提供更加友好的 API 接口。可以轻松地访问 AST,完成各种类型的代码操作,例如重构、生成、检查和分析等。
源文件

源文件(SourceFile):一棵抽象语法树的根节点。
  1. import { Project } from "ts-morph";
  2. const project = new Project({});
  3. // 创建 ts 文件
  4. const myClassFile = project.createSourceFile(
  5.   "./sourceFiles/MyClass.ts",
  6.   "export class MyClass {}"
  7. );
  8. // 保存在本地
  9. myClassFile.save();
  10. // 获取源文件
  11. const sourceFiles = project.getSourceFiles();
  12. // 提供 filePath 获取源文件
  13. const personFile = project.getSourceFile("Models/Person.ts");
  14. // 根据条件 获取满足条件的源文件
  15. const fileWithFiveClasses = project.getSourceFile(
  16.   (f) => f.getClasses().length === 5
  17. );
复制代码
诊断

5.png
  1. // 1.添加源文件到 Project 对象中
  2. const myBaseFile = project.addSourceFileAtPathIfExists("./sourceFiles/base.ts");
  3. // 调用诊断方法
  4. const sourceFileDiagnostics = myBaseFile?.getPreEmitDiagnostics();
  5. // 优化诊断
  6. const diagnostics =
  7.   sourceFileDiagnostics &&
  8.   project.formatDiagnosticsWithColorAndContext(sourceFileDiagnostics);
  9. // 获取诊断 message
  10. const message = sourceFileDiagnostics?.[0]?.getMessageText();
  11. // 获取报错文件类
  12. const sourceFile = sourceFileDiagnostics?.[0]?.getSourceFile();
  13. //...
复制代码
操作
  1. // 源文件操作
  2. // 重命名
  3. const project = new Project();
  4. project.addSourceFilesAtPaths("./sourceFiles/compiler.ts");
  5. const sourceFile = project.getSourceFile("./sourceFiles/compiler.ts");
  6. const myEnum = sourceFile?.getEnum("MyEnum");
  7. myEnum?.rename("NewEnum");
  8. sourceFile?.save();
  9. // 移除
  10. const member = sourceFile?.getEnum("NewEnum")!.getMember("myMember")!;
  11. member?.remove();
  12. sourceFile?.save();
  13. // 结构
  14. const classDe = sourceFile?.getClass("Test");
  15. const classStructure = classDe?.getStructure();
  16. console.log("classStructure", classStructure);
  17. // 顺序
  18. const interfaceDeclaration = sourcefile?.getInterfaceOrThrow("MyInterface");
  19. interfaceDeclaration?.setOrder(1);
  20. sourcefile?.save();
  21. // 代码书写
  22. const funcDe = sourceFile?.forEachChild((node) => {
  23.   if (Node.isFunctionDeclaration(node)) {
  24.     return node;
  25.   }
  26.   return undefined;
  27. });
  28. console.log("funcDe", funcDe);
  29. funcDe?.setBodyText((writer) =>
  30.   writer
  31.     .writeLine("let myNumber = 5;")
  32.     .write("if (myNumber === 5)")
  33.     .block(() => {
  34.       writer.writeLine("console.log('yes')");
  35.     })
  36. );
  37. sourceFile?.save();
  38. // 操作 AST 转化
  39. const sourceFile2 = project.createSourceFile(
  40.   "Example.ts",
  41.   `
  42.   class C1 {
  43.       myMethod() {
  44.           function nestedFunction() {
  45.           }
  46.       }
  47.   }
  48.   class C2 {
  49.       prop1: string;
  50.   }
  51.   function f1() {
  52.       console.log("1");
  53.       function nestedFunction() {
  54.       }
  55.   }`
  56. );
  57. sourceFile2.transform((traversal) => {
  58.   // this will skip visiting the children of the classes
  59.   if (ts.isClassDeclaration(traversal.currentNode))
  60.     return traversal.currentNode;
  61.   const node = traversal.visitChildren();
  62.   if (ts.isFunctionDeclaration(node)) {
  63.     return traversal.factory.updateFunctionDeclaration(
  64.       node,
  65.       [],
  66.       undefined,
  67.       traversal.factory.createIdentifier("newName"),
  68.       [],
  69.       [],
  70.       undefined,
  71.       traversal.factory.createBlock([])
  72.     );
  73.   }
  74.   return node;
  75. });
  76. sourceFile2.save();
复制代码
提出问题: 引用后重命名是否获取的到? 例如: 通过操作 enum 类型, 如果变量是别名的话,是否也可以进行替换操作?
源文件如下:
  1. // 引用后重命名是否获取的到?
  2. // 操作 AST 文件
  3. import { Project, Node, ts } from "ts-morph";
  4. // 操作
  5. // 设置
  6. // 重命名
  7. const project = new Project();
  8. project.addSourceFilesAtPaths("./sourceFiles/compiler.ts");
  9. const sourceFile = project.getSourceFile("./sourceFiles/compiler.ts");
  10. const myEnum = sourceFile?.getEnum("MyEnum");
  11. console.log("myEnum", myEnum); // 返回 undefined
  12. // -------------------------
  13. // compier.ts 文件
  14. import { a as MyEnum } from "../src/";
  15. interface IText {}
  16. export default class Test {
  17.   constructor() {
  18.     const a: IText = {};
  19.   }
  20. }
  21. const a = new Test();
  22. enum NewEnum {
  23.   myMember,
  24. }
  25. const myVar = NewEnum.myMember;
  26. function getText() {
  27.   let myNumber = 5;
  28.   if (myNumber === 5) {
  29.     console.log("yes");
  30.   }
  31. }
  32. // src/index.ts 文件
  33. export enum a {}
复制代码
分析原因:
compile.ts 在 ts-ast-viewer 中的结构如下:
6.png

而源代码中查找 MyEnum 的调用方法是获取 getEnum("MyEnum"),通过 ts-morph 源码实现可以看到, getEnum 方法通过判断是否为 EnumDeclaration 节点进行过滤。
7.png

据此可以得出下面语句为 importDeclaration 类型,所以是获取不到的。
  1. import { a as MyEnum } from "../src/";
复制代码
同时,针对是否会先将 src/index.ts 中 a 的代码导入,再进行查找?
这就涉及到代码执行的全流程:

  • 静态解析阶段;
  • 编译阶段;
ts-ast-viewer 获取的 ast 实际上是静态解析阶段, 是不涉及代码的运行, 其实是通过 import a from b 创建了 模块之间的联系, 从而构建 AST, 所以更本不会在静态解析的阶段上获取 index 文件中的 a 变量;
而实际上将 a 中的枚举 真正的导入的流程, 在于

  • 编译阶段: 识别 import , 创建模块依赖图;
  • 加载阶段: 加载模块内容;
  • 链接阶段: 加载模块后,编译器会链接模块,这意味着解析模块导出和导入之间的关系,确保每个导入都能正确地关联到其对应的导出;
  • 执行阶段: 最后执行, 以为折模块世纪需要的时候会被执行;
实践

利器 1: Outline 代码大纲

8.gif

从 vscode 代码大纲的展示入手, 实现步骤如下:
9.png
  1. // 调用获取 treeData
  2. export function getASTNode(fileName: string, sourceFileText: string): IDataSource {
  3.     const project = new Project({ useInMemoryFileSystem: true });
  4.     const sourceFile = project.createSourceFile('./test.tsx', sourceFileText);
  5.     let tree: IDataSource = {
  6.         id: -1,
  7.         type: 'root',
  8.         name: fileName,
  9.         children: [],
  10.         canExpended: true,
  11.     };
  12.     sourceFile.forEachChild(node => {
  13.         getNodeItem(node, tree)
  14.     })
  15.     return tree;
  16. }
  17. // getNodeItem 针对 AST 操作不同的语法类型,获取想要展示的数据
  18. function getNodeItem(node: Node, tree: IDataSource) {
  19.     const type = node.getKind();
  20.     switch (type) {
  21.         case SyntaxKind.ImportDeclaration:
  22.             break;
  23.         case SyntaxKind.FunctionDeclaration:
  24.             {
  25.                 const name = (node as DeclarationNode).getName();
  26.                 const icon = `symbol-${AST_TYPE_ICON[type]}`;
  27.                 const start = node.getStartLineNumber();
  28.                 const end = node.getEndLineNumber();
  29.                 const statements = (node as FunctionDeclaration).getStatements();
  30.                 if (statements?.length) {
  31.                     const canExpended = !!statements.filter(sts => Object.keys(AST_TYPE_ICON)?.includes(`${sts?.getKind()}`))?.length
  32.                     const node = { id: count++, name, type: icon, start, end, canExpended, children: [] };
  33.                     tree.children && tree.children.push(node);
  34.                     statements?.forEach((item) => getNodeItem(item, node));
  35.                 }
  36.                 break;
  37.             }
  38.       ... // 其他语法类型的节点进行处理
  39.     }
  40. }
复制代码
利器 2: 检查代码

举例: 检查源文件中不能包含函数表达式,目前的应用场景可能比较极端。
  1. const project = new Project();
  2. const sourceFiles = project.addSourceFilesAtPaths("./sourceFiles/*.ts");
  3. const errList: string[] = [];
  4. sourceFiles?.forEach((file) =>
  5.   file.transform((traversal) => {
  6.     const node = traversal.visitChildren(); // return type is `ts.Node`
  7.     if (ts.isVariableDeclaration(node)) {
  8.       if (node.initializer && ts.isFunctionExpression(node.initializer)) {
  9.         const filePath = file.getFilePath();
  10.         console.log(`No function expression allowed.Found function expression: ${node.name.getText()}
  11.             File: ${filePath}`);
  12.         errList.push(filePath);
  13.       }
  14.     }
  15.     return node;
  16.   })
  17. );
复制代码
10.png

利器 3: jsDoc 生成

举例: 通过接口定义生成 props 传参的注释文档。
  1. 可以尝试一下api 进行组合使用
  2. /** 举个例子
  3. * Gets the name.
  4. * @param person - Person to get the name from.
  5. */
  6. function getName(person: Person) {
  7.   // ...
  8. }
  9. // 获取所有
  10. functionDeclaration.getJsDocs(); // returns: JSDoc[]
  11. // 创建 注释
  12. classDeclaration.addJsDoc({
  13.   description: "Some description...",
  14.   tags: [{
  15.     tagName: "param",
  16.     text: "value - My value.",
  17.   }],
  18. });
  19. // 获取描述
  20. const jsDoc = functionDeclaration.getJsDocs()[0];
  21. jsDoc.getDescription(); // returns string: "Gets the name."
  22. // 获取 tags
  23. const tags = jsDoc.getTags();
  24. tags[0].getText(); // "@param person - Person to get the name from."
  25. // 获取 jsDoc 内容
  26. sDoc.getInnerText(); // "Gets the name.\n@param person - Person to get the name from."
复制代码
参考


  • ts-morph 官网
  • TypeScript AST Viewer
  • typeScript 官网
  • typescript 编译 API
  • TypeScript / How the compiler compiles
最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

  • 大数据分布式任务调度系统——Taier
  • 轻量级的 Web IDE UI 框架——Molecule
  • 针对大数据领域的 SQL Parser 项目——dt-sql-parser
  • 袋鼠云数栈前端团队代码评审工程实践文档——code-review-practices
  • 一个速度更快、配置更灵活、使用更简单的模块打包器——ko
  • 一个针对 antd 的组件测试工具库——ant-design-testing

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册