我們是袋鼠雲數棧 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);
大致的流程:
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;
} // ---函式宣告
Statement
語句節點, 語句時執行某些操作的一段程式碼。
const a = IAmFunction(); // 執行語句
Expression
const a = function IAmFunction(a: number, b: number) {
return a + b;
}; // -- 函式表示式
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
);
診斷
// 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 中的結構如下:
而原始碼中查詢 MyEnum 的呼叫方法是獲取 getEnum("MyEnum"),透過 ts-morph 原始碼實現可以看到, getEnum 方法透過判斷是否為 EnumDeclaration 節點進行過濾。
據此可以得出下面語句為 importDeclaration 型別,所以是獲取不到的。
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 程式碼大綱
從 vscode 程式碼大綱的展示入手, 實現步驟如下:
// 呼叫獲取 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;
})
);
利器 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."
參考
- 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