本节目标:把「读语法树」升级为「改语法树」。读完你能写出一个 TransformerFactory,把它挂进
program.emit的before/after,在编译期剥离调试代码、注入构建常量或批量重命名。同时你会知道为什么改写必须走factory.updateXxx,以及为什么类型相关的逻辑只能挂在before。
5.2 自定义 transformer
上一节我们建起了 Program,也学会了用 forEachChild 和 TypeChecker 读它。但读只是第一步——真正让工具链强大起来的是「改写」。
TypeScript 在发射阶段不是把 AST 直接打回文本,中间要经过一条变换管线(transform pipeline)。内置的降级逻辑(去掉类型、把 ?? 降成条件表达式、把 async 拆成状态机)全都是这条管线里的 transformer。官方把挂载点开放了出来:你可以在管线里插入自己的函数,拿到整棵树,返回一棵新的树。
5.2.1 管线里到底有几段
发射时的顺序大致是这样:
| 阶段 | 内容 | 你的挂载点 |
|---|---|---|
| 1 | before 自定义 transformer | before: [...] |
| 2 | transformTypeScript:抹掉类型节点 | — |
| 3 | 按 target 降级:ES2015 / ES2017 / … | — |
| 4 | after 自定义 transformer | after: [...] |
| 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;
};
三个要点:
- 返回
undefined就是删除。 管线看到undefined会把该节点从父节点的子节点列表中移除。但删除是有前提的——父节点得允许这个位置为空,见 5.2.9 的坑。 visitEachChild负责递归。 忘记调用它,你的 visitor 只会看到根节点,整棵树一动不动,而且不报错。这是新手最常踩的坑。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 的差别
| 维度 | before | after |
|---|---|---|
| 运行时机 | 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 与代码生成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。