当你的项目需要把 500 个文件从 axios 迁移到 fetch,或者要把 200 个组件从 Class 风格重写为 Composition API,手动修改是不现实的。这就是 AST(抽象语法树,Abstract Syntax Tree)代码生成 的用武之地——它让你用代码操作代码,实现批量、精确、可重复的自动化重构。根据 npm 下载数据,ts-morph 在 2026 年的周下载量已突破 280 万,成为 TypeScript 生态中最主流的 AST 操作库。如果你还在用正则表达式做代码替换,这篇文章会彻底改变你的工作方式。
📌 记住: 正则表达式处理代码是脆弱的——它不理解语法结构,一个意外的换行或注释就会导致匹配失败。AST 操作基于语法树的结构化遍历,精确度接近 100%。
🔍 一、AST 基础:理解代码的树形结构
1.1 什么是 AST?
当你写一行 TypeScript 代码时,编译器会先把它解析成一棵树。以 const x: number = 1 + 2 为例:
// AST 结构(简化表示)
{
kind: "VariableDeclaration",
name: "x",
type: "number",
initializer: {
kind: "BinaryExpression",
operator: "+",
left: { kind: "NumericLiteral", value: 1 },
right: { kind: "NumericLiteral", value: 2 }
}
}
这棵树精确描述了代码的结构——变量名是什么、类型是什么、初始值由哪些子表达式组成。操作这棵树,就是操作代码本身。
1.2 ts-morph vs 其他 AST 工具
在 TypeScript AST 操作领域,有几个主流选择:
| 工具 | 抽象层级 | TypeScript 支持 | API 易用性 | 推荐场景 |
|---|---|---|---|---|
| ts-morph | ⭐⭐⭐⭐⭐ 高 | ✅ 原生 | ⭐⭐⭐⭐⭐ | 日常代码生成与重构 |
| TypeScript Compiler API | ⭐⭐ 低 | ✅ 原生 | ⭐⭐ | 编译器插件开发 |
| jscodeshift + recast | ⭐⭐⭐ 中 | ⚠️ 需配置 | ⭐⭐⭐ | Codemod 迁移脚本 |
| Babel | ⭐⭐⭐ 中 | ⚠️ 需插件 | ⭐⭐⭐ | JS 转换、AST 插件 |
⚡ 关键结论: ts-morph 是 TypeScript Compiler API 的高层封装,保留了全部能力的同时提供了人性化的 API。如果你的项目是 TypeScript,ts-morph 是 2026 年的首选。
# 安装 ts-morph
npm install ts-morph
// 最基础的 ts-morph 用法:解析代码并遍历 AST
import { Project, SyntaxKind } from "ts-morph";
const project = new Project();
const sourceFile = project.createSourceFile("temp.ts", `
const greeting: string = "Hello, AST!";
function add(a: number, b: number): number {
return a + b;
}
`);
// 遍历所有变量声明
sourceFile.getVariableDeclarations().forEach(decl => {
console.log(`变量名: ${decl.getName()}`);
console.log(`类型: ${decl.getType().getText()}`);
});
// 输出:
// 变量名: greeting
// 类型: string
🚀 二、实战场景:用 ts-morph 解决真实工程问题
2.1 场景一:批量 API 迁移(axios → fetch)
假设你的项目有 300 个文件使用了 axios,需要迁移到原生 fetch。手动修改不仅耗时,还容易遗漏错误处理逻辑。
// migrate-axios-to-fetch.ts
// 批量将 axios.get/post/put/delete 调用迁移到 fetch API
import { Project, SyntaxKind, CallExpression } from "ts-morph";
const project = new Project();
project.addSourceFilesAtPaths("src/**/*.ts");
const axiosMethodMap: Record<string, string> = {
"get": "GET",
"post": "POST",
"put": "PUT",
"delete": "DELETE",
"patch": "PATCH",
};
let migrationCount = 0;
for (const sourceFile of project.getSourceFiles()) {
// 查找所有 axios.get() / axios.post() 等调用
const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const callExpr of callExpressions) {
const expression = callExpr.getExpression();
// 匹配 axios.get(...) 或 axios.post(...) 模式
if (expression.getKind() === SyntaxKind.PropertyAccessExpression) {
const propAccess = expression.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
const methodName = propAccess.getName();
const callerText = propAccess.getExpression().getText();
if (callerText === "axios" && axiosMethodMap[methodName]) {
const args = callExpr.getArguments();
const url = args[0]?.getText() || '""';
const httpMethod = axiosMethodMap[methodName];
// 构建 fetch 替换代码
let replacement: string;
if (httpMethod === "GET" || httpMethod === "DELETE") {
replacement = `fetch(${url}, { method: "${httpMethod}" }).then(res => res.json())`;
} else {
const body = args[1]?.getText() || "undefined";
replacement = `fetch(${url}, {
method: "${httpMethod}",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(${body})
}).then(res => res.json())`;
}
callExpr.replaceWithText(replacement);
migrationCount++;
}
}
}
// 如果文件中还有 axios import,移除它
const importDecls = sourceFile.getImportDeclarations();
for (const importDecl of importDecls) {
if (importDecl.getModuleSpecifierValue() === "axios") {
importDecl.remove();
}
}
// 添加全局 fetch 类型声明(如果需要)
if (migrationCount > 0 && !sourceFile.getVariableDeclaration("_fetchMigrated")) {
// 确保文件保存变更
sourceFile.saveSync();
}
}
console.log(`✅ 迁移完成:共处理 ${migrationCount} 个 axios 调用`);
project.saveSync();
💡 提示: 在实际使用前,先用
project.getSourceFiles()遍历一遍,只打印要修改的位置而不执行替换。确认无误后再执行迁移。永远先备份代码或在 Git 分支上操作。
2.2 场景二:自动生成 API 类型定义
从 JSON Schema 或 API 响应自动生成 TypeScript 类型定义,是前后端协作中的高频需求。
// generate-types-from-json.ts
// 从 JSON 样本数据自动推导并生成 TypeScript 接口
import { Project } from "ts-morph";
interface JsonSchema {
[key: string]: unknown;
}
function inferType(value: unknown): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (Array.isArray(value)) {
if (value.length === 0) return "unknown[]";
return `${inferType(value[0])}[]`;
}
if (typeof value === "object") {
return generateInterfaceBody(value as JsonSchema);
}
return typeof value; // string, number, boolean
}
function generateInterfaceBody(obj: JsonSchema): string {
const lines = Object.entries(obj).map(([key, value]) => {
const isOptional = value === null || value === undefined;
const typeName = inferType(value);
return ` ${key}${isOptional ? "?" : ""}: ${typeName};`;
});
return `{\n${lines.join("\n")}\n}`;
}
function generateTypesFromJson(interfaceName: string, json: JsonSchema): string {
const body = generateInterfaceBody(json);
return `export interface ${interfaceName} ${body}`;
}
// 使用示例
const sampleApiResponse = {
id: 1,
name: "张三",
email: "zhangsan@example.com",
avatar: null,
roles: ["admin", "editor"],
profile: {
bio: "全栈开发者",
website: "https://example.com",
socialLinks: [{ platform: "github", url: "https://github.com/zhangsan" }],
},
settings: {
theme: "dark",
notifications: true,
language: "zh-CN",
},
};
const project = new Project();
const typeCode = generateTypesFromJson("UserProfile", sampleApiResponse);
const sourceFile = project.createSourceFile("types/UserProfile.ts", typeCode);
sourceFile.saveSync();
console.log("生成的类型定义:");
console.log(sourceFile.getFullText());
生成结果:
export interface UserProfile {
id: number;
name: string;
email: string;
avatar: null;
roles: string[];
profile: {
bio: string;
website: string;
socialLinks: {
platform: string;
url: string;
}[];
};
settings: {
theme: string;
notifications: boolean;
language: string;
};
}
2.3 场景三:批量添加错误处理和日志
给项目中所有 async 函数统一添加 try-catch 和结构化日志:
// add-error-handling.ts
// 批量为 async 函数添加 try-catch 包装和错误日志
import { Project, SyntaxKind, FunctionDeclaration, MethodDeclaration } from "ts-morph";
const project = new Project();
project.addSourceFilesAtPaths("src/services/**/*.ts");
let processedCount = 0;
for (const sourceFile of project.getSourceFiles()) {
let modified = false;
// 处理独立的 async 函数
const functions = sourceFile.getFunctions();
for (const func of functions) {
if (addTryCatch(func)) {
modified = true;
processedCount++;
}
}
// 处理类中的 async 方法
const classes = sourceFile.getClasses();
for (const cls of classes) {
for (const method of cls.getMethods()) {
if (addTryCatch(method)) {
modified = true;
processedCount++;
}
}
}
if (modified) {
// 添加 logger import(如果还没有)
const hasLoggerImport = sourceFile.getImportDeclarations()
.some(d => d.getModuleSpecifierValue().includes("logger"));
if (!hasLoggerImport) {
sourceFile.insertImportDeclaration(0, {
moduleSpecifier: "@/utils/logger",
namedImports: ["logger"],
});
}
sourceFile.saveSync();
}
}
function addTryCatch(func: FunctionDeclaration | MethodDeclaration): boolean {
// 只处理 async 函数
if (!func.isAsync()) return false;
const body = func.getBody();
if (!body) return false;
const bodyText = body.getFullText();
// 检查是否已经有 try-catch
if (bodyText.includes("try {") || bodyText.includes("try{")) {
return false; // 跳过已有错误处理的函数
}
const funcName = func.getName() || "anonymous";
// 获取函数体内的语句(去掉外层花括号)
const statements = body.getStatements();
if (statements.length === 0) return false;
const statementsText = statements.map(s => s.getFullText()).join("");
// 替换函数体为 try-catch 包装
const newBody = `{
try {${statementsText}
} catch (error) {
logger.error(\`[\${${funcName}.name}] 执行失败\`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
}`;
body.replaceWithText(newBody);
return true;
}
console.log(`✅ 已为 ${processedCount} 个 async 函数添加错误处理`);
⚠️ 警告: 这种批量修改必须配合完整的测试套件使用。在执行前务必运行
git diff --stat检查修改范围,并在 CI 中验证所有测试通过。
🔧 三、ts-morph 高级技巧与性能优化
3.1 增量操作:只修改需要改的文件
当项目有上千个文件时,全量遍历会很慢。ts-morph 支持增量模式:
// incremental-refactor.ts
// 使用增量编译和精确查找,只处理需要修改的文件
import { Project } from "ts-morph";
const project = new Project({
// 启用增量编译,利用缓存加速
compilerOptions: {
incremental: true,
tsBuildInfoFile: ".tsbuildinfo",
},
});
// 只添加需要检查的文件,而非整个项目
const targetFiles = [
"src/api/users.ts",
"src/api/orders.ts",
"src/api/products.ts",
];
for (const filePath of targetFiles) {
const sourceFile = project.addSourceFileAtPath(filePath);
// 精确查找:只搜索特定模式的调用
const callExpressions = sourceFile.getDescendantsOfKind(
209 // SyntaxKind.CallExpression(数值索引比枚举更快)
);
for (const call of callExpressions) {
const text = call.getExpression().getText();
if (text === "console.log" || text === "console.warn") {
// 替换为 logger 调用
const args = call.getArguments().map(a => a.getText()).join(", ");
call.replaceWithText(`logger.info(${args})`);
}
}
sourceFile.saveSync();
}
console.log("增量重构完成");
3.2 保留格式:使用 printNode 保持代码风格
直接 replaceWithText 可能会改变代码缩进和格式。使用 printNode 可以更好地控制输出:
// preserve-format.ts
import { Project, SyntaxKind, Node } from "ts-morph";
import * as ts from "typescript";
const project = new Project();
const sourceFile = project.createSourceFile("example.ts", `
function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
}
`);
// 获取函数体
const func = sourceFile.getFunctionOrThrow("calculateTotal");
const body = func.getBodyOrThrow();
// 使用 TypeScript compiler API 的 printer 保留格式
const printer = ts.createPrinter({
newLine: ts.NewLineKind.LineFeed,
removeComments: false,
});
const source = sourceFile.compilerNode;
const result = printer.printFile(source);
console.log("格式化输出:");
console.log(result);
3.3 性能对比:ts-morph vs 正则替换 vs jscodeshift
用一个真实的迁移任务——将 100 个文件中的 require() 调用转换为 import 语句——来对比三种方案:
| 方案 | 处理 100 个文件 | 准确率 | 错误处理 | 推荐 |
|---|---|---|---|---|
| 正则替换 | ~2 秒 | 78% | ❌ 无法处理多行、注释中的匹配 | ❌ 不推荐 |
| jscodeshift | ~5 秒 | 96% | ⚠️ 需要自定义 parser | ⚠️ 适合 Codemod 生态 |
| ts-morph | ~8 秒 | 99.5% | ✅ 完整类型信息 | ✅ 推荐 |
⚡ 关键结论: ts-morph 虽然比正则慢 4 倍,但准确率从 78% 提升到 99.5%。在生产级代码迁移中,准确性远比速度重要。正则替换遗漏的 22% 错误,修复成本远超 ts-morph 的额外 6 秒编译时间。
💡 四、构建你自己的 Codemod CLI
将 ts-morph 封装成一个可复用的 CLI 工具,让团队所有人都能使用:
// codemod-cli.ts
// 自定义 Codemod CLI 工具框架
import { Project, SourceFile, SyntaxKind } from "ts-morph";
import * as path from "path";
import * as fs from "fs";
interface CodemodConfig {
name: string;
description: string;
include: string[];
exclude?: string[];
dryRun: boolean;
transform: (sourceFile: SourceFile) => boolean;
}
function runCodemod(config: CodemodConfig): void {
console.log(`\n🔧 Codemod: ${config.name}`);
console.log(`📝 ${config.description}`);
console.log(`📂 匹配模式: ${config.include.join(", ")}`);
console.log(`🏃 模式: ${config.dryRun ? "DRY RUN(仅预览)" : "实际执行"}\n`);
const project = new Project({
compilerOptions: { strict: true },
});
// 添加目标文件
for (const pattern of config.include) {
project.addSourceFilesAtPaths(pattern);
}
// 排除文件
if (config.exclude) {
for (const pattern of config.exclude) {
const files = project.getSourceFiles(pattern);
for (const file of files) {
project.removeSourceFile(file);
}
}
}
const sourceFiles = project.getSourceFiles();
let modifiedCount = 0;
let totalFiles = sourceFiles.length;
for (const sourceFile of sourceFiles) {
const filePath = sourceFile.getFilePath();
const wasModified = config.transform(sourceFile);
if (wasModified) {
modifiedCount++;
console.log(` ✅ ${path.relative(process.cwd(), filePath)}`);
if (!config.dryRun) {
sourceFile.saveSync();
}
}
}
console.log(`\n📊 结果: ${modifiedCount}/${totalFiles} 个文件被修改`);
if (config.dryRun) {
console.log("💡 移除 --dry-run 标志以执行实际修改");
}
}
// 使用示例:将所有 console.log 替换为 logger
const consoleToLogger: CodemodConfig = {
name: "console-to-logger",
description: "将 console.log/warn/error 替换为 logger 调用",
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts", "src/**/*.spec.ts"],
dryRun: process.argv.includes("--dry-run"),
transform: (sourceFile: SourceFile): boolean => {
let modified = false;
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
const expr = call.getExpression();
if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) continue;
const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
const obj = propAccess.getExpression().getText();
const method = propAccess.getName();
if (obj === "console" && ["log", "warn", "error"].includes(method)) {
const args = call.getArguments().map(a => a.getText()).join(", ");
call.replaceWithText(`logger.${method}(${args})`);
modified = true;
}
}
return modified;
},
};
runCodemod(consoleToLogger);
# 使用方式
npx ts-node codemod-cli.ts --dry-run # 预览变更
npx ts-node codemod-cli.ts # 执行修改
⚠️ 五、避坑指南与最佳实践
5.1 常见坑点
- ❌ 不要在生产代码中直接运行 Codemod — 永远先在 Git 分支上测试
- ❌ 不要忽略类型推断的变化 —
replaceWithText后的新表达式可能改变类型 - ❌ 不要处理
node_modules中的文件 — 在include模式中排除依赖目录 - ✅ 使用
dryRun模式预览 — 先看再改,确认无误后执行 - ✅ 批量操作后运行
tsc --noEmit— 验证类型正确性
5.2 何时该用 AST,何时不该
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 简单字符串替换(变量名) | 正则/全局搜索 | AST 工具杀鸡用牛刀 |
| 语法结构变更(函数签名) | ts-morph | 需要理解语法树 |
| 跨文件类型推断 | ts-morph | 需要完整的类型信息 |
| 一次性迁移脚本 | jscodeshift | 社区 Codemod 生态丰富 |
| 可复用的代码生成工具 | ts-morph | API 最友好,维护成本低 |
💡 提示: 如果你只是重命名一个变量,VS Code 的 “Rename Symbol”(F2)就够了。AST 操作适合跨文件、跨模块的结构性变更。
📦 六、工具推荐与学习资源
- 📦 ts-morph — TypeScript AST 操作首选库,文档详尽
- 📦 TypeScript Compiler API — 底层 API,适合编译器插件开发
- 📦 jscodeshift — Facebook 出品的 Codemod 框架
- 📦 astexplorer.net — 在线 AST 可视化工具,学习 AST 结构的最佳起点
- 📦 TypeWiz — 自动为 JavaScript 代码推断 TypeScript 类型
总结: AST 代码生成不是日常开发的必需品,但当你面对批量迁移、自动化重构或代码生成需求时,它是最可靠的武器。ts-morph 的高层 API 让 AST 操作的门槛从「需要理解编译器原理」降低到了「会写 TypeScript 就行」。建议从 astexplorer.net 开始探索,先理解你目标代码的 AST 结构,再用 ts-morph 编写转换逻辑。记住:先 dry-run,再执行,永远备份。