背景
在開發(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);
大致的流程:

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ù)聲明

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

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

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
);
診斷

// 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)如下:

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

據(jù)此可以得出下面語句為 importDeclaration 類型,所以是獲取不到的。
import { a as MyEnum } from "../src/";
同時(shí),針對(duì)是否會(huì)先將 src/index.ts 中 a 的代碼導(dǎo)入,再進(jìn)行查找?
這就涉及到代碼執(zhí)行的全流程:
- 靜態(tài)解析階段;
- 編譯階段;
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)入的流程, 在于
- 編譯階段: 識(shí)別 import , 創(chuàng)建模塊依賴圖;
- 加載階段: 加載模塊內(nèi)容;
- 鏈接階段: 加載模塊后,編譯器會(huì)鏈接模塊,這意味著解析模塊導(dǎo)出和導(dǎo)入之間的關(guān)系,確保每個(gè)導(dǎo)入都能正確地關(guān)聯(lián)到其對(duì)應(yīng)的導(dǎo)出;
- 執(zhí)行階段: 最后執(zhí)行, 以為折模塊世紀(jì)需要的時(shí)候會(huì)被執(zhí)行;
實(shí)踐
利器 1: Outline 代碼大綱

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

// 調(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;
})
);

利器 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."