探索 TypeScript 編程的利器:ts-morph 入門與實(shí)踐

背景

在開發(fā) web IDE 中生成代碼大綱的功能時(shí), 發(fā)現(xiàn)自己對(duì) TypeScript 的了解知之甚少,以至于針對(duì)該功能的實(shí)現(xiàn)沒有明確的思路。究其原因,平時(shí)的工作只停留在 TypeScript 使用類型定義的階段,導(dǎo)致缺乏對(duì) TypeScript 更深的了解, 所以本次通過 ts-morph 的學(xué)習(xí),對(duì) TypeScript 相關(guān)內(nèi)容初步深入;

基礎(chǔ)

TypeScript 如何轉(zhuǎn)譯成 JavaScript ?

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

const user = "TypeScript";

console.log(greet(user));

// 定義一個(gè)箭頭函數(shù)
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));
// 定義一個(gè)箭頭函數(shù)
var welcome = function (name) {
  // 箭頭函數(shù)轉(zhuǎn)普通函數(shù)
  // ts --traget 沒有指定版本則轉(zhuǎn)譯成字符串拼接
  console.log("Welcome ".concat(name)); // 字符串拼接
};
welcome(user);

大致的流程:


file

tsconfig.json 的作用?

如果一個(gè)目錄下存在 tsconfig.json 文件,那么它意味著這個(gè)目錄是 TypeScript 項(xiàng)目的根目錄。 tsconfig.json 文件中指定了用來編譯這個(gè)項(xiàng)目的根文件和編譯選項(xiàng)。

// 例如執(zhí)行: tsc --init, 生成默認(rèn) tsconfig.json 文件, 其中包含主要配置
{
  "compilerOptions": {
     "target": "es2016",
     "module": "commonjs",
     "outDir": "./dist",
     "esModuleInterop": true,
     "strict": true,
     "skipLibCheck": true
  }
  // 自行配置例如:
  "includes": ["src/**/*"]
  "exclude": ["node_modules", "dist", "src/public/**/*"],
}

什么是 AST?

計(jì)算機(jī)科學(xué)中,抽象語法樹 (Abstract Syntax Tree,AST),或簡(jiǎn)稱語法樹(Syntax tree),是源代碼語法結(jié)構(gòu)的一種抽象表示。它以樹狀的形式表現(xiàn)編程語言的語法結(jié)構(gòu),樹上的每個(gè)節(jié)點(diǎn)都表示源代碼中的一種結(jié)構(gòu)。之所以說語法是“抽象”的,是因?yàn)檫@里的語法并不會(huì)表示出真實(shí)語法中出現(xiàn)的每個(gè)細(xì)節(jié)。

Declaration

聲明節(jié)點(diǎn),是特定類型的節(jié)點(diǎn),在程序中具有語義作用, 用來引入新的標(biāo)識(shí)。

function IAmFunction() {
  return 1;
} // ---函數(shù)聲明
file

Statement

語句節(jié)點(diǎn), 語句時(shí)執(zhí)行某些操作的一段代碼。

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

Expression

const a = function IAmFunction(a: number, b: number) {
  return a + b;
}; // -- 函數(shù)表達(dá)式
file

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

介紹

ts-morph 是一個(gè)功能強(qiáng)大的 TypeScript 工具庫(kù),它對(duì) TypeScript 編譯器的 API 進(jìn)行了封裝,提供更加友好的 API 接口??梢暂p松地訪問 AST,完成各種類型的代碼操作,例如重構(gòu)、生成、檢查和分析等。

源文件

源文件(SourceFile):一棵抽象語法樹的根節(jié)點(diǎn)。

import { Project } from "ts-morph";

const project = new Project({});
// 創(chuàng)建 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");
// 根據(jù)條件 獲取滿足條件的源文件
const fileWithFiveClasses = project.getSourceFile(
  (f) => f.getClasses().length === 5
);

診斷

file
// 1.添加源文件到 Project 對(duì)象中
const myBaseFile = project.addSourceFileAtPathIfExists("./sourceFiles/base.ts");
// 調(diào)用診斷方法
const sourceFileDiagnostics = myBaseFile?.getPreEmitDiagnostics();
// 優(yōu)化診斷
const diagnostics =
  sourceFileDiagnostics &&
  project.formatDiagnosticsWithColorAndContext(sourceFileDiagnostics);
// 獲取診斷 message
const message = sourceFileDiagnostics?.[0]?.getMessageText();
// 獲取報(bào)錯(cuò)文件類
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();

// 結(jié)構(gòu)
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 轉(zhuǎn)化
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 類型, 如果變量是別名的話,是否也可以進(jìn)行替換操作?

源文件如下:

// 引用后重命名是否獲取的到?
// 操作 AST 文件
import { Project, Node, ts } from "ts-morph";
// 操作
// 設(shè)置
// 重命名
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 中的結(jié)構(gòu)如下:


file

而源代碼中查找 MyEnum 的調(diào)用方法是獲取 getEnum("MyEnum"),通過 ts-morph 源碼實(shí)現(xiàn)可以看到, getEnum 方法通過判斷是否為 EnumDeclaration 節(jié)點(diǎn)進(jìn)行過濾。


file

據(jù)此可以得出下面語句為 importDeclaration 類型,所以是獲取不到的。

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

同時(shí),針對(duì)是否會(huì)先將 src/index.ts 中 a 的代碼導(dǎo)入,再進(jìn)行查找?
這就涉及到代碼執(zhí)行的全流程:

  1. 靜態(tài)解析階段;
  2. 編譯階段;

ts-ast-viewer 獲取的 ast 實(shí)際上是靜態(tài)解析階段, 是不涉及代碼的運(yùn)行, 其實(shí)是通過 import a from b 創(chuàng)建了 模塊之間的聯(lián)系, 從而構(gòu)建 AST, 所以更本不會(huì)在靜態(tài)解析的階段上獲取 index 文件中的 a 變量;

而實(shí)際上將 a 中的枚舉 真正的導(dǎo)入的流程, 在于

  1. 編譯階段: 識(shí)別 import , 創(chuàng)建模塊依賴圖;
  2. 加載階段: 加載模塊內(nèi)容;
  3. 鏈接階段: 加載模塊后,編譯器會(huì)鏈接模塊,這意味著解析模塊導(dǎo)出和導(dǎo)入之間的關(guān)系,確保每個(gè)導(dǎo)入都能正確地關(guān)聯(lián)到其對(duì)應(yīng)的導(dǎo)出;
  4. 執(zhí)行階段: 最后執(zhí)行, 以為折模塊世紀(jì)需要的時(shí)候會(huì)被執(zhí)行;

實(shí)踐

利器 1: Outline 代碼大綱

file

從 vscode 代碼大綱的展示入手, 實(shí)現(xiàn)步驟如下:

file
// 調(diào)用獲取 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 針對(duì) AST 操作不同的語法類型,獲取想要展示的數(shù)據(jù)
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;
            }
      ... // 其他語法類型的節(jié)點(diǎn)進(jìn)行處理
    }
}

利器 2: 檢查代碼

舉例: 檢查源文件中不能包含函數(shù)表達(dá)式,目前的應(yīng)用場(chǎng)景可能比較極端。

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;
  })
);
file

利器 3: jsDoc 生成

舉例: 通過接口定義生成 props 傳參的注釋文檔。

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

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

// 創(chuàng)建 注釋
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 內(nèi)容
sDoc.getInnerText(); // "Gets the name.\n@param person - Person to get the name from."
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容