如何用TypeScript编译器API实现自定义代码转换器?

来源:集群教程作者:乙爱丽丝头衔:网络博主
导读:本期聚焦于乙爱丽丝创作的《如何用TypeScript编译器API实现自定义代码转换器?》,敬请观看详情。想给TypeScript项目批量去掉调试用的console.log,或者在编译阶段自动注入埋点代码?靠手动改源码显然不现实。借助TypeScript编译器API,可以写一个自定义转换器直接操作抽象语法树,在代码生成前完成节点替换、删除或新增。本文从编译器执行流程入手,解释Program、SourceFile和Transformer之间的关系,然后手把手实现两个转换器:一个用于移除console语句,另一个给函数调用自动包裹性能计时逻辑。你会看到如何用ts.factory创建节点、用visitEachChild递归遍历,以及如何在构建工具中挂载自定义转换器。读完能理解AST变换的基本套路,也能避开类型节点丢失和sourcemap错位等常见坑。

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

如何用TypeScript编译器API实现自定义代码转换器?

先理解编译器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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0929/63455.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。