《TypeScript高级编程》5.2 自定义 transformer

本节动手写一个真正的 transformer。先讲清 TransformerFactory 的两级签名与 before/after 挂载点的差别,再用 ts.factory 在遍历中改写节点:剥离 console 调用、注入编译期常量、重命名函数。最后讲节点被改坏、parent 指针失效、类型节点在 after 里消失这三类高频坑的成因与处理。

本节目标:把「读语法树」升级为「改语法树」。读完你能写出一个 TransformerFactory,把它挂进 program.emit 的 before/after,在编译期剥离调试代码、注入构建常量或批量重命名。同时你会知道为什么改写必须走 factory.updateXxx,以及为什么类型相关的逻辑只能挂在 before。

5.2 自定义 transformer

上一节我们建起了 Program,也学会了用 forEachChild 和 TypeChecker 读它。但读只是第一步——真正让工具链强大起来的是「改写」。

TypeScript 在发射阶段不是把 AST 直接打回文本,中间要经过一条变换管线(transform pipeline)。内置的降级逻辑(去掉类型、把 ?? 降成条件表达式、把 async 拆成状态机)全都是这条管线里的 transformer。官方把挂载点开放了出来:你可以在管线里插入自己的函数,拿到整棵树,返回一棵新的树。

5.2.1 管线里到底有几段

发射时的顺序大致是这样:

阶段内容你的挂载点
1before 自定义 transformerbefore: [...]
2transformTypeScript:抹掉类型节点—
3按 target 降级:ES2015 / ES2017 / …—
4after 自定义 transformerafter: [...]
5打印成文本 + 生成 source map—

这张表解释了本节最重要的一条规则:**想读类型节点就挂 before,因为到 after 时 interface、类型注解、泛型参数已经被第 2 步删干净了。**反过来,如果你只关心运行时语义(比如注入常量、删调试代码),挂在 after 上能少处理很多噪音节点。

5.2.2 TransformerFactory 的两级签名

自定义 transformer 的类型定义短得有点反直觉:

type TransformerFactory<T extends ts.Node> = (
  context: ts.TransformationContext
) => ts.Transformer<T>;

type Transformer<T extends ts.Node> = (node: T) => T;

也就是说它是一个「返回函数的函数」。第一级接收 TransformationContext,返回第二级;第二级接收节点、返回节点。为什么设计成两级?因为 context 里带着 factory 和 getCompilerOptions(),这些资源与「这一次编译」绑定;而第二级函数会被反复调用来遍历树的每一个节点,必须足够轻。

context 里最常用的两个成员:

成员用途
context.factory构造新节点(ts.factory.createXxx)
context.getCompilerOptions()读 target、jsx 等配置决定改写策略
context.hoistVariableDeclaration在文件顶层插入变量声明(降级常用)
context.enableSubstitution / enableEmitNotification增量场景下通知编译器「这棵树变了」

5.2.3 第一个 transformer:剥离 console

先写一个最实用的:生产构建里把 console.log(...) 整条语句删掉。

import ts from "typescript";

export const stripConsole: ts.TransformerFactory<ts.SourceFile> = (context) => {
  const visitor: ts.Visitor = (node) => {
    const isConsoleCall =
      ts.isCallExpression(node) &&
      ts.isPropertyAccessExpression(node.expression) &&
      ts.isIdentifier(node.expression.expression) &&
      node.expression.expression.text === "console";

    if (isConsoleCall) {
      return undefined; // 返回 undefined 表示「删除这个节点」
    }
    return ts.visitEachChild(node, visitor, context);
  };

  return (sourceFile) => ts.visitNode(sourceFile, visitor) as ts.SourceFile;
};

三个要点:

  1. 返回 undefined 就是删除。 管线看到 undefined 会把该节点从父节点的子节点列表中移除。但删除是有前提的——父节点得允许这个位置为空,见 5.2.9 的坑。
  2. visitEachChild 负责递归。 忘记调用它,你的 visitor 只会看到根节点,整棵树一动不动,而且不报错。这是新手最常踩的坑。
  3. visitNode 是入口。 它比 visitEachChild 多一层校验,适合从根开始。

5.2.4 用 factory 造新节点

删除之外更常见的是替换。比如把源码里的 __BUILD_TIME__ 标识符换成真正的构建时间字符串:

export const injectBuildTime = (
  buildTime: string
): ts.TransformerFactory<ts.SourceFile> => {
  return (context) => {
    const { factory } = context;

    const visitor: ts.Visitor = (node) => {
      if (ts.isIdentifier(node) && node.text === "__BUILD_TIME__") {
        return factory.createStringLiteral(buildTime);
      }
      return ts.visitEachChild(node, visitor, context);
    };

    return (sourceFile) => ts.visitNode(sourceFile, visitor) as ts.SourceFile;
  };
};

注意替换之后直接 return,不要再对刚造出来的新节点调用 visitEachChild。新节点是你自己造的、内容确定,再递归一遍纯属浪费;更糟的情况是造出的节点恰好又匹配了你的判断条件,于是无限递归。

ts.factory 的构造函数命名有统一规律:createXxx 对应 SyntaxKind.Xxx。createStringLiteral、createNumericLiteral、createIdentifier、createCallExpression、createPropertyAccessExpression,需要哪个查 SyntaxKind 的名字就八九不离十。

visitor 的返回值一共有三种语义,必须分清:

返回值语义典型用法
undefined从父节点的子节点列表里删掉删调试语句、删空导入
原节点(递归后)保留并继续向下访问默认分支,必须调 visitEachChild
新节点用新节点替换原节点常量折叠、语法降级

只要某个分支不是「替换」,就一定要落到 ts.visitEachChild 上;三种语义里只有它负责继续遍历。

5.2.5 改写已有节点必须用 update 系列

假设要把所有函数名加一个 _ 前缀。有人会写成 node.name.text = "_" + node.name.text —— 这行代码不报错,但不生效:AST 节点在 Program 建好之后基本是冻结的,直接改字段既不会被打印出来,还会让 source map 与增量缓存错位。

正确做法是用 factory.updateXxx 重建:

if (ts.isFunctionDeclaration(node) && node.name) {
  return factory.updateFunctionDeclaration(
    node,
    node.modifiers,
    node.asteriskToken,
    factory.createIdentifier(`_${node.name.text}`),
    node.typeParameters,
    node.parameters,
    node.type,
    node.body
  );
}

update 系列的第一个参数是原节点,后面按该节点字段在 SyntaxKind 定义里的顺序逐个传入。这个顺序最容易记错,写的时候对着类型定义或 IDE 提示来,别凭记忆。

做法结果结论
node.name = newName编译不报错,产物无变化禁止
factory.createFunctionDeclaration(...)从零造节点,所有字段都要填用于新建
factory.updateFunctionDeclaration(node, ...)复用原节点身份与位置信息改写首选

update 之外还有一类需求:把某个表达式包起来。比如给每个函数体套一层 try/catch,或把 fetch(url) 包成 trace("fetch", () => fetch(url))。这类「造整条语句」的场景就直接用 createXxx 从零搭:

// 把 expr 包成 __trace("name", () => expr)
function wrapWithTrace(
  factory: ts.NodeFactory,
  name: string,
  expr: ts.Expression
): ts.Expression {
  const arrow = factory.createArrowFunction(
    undefined,                       // modifiers
    undefined,                       // typeParameters
    [],                              // parameters
    undefined,                       // type
    factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
    expr
  );

  return factory.createCallExpression(
    factory.createIdentifier("__trace"),
    undefined,                       // typeArguments
    [factory.createStringLiteral(name), arrow]
  );
}

从零构造节点时必须把每个字段都填对位置,undefined 只适用于可选字段。多填一个少填一个,编译器不会报错(参数都是宽类型),但产出的语法树可能不合法,直到打印阶段才暴露成一句难以定位的 Debug Failure。

5.2.6 before 与 after 的差别

维度beforeafter
运行时机TypeScript 自身 transform 之前全部降级之后
树里还有类型吗有(interface、类型注解、泛型都在)没有,已是纯 JS 语义节点
适合做什么读类型生成代码、装饰器元数据、API 扫描注入常量、删调试代码、按 target 补垫片
多个 transformer 的顺序数组顺序串行,前一个的输出给后一个同上

一条实用的判断法则:你的逻辑里如果出现了 ts.isInterfaceDeclaration、getTypeAtLocation 这类与类型相关的调用,就必须挂 before。 挂在 after 上只会得到 undefined 或空结果,而且不报错。

5.2.7 挂进 emit

写好的 transformer 通过 program.emit 的第五个参数传进去:

const result = program.emit(
  undefined,                       // targetSourceFile:undefined 表示全部
  undefined,                       // writeFile:undefined 用默认的 ts.sys.writeFile
  undefined,                       // cancellationToken
  false,                           // emitOnlyDtsFiles
  {
    before: [injectBuildTime(new Date().toISOString()), stripConsole],
    after: [],
  }
);

console.log(result.emitSkipped); // false 表示确实写了文件

emit 的签名是五个位置参数,customTransformers 在最后。第四个 emitOnlyDtsFiles 是布尔值,和第五个对象紧挨着,写错位置时类型检查通常能拦住,但传 undefined 当第四个参数很容易被误读。

调试 transformer 有个朴素但有效的办法:在 visitor 里打印节点,或者干脆先用 ts.createPrinter() 把改写后的树打出来看(createPrinter 是下一节的主角)。如果发现产物没变化,第一件事是确认 emitSkipped 与 writeFile 是否真的落盘了,而不是怀疑 transformer 逻辑。

5.2.8 在 tsc 命令里用 transformer

tsc 命令行没有官方的 transformer 开关,--transformers 属于非官方补丁。想在 tsc 流程里用,有三条路:

方案原理代价
自己写构建脚本直接调 program.emit完全可控,但要自己处理 watch、增量
ts-patch给本地 typescript 打补丁,支持 tsconfig.plugins需在 CI 里执行 patch 步骤
ttypescript独立的 tsc 包装器与新版 TS 的跟进速度不稳定

ts-patch 的用法是先把本地 typescript 替换成打过补丁的版本,再在 tsconfig.json 里声明插件:

{
  "compilerOptions": { "plugins": [{ "transform": "./tools/strip-console.ts" }] },
  "ts-patch": { "transformerProgram": true }
}

插件模块默认导出一个 TransformerFactory,和本节写的完全一致——这也是为什么先掌握手写 transformer,再看 ts-patch 会觉得只是换了个加载方式。

工程上更推荐第一条:既然你已经会写 Program 了,把 emit 包进自己的构建脚本反而是最透明的做法,也最容易和 bundler 集成。

5.2.9 常见坑

现象原因处理
transformer 完全不生效visitor 里忘了 visitEachChild每个分支末尾补上递归
node.parent 指向旧树手改了节点字段而非重建改用 factory.updateXxx
删除节点后 emit 报错父节点该位置不允许为空(如 return 的参数)用 void 0 或空块占位
类型判断在 after 里永远为假类型节点已被抹掉改挂 before
source map 行号错乱新节点没带原位置信息复用原节点、只改必要字段
同一个 transformer 跑了两次before 与 after 都注册了检查注册数组

「删除节点后 emit 报错」这条值得展开:console.log() 通常出现在表达式语句里,删掉整个语句是安全的;但如果它嵌在 return console.log(x) 里,你删掉的是 return 的参数,父节点就成了不合法结构。

稳妥的做法是在语句数组层面过滤,而不是在表达式层面返回 undefined:

function isConsoleStatement(s: ts.Statement): boolean {
  if (!ts.isExpressionStatement(s)) return false;
  const e = s.expression;
  return (
    ts.isCallExpression(e) &&
    ts.isPropertyAccessExpression(e.expression) &&
    ts.isIdentifier(e.expression.expression) &&
    e.expression.expression.text === "console"
  );
}

// 在 Block 或 SourceFile 的 statements 上做一次 filter,语义清晰、零风险
const kept = factory.createNodeArray(sf.statements.filter((s) => !isConsoleStatement(s)));

这样改写出来的树结构永远合法,因为被移除的是完整的语句,而不是某个位置必需的子表达式。

5.2.10 与装饰器的关系

标准装饰器(见 标准装饰器(TS 5.x) )在编译期同样是通过 transform 落地的:装饰器表达式会被重写成 __decorate 之类的辅助调用。理解了本节的两级函数与 update 系列,你就能自己实现一套「编译期展开」的方案,而不必依赖运行时的 reflect-metadata。

两者的取舍很清晰:运行时方案(AOP 与运行时类型信息 )灵活、可动态增删,但要求装饰器信息在产物里保留;编译期方案产物更小、启动更快,代价是每次改逻辑都要重新编译。库作者常常两者混用——公开 API 用编译期展开保证性能,内部扩展点留给运行时。

如果只想看别人怎么组织这条管线,可以对比 esbuild 的转换原理 ,它用 Go 重写了同样的 transform 概念,只是没有暴露 TransformerFactory 这样的挂载点。

小结

  • transformer 是「返回函数的函数」:第一级拿 context,第二级拿节点、返回节点。
  • 发射管线里 before 在类型被抹掉之前,after 在降级之后——读类型必须挂 before。
  • 删除节点靠返回 undefined,替换节点靠 context.factory.createXxx,改写已有节点靠 factory.updateXxx。
  • 递归的引擎是 visitEachChild,忘了它 transformer 会静默失效。
  • 想让 tsc 命令支持 transformer,最稳的路是自己写基于 program.emit 的构建脚本。

到这里我们已经会读也会改了。但改写之后总得把树变成文本落到磁盘——下一节讲 AST 与代码生成,把 factory 从「造一个表达式」用到「造一整个文件」。

阅读导航:上一节:5.1 TypeScript Compiler API 入门 · 下一节:5.3 AST 与代码生成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes