TypeScript编译器API并不是只为IDE和语言服务准备的,它同样可以驱动代码生成流程。当我们需要在编译阶段做源码层面的改造时,自定义转换器就是一个很实用的方案。转换器工作于抽象语法树之上,在类型检查之后、文件输出之前介入,能够修改甚至替换节点。本文会从Program和SourceFile这些基础对象讲起,再实现两个可运行的转换器,并说明如何把它们挂到构建流程里。

先理解编译器API的核心对象
编写转换器之前,需要先弄清楚几个关键对象。Program代表整个编译项目,通过ts.createProgram创建,它管理多个源文件以及编译选项。创建Program后,可以调用getSourceFile拿到某个文件对应的SourceFile。SourceFile本身就是一个AST节点,它包含一组顶层语句,往下可以访问到函数、表达式、变量声明等所有节点。
每个AST节点都有一个kind属性,用来标识节点类型,例如函数声明、调用表达式、属性访问等。实际开发中几乎不会直接拿kind数字做判断,而是使用TypeScript提供的一批类型守卫函数,比如ts.isCallExpression、ts.isIdentifier。这些守卫函数在判断为真后,还能帮助编辑器做类型收窄,让后续代码获得更精确的节点类型。
TransformerFactory是转换器的工厂函数。一个转换器最终会被包装成ts.TransformerFactory<ts.SourceFile>,它接收一个TransformationContext,返回真正的转换函数。转换函数的签名可以理解为SourceFile => SourceFile,内部通常使用ts.visitEachChild递归遍历整棵AST,并在遍历过程中返回替换后的节点。下面先写一个简单脚本,查看某个TS文件的AST结构。
import * as ts from "typescript";
const fileName = "./src/sample.ts";
const program = ts.createProgram([fileName], {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
});
const sourceFile = program.getSourceFile(fileName);
function printNode(node: ts.Node, depth: number = 0) {
const indent = " ".repeat(depth);
console.log(indent + ts.SyntaxKind[node.kind]);
ts.forEachChild(node, function(child) {
printNode(child, depth + 1);
});
}
if (sourceFile) {
printNode(sourceFile);
}
这段代码会输出AST节点类型。理解节点结构之后,就能在特定类型上做文章。比如一个console.log(x)在AST中是一个CallExpression,它的expression属性是一个PropertyAccessExpression,左侧是名为console的Identifier,右侧是名为log的Identifier。转换器要判断的就是这几个条件是否同时成立。
动手实现两个自定义转换器
第一个示例是移除所有console.log调用。转换器在访问每个节点时,先判断是否为CallExpression,再判断调用目标是否为console.log。如果符合条件,直接返回一个空语句,相当于把原语句从输出中剔除。这里必须使用ts.factory.createEmptyStatement创建新节点,不能返回undefined,否则会在后续遍历中引发错误。
import * as ts from "typescript";
function createRemoveConsoleTransformer() {
return function(context: ts.TransformationContext) {
function visit(node: ts.Node): ts.Node {
if (ts.isCallExpression(node)) {
const expr = node.expression;
if (
ts.isPropertyAccessExpression(expr) &&
ts.isIdentifier(expr.expression) &&
expr.expression.text === "console" &&
expr.name.text === "log"
) {
return ts.factory.createEmptyStatement();
}
}
return ts.visitEachChild(node, visit, context);
}
return function(sourceFile: ts.SourceFile) {
return ts.visitNode(sourceFile, visit) as ts.SourceFile;
};
};
}
第二个示例稍微复杂一些,它给名为track的函数调用自动包裹性能计时逻辑。原先的track()会被替换成一个代码块,代码块里先调用console.time,再执行原来的函数调用,最后调用console.timeEnd。这种转换可以用于临时埋点,不用修改业务源码。
import * as ts from "typescript";
function createTimingTransformer() {
return function(context: ts.TransformationContext) {
function visit(node: ts.Node): ts.Node {
if (ts.isCallExpression(node)) {
const callee = node.expression;
if (ts.isIdentifier(callee) && callee.text === "track") {
const startCall = ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier("console"),
ts.factory.createIdentifier("time")
),
undefined,
[ts.factory.createStringLiteral("track")]
);
const endCall = ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier("console"),
ts.factory.createIdentifier("timeEnd")
),
undefined,
[ts.factory.createStringLiteral("track")]
);
const block = ts.factory.createBlock(
[
ts.factory.createExpressionStatement(startCall),
ts.factory.createExpressionStatement(node),
ts.factory.createExpressionStatement(endCall),
],
true
);
return block;
}
}
return ts.visitEachChild(node, visit, context);
}
return function(sourceFile: ts.SourceFile) {
return ts.visitNode(sourceFile, visit) as ts.SourceFile;
};
};
}
这两个例子展示了转换器最常见的模式:访问目标节点,使用ts.factory创建新节点,然后用新节点替换旧节点。工厂API非常丰富,几乎可以构造所有TypeScript语法节点。要注意的是,AST节点在转换过程中应当被视作不可变对象。不要直接修改原节点的属性,而是用ts.factory.updateXXX系列函数创建更新后的副本。对于不需要改动的节点,应当继续调用ts.visitEachChild递归处理其子节点,这样整棵树的变换才会完整。
把转换器接入编译流程
tsc命令行工具本身不提供直接传入自定义转换器的选项,因此需要写一个Node脚本来调用program.emit。在脚本里读取tsconfig.json,创建Program,然后把转换器放进customTransformers参数中。转换器分为before和after两类,前者在TypeScript编译到JavaScript之前运行,后者在编译之后、输出之前运行。大多数源码级别的改动都会放到before阶段。
import * as ts from "typescript";
import * as path from "path";
const configPath = path.resolve("./tsconfig.json");
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
const parsedConfig = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(configPath)
);
const program = ts.createProgram(parsedConfig.fileNames, parsedConfig.options);
const transformers: ts.CustomTransformers = {
before: [createRemoveConsoleTransformer()],
after: []
};
const emitResult = program.emit(undefined, undefined, undefined, false, transformers);
const diagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
diagnostics.forEach(function(diagnostic) {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
console.log(message);
});
if (emitResult.emitSkipped) {
process.exit(1);
}
如果项目已经使用Webpack、Rollup等构建工具,也可以借助对应插件传入转换器。例如ts-loader提供getCustomTransformers选项,Rollup的TypeScript插件同样支持类似配置。如果不想维护额外脚本,还可以使用ts-patch或ttsc这类工具,让tsc命令直接读取转换器配置。无论采用哪种方式,都要清楚转换器只影响最终输出,不会改变原始源文件,也不会影响类型检查结果。因此,如果转换器引入了新的语法错误或类型问题,诊断信息很可能仍然基于转换前的AST,排查时需要额外留意。
调试与常见问题
调试转换器最直接的方法是使用ts.createPrinter打印某个节点的文本。可以在访问节点时打印替换前后的结构,快速验证转换逻辑是否正确。下面是一个简单的打印工具函数。
import * as ts from "typescript";
function printNodeText(node: ts.Node, sourceFile: ts.SourceFile) {
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const result = printer.printNode(ts.EmitHint.Unspecified, node, sourceFile);
console.log(result);
}
另一个常见问题是节点缓存。不要在不同SourceFile之间复用同一个节点对象,因为节点包含对所属源文件的引用。跨文件复用会导致打印时使用错误的源文件信息,甚至产生错误的source map。转换器应尽量保持无副作用,不要在遍历过程中修改外部状态,尤其不要缓存ts.factory.createIdentifier创建的标识符节点。
使用getText时要特别小心。它会返回节点在原始源文件中的文本,但经过上游转换后,文本可能已经过时。更稳妥的做法是通过节点的结构化属性来判断语义,比如用expr.expression.text而不是依赖字符串切片。如果项目开启了sourceMap,还要确认转换后的节点位置信息是否合理。一般来说,用工厂函数创建的新节点没有原始位置,如果对source map质量要求较高,可以调用ts.setSourceMapRange或相关辅助函数为新节点复制位置信息。
类型节点和表达式节点也需要区分清楚。转换器通常只处理表达式和语句层面的改动,不应随意改动类型节点,否则可能破坏声明文件的生成。建议在编写转换器时先限制处理范围,比如只处理SyntaxKind.CallExpression或SyntaxKind.VariableStatement,并对不必要的节点尽快返回原节点,减小遍历开销。
TypeScript编译器API代码转换器AST修改时间:2026-09-29 15:39:04