探索 TypeScript 程式設計的利器:ts-morph 入門與實踐

袋鼠云数栈UED發表於2024-11-29

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:貝兒

背景

在開發 web IDE 中生成程式碼大綱的功能時, 發現自己對 TypeScript 的瞭解知之甚少,以至於針對該功能的實現沒有明確的思路。究其原因,平時的工作只停留在 TypeScript 使用型別定義的階段,導致缺乏對 TypeScript 更深的瞭解, 所以本次透過 ts-morph 的學習,對 TypeScript 相關內容初步深入;

基礎

TypeScript 如何轉譯成 JavaScript ?

// typescript -> javascript
// 執行 tsc greet.ts
function greet(name: string) {
  return "Hello," + name;
}

const user = "TypeScript";

console.log(greet(user));

// 定義一個箭頭函式
const welcome = (name: string) => {
  console.log(`Welcome ${name}`);
};

welcome(user);
// typescript -> javascript
function greet(name) {
  // 型別擦除
  return "Hello," + name;
}
var user = "TypeScript";
console.log(greet(user));
// 定義一個箭頭函式
var welcome = function (name) {
  // 箭頭函式轉普通函式
  // ts --traget 沒有指定版本則轉譯成字串拼接
  console.log("Welcome ".concat(name)); // 字串拼接
};
welcome(user);

大致的流程:
1129_1.png

tsconfig.json 的作用?

如果一個目錄下存在 tsconfig.json 檔案,那麼它意味著這個目錄是 TypeScript 專案的根目錄。 tsconfig.json 檔案中指定了用來編譯這個專案的根檔案和編譯選項。
// 例如執行: tsc --init, 生成預設 tsconfig.json 檔案, 其中包含主要配置
{
  "compilerOptions": {
     "target": "es2016",
     "module": "commonjs",
     "outDir": "./dist",
     "esModuleInterop": true,
     "strict": true,
     "skipLibCheck": true
  }
  // 自行配置例如:
  "includes": ["src/**/*"]
  "exclude": ["node_modules", "dist", "src/public/**/*"],
}

什麼是 AST?

電腦科學中,抽象語法樹 (Abstract Syntax Tree,AST),或簡稱語法樹(Syntax tree),是原始碼語法結構的一種抽象表示。它以樹狀的形式表現程式語言的語法結構,樹上的每個節點都表示原始碼中的一種結構。之所以說語法是“抽象”的,是因為這裡的語法並不會表示出真實語法中出現的每個細節。

Declaration

宣告節點,是特定型別的節點,在程式中具有語義作用, 用來引入新的標識。

function IAmFunction() {
  return 1;
} // ---函式宣告

1129_2.png

Statement

語句節點, 語句時執行某些操作的一段程式碼。

const a = IAmFunction(); // 執行語句

1129_3.png

Expression

const a = function IAmFunction(a: number, b: number) {
  return a + b;
}; // -- 函式表示式

1129_4.png

TypeScript Compiler API 中幾乎提供了所有編譯相關的 API, 可以進行了類似 tsc 的行為,但是 API 較為底層, 上手成本比較困難, 這個時候就要引出我們的利器: ts-morph , 讓 AST 操作更加簡單一些。

介紹

ts-morph 是一個功能強大的 TypeScript 工具庫,它對 TypeScript 編譯器的 API 進行了封裝,提供更加友好的 API 介面。可以輕鬆地訪問 AST,完成各種型別的程式碼操作,例如重構、生成、檢查和分析等。

原始檔

原始檔(SourceFile):一棵抽象語法樹的根節點。

import { Project } from "ts-morph";

const project = new Project({});
// 建立 ts 檔案
const myClassFile = project.createSourceFile(
  "./sourceFiles/MyClass.ts",
  "export class MyClass {}"
);
// 儲存在本地
myClassFile.save();

// 獲取原始檔
const sourceFiles = project.getSourceFiles();
// 提供 filePath 獲取原始檔
const personFile = project.getSourceFile("Models/Person.ts");
// 根據條件 獲取滿足條件的原始檔
const fileWithFiveClasses = project.getSourceFile(
  (f) => f.getClasses().length === 5
);

診斷

1129_5.png

// 1.新增原始檔到 Project 物件中
const myBaseFile = project.addSourceFileAtPathIfExists("./sourceFiles/base.ts");
// 呼叫診斷方法
const sourceFileDiagnostics = myBaseFile?.getPreEmitDiagnostics();
// 最佳化診斷
const diagnostics =
  sourceFileDiagnostics &&
  project.formatDiagnosticsWithColorAndContext(sourceFileDiagnostics);
// 獲取診斷 message
const message = sourceFileDiagnostics?.[0]?.getMessageText();
// 獲取報錯檔案類
const sourceFile = sourceFileDiagnostics?.[0]?.getSourceFile();
//...

操作

// 原始檔操作
// 重新命名
const project = new Project();
project.addSourceFilesAtPaths("./sourceFiles/compiler.ts");
const sourceFile = project.getSourceFile("./sourceFiles/compiler.ts");
const myEnum = sourceFile?.getEnum("MyEnum");
myEnum?.rename("NewEnum");
sourceFile?.save();
// 移除
const member = sourceFile?.getEnum("NewEnum")!.getMember("myMember")!;
member?.remove();
sourceFile?.save();

// 結構
const classDe = sourceFile?.getClass("Test");
const classStructure = classDe?.getStructure();
console.log("classStructure", classStructure);

// 順序
const interfaceDeclaration = sourcefile?.getInterfaceOrThrow("MyInterface");
interfaceDeclaration?.setOrder(1);
sourcefile?.save();

// 程式碼書寫
const funcDe = sourceFile?.forEachChild((node) => {
  if (Node.isFunctionDeclaration(node)) {
    return node;
  }
  return undefined;
});
console.log("funcDe", funcDe);
funcDe?.setBodyText((writer) =>
  writer
    .writeLine("let myNumber = 5;")
    .write("if (myNumber === 5)")
    .block(() => {
      writer.writeLine("console.log('yes')");
    })
);
sourceFile?.save();

// 操作 AST 轉化
const sourceFile2 = project.createSourceFile(
  "Example.ts",
  `
  class C1 {
      myMethod() {
          function nestedFunction() {
          }
      }
  }

  class C2 {
      prop1: string;
  }

  function f1() {
      console.log("1");

      function nestedFunction() {
      }
  }`
);

sourceFile2.transform((traversal) => {
  // this will skip visiting the children of the classes
  if (ts.isClassDeclaration(traversal.currentNode))
    return traversal.currentNode;

  const node = traversal.visitChildren();
  if (ts.isFunctionDeclaration(node)) {
    return traversal.factory.updateFunctionDeclaration(
      node,
      [],
      undefined,
      traversal.factory.createIdentifier("newName"),
      [],
      [],
      undefined,
      traversal.factory.createBlock([])
    );
  }
  return node;
});

sourceFile2.save();

提出問題: 引用後重新命名是否獲取的到? 例如: 透過操作 enum 型別, 如果變數是別名的話,是否也可以進行替換操作?

原始檔如下:

// 引用後重新命名是否獲取的到?
// 操作 AST 檔案
import { Project, Node, ts } from "ts-morph";
// 操作
// 設定
// 重新命名
const project = new Project();
project.addSourceFilesAtPaths("./sourceFiles/compiler.ts");
const sourceFile = project.getSourceFile("./sourceFiles/compiler.ts");
const myEnum = sourceFile?.getEnum("MyEnum");
console.log("myEnum", myEnum); // 返回 undefined
// -------------------------
// compier.ts 檔案
import { a as MyEnum } from "../src/";
interface IText {}
export default class Test {
  constructor() {
    const a: IText = {};
  }
}

const a = new Test();

enum NewEnum {
  myMember,
}

const myVar = NewEnum.myMember;

function getText() {
  let myNumber = 5;
  if (myNumber === 5) {
    console.log("yes");
  }
}
// src/index.ts 檔案
export enum a {}

分析原因:
compile.ts 在 ts-ast-viewer 中的結構如下:
1129_6.png

而原始碼中查詢 MyEnum 的呼叫方法是獲取 getEnum("MyEnum"),透過 ts-morph 原始碼實現可以看到, getEnum 方法透過判斷是否為 EnumDeclaration 節點進行過濾。
1129_7.png
據此可以得出下面語句為 importDeclaration 型別,所以是獲取不到的。

import { a as MyEnum } from "../src/"; 

同時,針對是否會先將 src/index.ts 中 a 的程式碼匯入,再進行查詢?
這就涉及到程式碼執行的全流程:

  1. 靜態解析階段;
  2. 編譯階段;

ts-ast-viewer 獲取的 ast 實際上是靜態解析階段, 是不涉及程式碼的執行, 其實是透過 import a from b 建立了 模組之間的聯絡, 從而構建 AST, 所以更本不會在靜態解析的階段上獲取 index 檔案中的 a 變數;

而實際上將 a 中的列舉 真正的匯入的流程, 在於

  1. 編譯階段: 識別 import , 建立模組依賴圖;
  2. 載入階段: 載入模組內容;
  3. 連結階段: 載入模組後,編譯器會連結模組,這意味著解析模組匯出和匯入之間的關係,確保每個匯入都能正確地關聯到其對應的匯出;
  4. 執行階段: 最後執行, 以為折模組世紀需要的時候會被執行;

實踐

利器 1: Outline 程式碼大綱

2024-11-29 15.45.34.gif

從 vscode 程式碼大綱的展示入手, 實現步驟如下:

1129_8.png

// 呼叫獲取 treeData
export function getASTNode(fileName: string, sourceFileText: string): IDataSource {
    const project = new Project({ useInMemoryFileSystem: true });
    const sourceFile = project.createSourceFile('./test.tsx', sourceFileText);
    let tree: IDataSource = {
        id: -1,
        type: 'root',
        name: fileName,
        children: [],
        canExpended: true,
    };
    sourceFile.forEachChild(node => {
        getNodeItem(node, tree)
    })
    return tree;
}

// getNodeItem 針對 AST 操作不同的語法型別,獲取想要展示的資料
function getNodeItem(node: Node, tree: IDataSource) {
    const type = node.getKind();
    switch (type) {
        case SyntaxKind.ImportDeclaration:
            break;
        case SyntaxKind.FunctionDeclaration:
            {
                const name = (node as DeclarationNode).getName();
                const icon = `symbol-${AST_TYPE_ICON[type]}`;
                const start = node.getStartLineNumber();
                const end = node.getEndLineNumber();
                const statements = (node as FunctionDeclaration).getStatements();
                if (statements?.length) {
                    const canExpended = !!statements.filter(sts => Object.keys(AST_TYPE_ICON)?.includes(`${sts?.getKind()}`))?.length
                    const node = { id: count++, name, type: icon, start, end, canExpended, children: [] };
                    tree.children && tree.children.push(node);
                    statements?.forEach((item) => getNodeItem(item, node));
                }
                break;
            }
      ... // 其他語法型別的節點進行處理
    }
}

利器 2: 檢查程式碼

舉例: 檢查原始檔中不能包含函式表示式,目前的應用場景可能比較極端。

const project = new Project();

const sourceFiles = project.addSourceFilesAtPaths("./sourceFiles/*.ts");

const errList: string[] = [];

sourceFiles?.forEach((file) =>
  file.transform((traversal) => {
    const node = traversal.visitChildren(); // return type is `ts.Node`
    if (ts.isVariableDeclaration(node)) {
      if (node.initializer && ts.isFunctionExpression(node.initializer)) {
        const filePath = file.getFilePath();
        console.log(`No function expression allowed.Found function expression: ${node.name.getText()}
            File: ${filePath}`);
        errList.push(filePath);
      }
    }
    return node;
  })
);

1129_9.png

利器 3: jsDoc 生成

舉例: 透過介面定義生成 props 傳參的註釋文件。

可以嘗試一下api 進行組合使用
 /** 舉個例子
 * Gets the name.
 * @param person - Person to get the name from.
 */
function getName(person: Person) {
  // ...
}

// 獲取所有
functionDeclaration.getJsDocs(); // returns: JSDoc[]

// 建立 註釋
classDeclaration.addJsDoc({
  description: "Some description...",
  tags: [{
    tagName: "param",
    text: "value - My value.",
  }],
});

// 獲取描述
const jsDoc = functionDeclaration.getJsDocs()[0];
jsDoc.getDescription(); // returns string: "Gets the name."

// 獲取 tags
const tags = jsDoc.getTags();
tags[0].getText(); // "@param person - Person to get the name from."

// 獲取 jsDoc 內容
sDoc.getInnerText(); // "Gets the name.\n@param person - Person to get the name from."

參考

  1. ts-morph 官網
  2. TypeScript AST Viewer
  3. typeScript 官網
  4. typescript 編譯 API
  5. 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

相關文章