typescript-transformer-handbook
typescript-transformer-handbook 是一本专为 TypeScript 开发者编写的实战指南,旨在系统性地讲解如何编写自定义代码转换器(Transformers)。在 TypeScript 编译过程中,开发者有时需要深度干预代码的转换逻辑,例如自动注入代码、优化语法结构或实现特定的领域特定语言(DSL),而官方文档往往缺乏具体的实操细节。这份手册正是为了解决这一痛点,填补了从理论概念到工程落地之间的空白。
它非常适合希望深入理解 TypeScript 编译器内部机制、需要定制编译流程的高级前端工程师及工具链开发者阅读。内容不仅涵盖了抽象语法树(AST)的基础概念和编译阶段解析,更提供了大量可运行的代码示例,详细演示了如何遍历节点、修改语法树、管理作用域以及处理复杂的类型检查逻辑。
其独特的技术亮点在于对 Transformer API 的深度剖析,包括如何安全地替换节点、动态添加导入声明、重命名绑定以及在 Webpack 或 ttypescript 等主流构建工具中集成自定义转换器。此外,手册还分享了组合转换器、抛出友好语法错误等实用技巧,并介绍了专门的测试库,帮助开发者高效构建稳定可靠的编译插件。无论你是想扩展 TypeScript 的能力边界,还是致力于开发高效的构建工具,这份手册都是不可或缺的参考资源。
使用场景
某大型前端团队在构建内部低代码平台时,需要自定义 TypeScript 编译流程,将特定的业务注解自动转换为运行时校验逻辑。
没有 typescript-transformer-handbook 时
- 开发者面对 TypeScript 复杂的抽象语法树(AST)文档无从下手,难以理解 Scanner、Binder 到 Transforms 的具体编译阶段。
- 尝试手动编写转换器时,常因不懂如何正确操作
context和visitNode,导致节点替换失败或破坏原有的作用域绑定。 - 缺乏系统的操作指南,在处理“变量提升”、“重命名引用”或“动态插入 Import"等高级需求时只能盲目试错。
- 调试过程极其痛苦,无法快速定位是遍历逻辑错误还是节点克隆方式不当,严重拖慢开发进度。
使用 typescript-transformer-handbook 后
- 团队通过手册清晰的“编译阶段”图解,迅速掌握了从解析到发射的全流程,精准定位代码注入的最佳时机。
- 依据手册中关于
visitor模式和context用法的最佳实践,轻松实现了安全的节点遍历与无损替换。 - 直接复用手册提供的“转换操作”食谱(如添加导入声明、检查符号同一性),高效完成了业务注解到校验代码的自动化生成。
- 借助手册推荐的测试库和常见陷阱提示,大幅减少了运行时报错,确保了自定义转换器在 Webpack 和 ttypescript 中的稳定运行。
typescript-transformer-handbook 将晦涩的编译器底层原理转化为可落地的实战指南,让开发者能像搭积木一样安全、高效地定制 TypeScript 编译能力。
运行环境要求
- 未说明
不需要 GPU
未说明

快速开始
TypeScript 转换器手册
本文档介绍了如何编写一个 TypeScript 的 转换器。
目录
引言
TypeScript 是 JavaScript 的带类型超集,最终会被编译成普通的 JavaScript。TypeScript 支持消费者将代码从一种形式“转换”为另一种形式的功能,这与 Babel 使用“插件”来实现类似。
关注我 @itsmadou,获取最新动态和讨论
运行示例
本手册中提供了多个可供使用的示例。如果你想深入学习,请确保:
- 克隆仓库
- 使用
yarn安装依赖 - 构建你想要的示例:
yarn build example_name
基础知识
简单来说,转换器本质上是一个接收并返回一段代码的函数,例如:
const Transformer = code => code;
但不同的是,这里的 code 并不是字符串类型,而是以抽象语法树(AST)的形式存在,如下文所述。借助 AST,我们可以执行强大的操作,比如更新、替换、添加和删除节点。
什么是抽象语法树 (AST)
抽象语法树,简称 AST,是一种描述已解析代码的数据结构。在使用 TypeScript 处理 AST 时,强烈建议使用 AST 查看工具,例如 ts-ast-viewer.com。
通过这样的工具,我们可以看到以下代码:
function hello() {
console.log('world');
}
其 AST 表示如下:
-> SourceFile
-> FunctionDeclaration
- Identifier
-> Block
-> ExpressionStatement
-> CallExpression
-> PropertyAccessExpression
- Identifier
- Identifier
- StringLiteral
- EndOfFileToken
要更详细地查看 AST,可以亲自访问 ts-ast-viewer.com! 你还可以在左下角看到用于生成相同 AST 的代码,在右侧面板中查看所选节点的元数据。非常实用!
查看元数据时,你会发现它们都具有相似的结构(省略了一些属性):
{
kind: 307, // (SyntaxKind.SourceFile)
pos: 0,
end: 47,
statements: [{...}],
}
{
kind: 262, // (SyntaxKind.FunctionDeclaration)
pos: 0,
end: 47,
name: {...},
body: {...},
}
{
kind: 244, // (SyntaxKind.ExpressionStatement)
pos: 19,
end: 45,
expression: {...}
}
SyntaxKind是 TypeScript 中的一个枚举类型,用于描述节点的种类。有关更多信息,请参阅 Basarat 的 AST 提示:AST 提示 — SyntaxKind。
以此类推。每一个条目都描述了一个“节点”。AST 可以由一个或多个节点组成,它们共同描述了程序的语法,可用于静态分析。
每个节点都有一个 kind 属性,用于描述节点的类型;此外还有 pos 和 end 属性,分别表示该节点在源代码中的起始和结束位置。我们将在手册的后续部分讨论如何将节点缩小到特定类型。
阶段
与 Babel 非常相似—— 不过 TypeScript 有五个阶段: 解析器、 绑定器、 检查器、 转换、 输出。
其中有两个步骤是 TypeScript 特有的, 即 绑定器 和 检查器。 由于 检查器 涉及 TypeScript 类型检查的具体实现细节,我们在此将略过不表。
如果想更深入地了解 TypeScript 编译器的内部机制,可以阅读 Basarat 的手册。
TypeScript 中的“程序”
在继续之前,我们需要快速明确一下,在 TypeScript 中,“程序”究竟指的是什么。 一个“程序”是由一个或多个入口源文件组成的集合,这些文件会引入一个或多个模块。 整个集合会在每个编译阶段中被使用。
这与 Babel 处理文件的方式不同—— Babel 是逐个文件输入、逐个文件输出, 而 TypeScript 则是以整个项目为单位进行处理。 这就是为什么例如用 Babel 解析 TypeScript 时,枚举无法正常工作的原因, 因为 Babel 并没有获取到所有必要的信息。
解析器
TypeScript 的解析器实际上由两部分组成:
scanner(词法分析器)和 parser(语法分析器)。
这一步骤会将源代码转换为抽象语法树(AST)。
源代码 ~~ scanner ~~> 令牌流 ~~ parser ~~> AST
语法分析器接收源代码,并尝试将其转换为内存中的 AST 表示形式,以便在编译器中进一步处理。更多信息请参阅 Parser。
词法分析器
词法分析器由语法分析器使用,以线性方式将字符串转换成一系列令牌; 随后由语法分析器负责将这些令牌组织成一棵树状结构。更多信息请参阅 Scanner。
绑定器
绑定器会创建符号映射,并利用 AST 为类型系统提供支持,这对于链接引用以及识别导入和导出节点至关重要。 更多信息请参阅 Binder。
转换
这正是我们关注的步骤。 它允许开发者以任何合适的方式修改代码。 无论是性能优化、编译时行为,还是其他任何我们可以想象的操作,都可以在这里实现。
我们主要关心三个转换阶段:
before:在 TypeScript 自带的转换器之前运行(代码尚未被编译)。after:在 TypeScript 自带的转换器之后运行(代码已被编译)。afterDeclarations:在 声明 步骤之后运行(你可以在这里转换类型定义)。
通常情况下,90% 的场景下我们都会编写 before 阶段的转换器,
但如果你需要在编译后进行一些转换,或者修改类型,
那么你可能会选择使用 after 和 afterDeclarations 阶段。
提示:类型检查不应该发生在转换之后。 如果发生了这种情况,很可能是出现了 bug——请提交 issue!
输出
这一阶段位于最后,负责将最终的代码输出到某个位置。 通常情况下,输出会写入文件系统, 但也可能是在内存中。
遍历
如果想要以任何方式修改 AST,就需要对这棵树进行递归遍历。 具体来说,就是访问每一个节点, 然后返回原节点、更新后的节点,或者全新的节点。
以下是一个 JSON 格式的 AST 示例(省略了一些值):
{
kind: 307, // (SyntaxKind.SourceFile)
statements: [{
kind: 262, // (SyntaxKind.FunctionDeclaration)
name: {
kind: 80 // (SyntaxKind.Identifier)
escapedText: "hello"
},
body: {
kind: 241, // (SyntaxKind.Block)
statements: [{
kind: 244, // (SyntaxKind.ExpressionStatement)
expression: {
kind: 213, // (SyntaxKind.CallExpression)
expression: {
kind: 211, // (SyntaxKind.PropertyAccessExpression)
name: {
kind: 80 // (SyntaxKind.Identifier)
escapedText: "log",
},
expression: {
kind: 80, // (SyntaxKind.Identifier)
escapedText: "console",
}
}
},
arguments: [{
kind: 11, // (SyntaxKind.StringLiteral)
text: "world",
}]
}]
}
}]
}
如果我们对其进行遍历,就会从 SourceFile 开始,依次访问每个节点。
你可能会认为自己可以手动逐级访问,比如 source.statements[0].name 等,
但你会发现这种方式难以扩展,且极易出错——因此请谨慎使用。
对于大多数情况而言,最好使用 TypeScript 内置的方法来遍历 AST。 TypeScript 提供了两种主要的遍历方法:
visitNode()
通常你只需要传入初始的 SourceFile 节点即可。
稍后我们会详细介绍 visitor 函数的作用。
import * as ts from 'typescript';
ts.visitNode(sourceFile, visitor, test);
visitEachChild()
这是一个特殊的函数,它内部会调用 visitNode。
它可以自动向下遍历到最深层的节点,
并且无需你额外思考如何实现。
稍后我们会介绍 context 对象的作用。
import * as ts from 'typescript';
ts.visitEachChild(node, visitor, context);
visitor
访问者模式 是你在编写每个转换器时都会用到的模式, 幸运的是,TypeScript 已经帮我们封装好了这部分逻辑,我们只需提供一个回调函数即可。 我们可以编写一个最简单的函数如下:
import * as ts from 'typescript';
const transformer = sourceFile => {
const visitor = (node: ts.Node): ts.Node => {
console.log(node.kind, `\t# ts.SyntaxKind.${ts.SyntaxKind[node.kind]}`);
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
};
注意:你会看到我们对每个节点都进行了返回。 这是必须的! 如果没有返回,就会出现奇怪的错误。
如果我们把这个转换应用到前面的例子中,控制台将会输出以下内容(注释为事后添加):
307 # ts.SyntaxKind.SourceFile
262 # ts.SyntaxKind.FunctionDeclaration
80 # ts.SyntaxKind.Identifier
241 # ts.SyntaxKind.Block
244 # ts.SyntaxKind.ExpressionStatement
213 # ts.SyntaxKind.CallExpression
211 # ts.SyntaxKind.PropertyAccessExpression
80 # ts.SyntaxKind.Identifier
80 # ts.SyntaxKind.Identifier
11 # ts.SyntaxKind.StringLiteral
提示:你可以在 /example-transformers/log-every-node 查看该示例的源码——如果想在本地运行,可以使用
yarn build log-every-node命令。
它会尽可能深入地进入每一个节点, 直到到达最底层, 然后再进入下一个子节点。
context
每个转换器都会接收到一个转换上下文 context。
这个上下文不仅用于 visitEachChild,还能帮助我们获取当前的 TypeScript 配置等有用信息。
我们很快就会看到第一个简单的 TypeScript 转换器示例。
作用域
本部分内容大多直接摘自 Babel 手册,因为其中的原则同样适用。
接下来我们介绍作用域的概念(作用域)。 JavaScript 使用词法作用域(闭包),它是一种树状结构,块级作用域会创建新的作用域。
// 全局作用域
function scopeOne() {
// 作用域 1
function scopeTwo() {
// 作用域 2
}
}
在 JavaScript 中,无论你是通过变量、函数、类、参数、导入、标签等方式创建引用,它们都属于当前的作用域。
var global = '我在全局作用域中';
function scopeOne() {
var one = '我在 `scopeOne()` 创建的作用域中';
function scopeTwo() {
var two = '我在 `scopeTwo()` 创建的作用域中';
}
}
嵌套更深的作用域中的代码可以使用外层作用域中的引用。
function scopeOne() {
var one = '我在 `scopeOne()` 创建的作用域中';
function scopeTwo() {
one = '我在 `scopeTwo()` 中更新了 `scopeOne` 的引用';
}
}
较低的作用域也可以创建同名的引用,而不会影响外层作用域的引用。
function scopeOne() {
var one = '我在 `scopeOne()` 创建的作用域中';
function scopeTwo() {
var one = '我正在创建一个新的 `one`,但不改变 `scopeOne()` 中的引用。';
}
}
编写转换器时,我们需要特别注意作用域问题。在修改代码的不同部分时,必须确保不会破坏现有代码的逻辑。
有时我们可能需要添加新的引用,并确保它们不会与现有的引用发生冲突。或者只是想找到某个变量被引用的位置。因此,我们需要能够在特定的作用域内跟踪这些引用。
绑定
引用都属于特定的作用域,这种关系被称为绑定。
function scopeOnce() {
var ref = '这是一个绑定';
ref; // 这是对绑定的引用
function scopeTwo() {
ref; // 这是来自较低作用域的对绑定的引用
}
}
转换器 API
编写转换器时,建议使用 TypeScript 来实现。我们将主要借助 typescript 包来完成大部分工作,因为它几乎涵盖了所有功能,而 Babel 则依赖于多个独立的小型包。
首先,安装 typescript:
npm i typescript --save
然后导入它:
import * as ts from 'typescript';
提示:强烈推荐使用 VSCode 的智能感知功能来探索 API,这非常有帮助!
访问节点
以下方法用于访问 AST 节点——我们已经在前面简要介绍过其中一些。
ts.visitNode(node, visitor, test):用于访问根节点,通常是SourceFile。ts.visitEachChild(node, visitor, context):用于访问节点的每个子节点。ts.isXyz(node):用于缩小节点的类型范围,例如ts.isVariableDeclaration(node)。
操作节点
这些方法用于以某种方式修改节点。
ts.factory.createXyz(...):用于创建新节点并返回,例如ts.factory.createIdentifier('world')。ts.factory.updateXyz(node, ...):用于更新节点并返回,例如ts.factory.updateVariableDeclaration()。ts.factory.updateSourceFile(sourceFile, ...):用于更新源文件并返回。ts.setOriginalNode(newNode, originalNode):用于设置节点的原始节点。ts.setXyz(...):用于设置某些属性。ts.addXyz(...):用于添加某些内容。
context
如上所述,context 会传递给每个转换器,并提供一些实用的方法(以下并非完整列表,仅列出我们关注的部分):
getCompilerOptions():获取传递给转换器的编译器选项。hoistFunctionDeclaration(node):将函数声明提升到包含它的作用域顶部。hoistVariableDeclaration(node):将变量声明提升到包含它的作用域顶部。
program
这是编写 Program 类型转换器时可用的一个特殊属性。我们将在“转换器类型”一节中详细介绍这种类型的转换器。它包含了关于整个程序的元数据,例如(以下并非完整列表,仅列出我们关注的部分):
getRootFileNames():获取项目中所有文件的名称数组。getSourceFiles():获取项目中的所有SourceFile。getCompilerOptions():从tsconfig.json、命令行或其他来源获取编译器选项(也可以通过context获取)。getSourceFile(fileName: string):根据文件名获取对应的SourceFile。getSourceFileByPath(path: Path):根据路径获取对应的SourceFile。getCurrentDirectory():获取当前目录的字符串。getTypeChecker():获取类型检查器,这在处理 符号 时非常有用。
typeChecker
它是调用 program.getTypeChecker() 后得到的对象。它包含许多有趣且在编写转换器时会用到的信息。
getSymbolAtLocation(node):用于获取节点的符号。getExportsOfModule(symbol):返回模块符号的导出内容。
编写你的第一个转换器
这正是我们一直期待的部分! 让我们来编写我们的第一个转换器。
首先,我们导入 typescript。
import * as ts from 'typescript';
它将包含我们在编写转换器时可能用到的所有内容。
接下来,我们创建一个默认导出,它将成为我们的转换器。
我们的初始转换器将是一个转换器工厂(因为这样我们可以访问 context)——其他类型的转换器我们稍后再讨论。
import * as ts from 'typescript';
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
// 转换代码在这里
};
};
export default transformer;
由于我们使用 TypeScript 来编写转换器,
我们获得了类型安全,更重要的是智能感知!
如果你已经做到这里,你会发现 TypeScript 抱怨我们没有返回一个 SourceFile——让我们修复这个问题。
import * as ts from "typescript";
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
+ return sourceFile;
};
};
export default transformer;
太好了,我们修复了类型错误!
对于我们的第一个转换器,我们将借鉴 Babel 手册 的思路,重命名一些标识符。
这是我们的源代码:
babel === plugins;
让我们编写一个访问者函数,
记住,访问者函数应该接受特定类型的 node(这里是 SourceFile),
然后返回相同类型的 node。注意,visitNode 的 test 参数可以用来确保返回特定类型的节点。
import * as ts from 'typescript';
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
+ const visitor = (node: ts.Node): ts.Node => {
+ return node;
+ };
+
+ return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
-
- return sourceFile;
};
};
export default transformer;
好的,这会访问 SourceFile……
然后立即返回它。
这有点没用——让我们确保访问每个节点吧!
import * as ts from 'typescript';
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
const visitor = (node: ts.Node): ts.Node => {
- return node;
+ return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
};
};
export default transformer;
现在让我们找到标识符以便重命名它们:
import * as ts from 'typescript';
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
const visitor = (node: ts.Node): ts.Node => {
+ if (ts.isIdentifier(node)) {
+ // 在这里进行转换
+ }
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
};
};
export default transformer;
然后,让我们针对我们感兴趣的特定标识符:
import * as ts from 'typescript';
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
const visitor = (node: ts.Node): ts.Node => {
if (ts.isIdentifier(node)) {
+ switch (node.escapedText) {
+ case 'babel':
+ // 重命名 babel
+
+ case 'plugins':
+ // 重命名 plugins
+ }
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
};
};
export default transformer;
最后,让我们返回已重命名的新节点!
import * as ts from 'typescript';
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
const visitor = (node: ts.Node): ts.Node => {
if (ts.isIdentifier(node)) {
switch (node.escapedText) {
case 'babel':
+ return ts.factory.createIdentifier('typescript');
case 'plugins':
+ return ts.factory.createIdentifier('transformers');
}
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
};
};
export default transformer;
太棒了! 当我们将其应用于我们的源代码时,会得到以下输出:
typescript === transformers;
提示 - 你可以查看此示例的源代码:/example-transformers/my-first-transformer。如果想在本地运行,可以通过
yarn build my-first-transformer来执行。
转换器的类型
所有转换器最终都会返回 TransformerFactory 类型签名。
这些转换器类型取自 ttypescript。
工厂
也称为 raw,
这与我们在编写第一个转换器时使用的类型相同。
// ts.TransformerFactory
(context: ts.TransformationContext) => (sourceFile: ts.SourceFile) => ts.SourceFile;
配置
当你的转换器需要由使用者控制的配置时。
(config?: YourPluginConfigInterface) => ts.TransformerFactory;
程序
当你需要访问 program 对象时,你应该使用这种签名,
它应该返回一个 TransformerFactory。
它还具有作为第二个参数提供的、由使用者提供的配置。
(program: ts.Program, config?: YourPluginConfigInterface) => ts.TransformerFactory;
使用转换器
有趣的是,TypeScript 并没有官方支持通过 tsconfig.json 使用转换器。
有一个 GitHub 问题 专门讨论引入相关功能。
尽管如此,你仍然可以使用转换器,只是方式稍微绕了一点。
ts-patch
这是推荐的方法! 希望未来能正式支持在
typescript中使用。
本质上是 tsc CLI 的封装——它通过 tsconfig.json 提供对转换器的一流支持。它将 typescript 列为 peer dependency,因此理论上不会过于脆弱。
安装:
npm i ts-patch -D
将你的转换器添加到编译器选项中:
{
"compilerOptions": {
"plugins": [{ "transform": "my-first-transformer" }]
}
}
运行 tspc:
tspc
ts-patch 支持 tsc CLI、Webpack、Rollup、Jest 和 VSCode。说实话,几乎涵盖了我们所有可能的使用场景。
转换操作
访问
检查节点是否为特定类型
有许多辅助方法可以断言节点的类型。当这些方法返回 true 时,它们会“收窄” node 的类型,从而可能为你提供基于该类型的额外属性和方法。
提示:充分利用智能感知来查看
ts导入中可用的方法,同时使用 TypeScript AST 查看器 来了解节点的具体类型。
import * as ts from 'typescript';
const visitor = (node: ts.Node): ts.Node => {
if (ts.isJsxAttribute(node.parent)) {
// node.parent 是一个 JSX 属性
// ...
}
};
检查两个标识符是否引用同一个符号
标识符由解析器创建,并且总是唯一的。例如,如果你创建了一个变量 foo,并在另一行再次使用它,那么将会生成两个具有相同文本内容 foo 的独立标识符。
随后,链接器会遍历这些标识符,并将引用同一变量的标识符通过一个共同的符号连接起来(同时考虑作用域和遮蔽)。可以将符号理解为我们直观上所认为的变量。
因此,要检查两个标识符是否引用同一个符号,只需获取与标识符相关的符号,并通过引用比较它们是否相同。
简短示例:
const symbol1 = typeChecker.getSymbolAtLocation(node1);
const symbol2 = typeChecker.getSymbolAtLocation(node2);
symbol1 === symbol2; // 通过引用比较
完整示例:
以下代码会记录所有重复的符号。
import * as ts from 'typescript';
const transformerProgram = (program: ts.Program) => {
const typeChecker = program.getTypeChecker();
// 创建已找到符号的数组
const foundSymbols = new Array<ts.Symbol>();
const transformerFactory: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
const visitor = (node: ts.Node): ts.Node => {
if (ts.isIdentifier(node)) {
const relatedSymbol = typeChecker.getSymbolAtLocation(node);
// 检查数组中是否已包含相同的符号——通过引用比较
if (foundSymbols.includes(relatedSymbol)) {
const foundIndex = foundSymbols.indexOf(relatedSymbol);
console.log(
`在位置 ${foundIndex} 找到了现有符号,名称为 "${relatedSymbol.name}"`
);
} else {
// 如果未找到,则将其添加到数组中
foundSymbols.push(relatedSymbol);
console.log(
`找到了新符号,名称为 "${relatedSymbol.name}"。已添加到位置 ${foundSymbols.length - 1}`
);
}
return node;
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
};
};
return transformerFactory;
};
export default transformerProgram;
提示:你可以在 /example-transformers/match-identifier-by-symbol 中查看此示例的源代码;如果想在本地运行,可以使用
yarn build match-identifier-by-symbol命令。
查找特定父节点
虽然没有现成的方法可以直接实现这一功能,但你可以自己编写一个。给定一个节点:
const findParent = (node: ts.Node, predicate: (node: ts.Node) => boolean) => {
if (!node.parent) {
return undefined;
}
if (predicate(node.parent)) {
return node.parent;
}
return findParent(node.parent, predicate);
};
const visitor = (node: ts.Node): ts.Node => {
if (ts.isStringLiteral(node)) {
const parent = findParent(node, ts.isFunctionDeclaration);
if (parent) {
console.log('字符串字面量有一个函数声明作为父节点');
}
return node;
}
};
对于以下源代码:
function hello() {
if (true) {
'world';
}
}
上述代码会在控制台输出 字符串字面量有一个函数声明作为父节点。
- 替换节点后进行遍历时需小心——
parent可能未被正确设置。如果需要在转换后继续遍历,请确保手动为节点设置parent属性。
提示:你可以在 /example-transformers/find-parent 中查看此示例的源代码;如果想在本地运行,可以使用
yarn build find-parent命令。
停止遍历
在访问者函数中,你可以提前返回而不继续遍历子节点。例如,当我们遇到某个节点并且知道无需再深入时:
const visitor = (node: ts.Node): ts.Node => {
if (ts.isArrowFunction(node)) {
// 提前返回
return node;
}
};
操作
更新节点
if (ts.isVariableDeclaration(node)) {
return ts.updateVariableDeclaration(
node,
node.name,
undefined,
node.type,
ts.createStringLiteral('world')
);
}
-const hello = true;
+const hello = "updated-world";
提示:你可以在 /example-transformers/update-node 中查看此示例的源代码;如果想在本地运行,可以使用
yarn build update-node命令。
替换节点
有时我们可能希望完全替换一个节点,而不是仅仅更新它。这时只需返回一个新的节点即可!
if (ts.isFunctionDeclaration(node)) {
// 将找到的任何函数替换为箭头函数。
return ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
ts.factory.createIdentifier(node.name.escapedText),
undefined,
ts.factory.createArrowFunction(
undefined,
undefined,
[],
undefined,
ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
ts.factory.createBlock([], false)
)
),
],
ts.NodeFlags.Const
);
}
-function helloWorld() {}
+const helloWorld = () => {};
提示:你可以在 /example-transformers/replace-node 中查看此示例的源代码;如果想在本地运行,可以使用
yarn build replace-node命令。
用多个节点替换一个节点
有趣的是,访问者函数不仅可以返回一个节点,还可以返回一个节点数组。 这意味着,即使它接收一个节点作为输入,也可以返回多个节点来替换该输入节点。
type Visitor<TIn extends Node = Node, TOut extends Node | undefined = TIn | undefined> =
(node: TIn) => VisitResult<TOut>;
type VisitResult<T extends Node | undefined> = T | readonly Node[];
让我们把每个表达式语句都替换成两个相同的语句(即复制该语句):
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
const visitor = (node: ts.Node): ts.VisitResult<ts.Node> => {
// 如果是表达式语句,
if (ts.isExpressionStatement(node)) {
// 返回两次该语句。
// 实际上就是复制了该语句
return [node, node];
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
};
};
因此,
let a = 1;
a = 2;
会变成
let a = 1;
a = 2;
a = 2;
提示 - 您可以在 /example-transformers/return-multiple-node 查看此示例的源代码。如果想在本地运行,可以使用
yarn build return-multiple-node命令。
声明语句(第一行)会被忽略,因为它不是 ExpressionStatement。
注意 - 请确保您在 AST 中的操作是有意义的。例如,用两个表达式代替一个通常是无效的。
假设有一个赋值表达式(带有 EqualToken 操作符的 BinaryExpression),如 a = b = 2。现在如果用两个节点代替 b = 2 表达式,就会导致无效(因为右侧不能是多个节点)。因此,TS 会抛出错误:Debug Failure. False expression: Too many nodes written to output.
插入兄弟节点
这实际上与上一节相同。只需返回包含自身和其他兄弟节点的节点数组即可。
删除节点
如果您不再需要某个特定节点,该怎么办呢?
只需返回 undefined!
if (ts.isImportDeclaration(node)) {
// 将移除所有 import 声明
return undefined;
}
import lodash from 'lodash';
-import lodash from 'lodash';
提示 - 您可以在 /example-transformers/remove-node 查看此示例的源代码。如果想在本地运行,可以使用
yarn build remove-node命令。
添加新的 import 声明
有时您的转换可能需要一些运行时依赖,为此您可以添加自己的 import 声明。
ts.factory.updateSourceFile(sourceFile, [
ts.factory.createImportDeclaration(
/* 修饰符 */ undefined,
ts.factory.createImportClause(
false,
ts.factory.createIdentifier('DefaultImport'),
ts.factory.createNamedImports([
ts.factory.createImportSpecifier(
false,
undefined,
ts.factory.createIdentifier('namedImport')
),
])
),
ts.factory.createStringLiteral('package')
),
// 确保源文件中的其余语句仍然存在。
...sourceFile.statements,
]);
+import DefaultImport, { namedImport } from "package";
提示 - 您可以在 /example-transformers/add-import-declaration 查看此示例的源代码。如果想在本地运行,可以使用
yarn build add-import-declaration命令。
作用域
将变量声明提升到其作用域顶部
有时您可能希望将 VariableDeclaration 提升上去以便对其进行赋值。请记住,这只会提升变量声明本身——赋值操作仍然保留在源代码中的原位置。
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
context.hoistVariableDeclaration(node.name);
return node;
}
function functionOne() {
+ var innerOne;
+ var innerTwo;
const innerOne = true;
const innerTwo = true;
}
提示 - 您可以在 /example-transformers/hoist-variable-declaration 查看此示例的源代码。如果想在本地运行,可以使用
yarn build hoist-variable-declaration命令。
您也可以对函数声明执行类似的操作:
if (ts.isFunctionDeclaration(node)) {
context.hoistFunctionDeclaration(node);
return node;
}
+function functionOne() {
+ console.log('hello, world!');
+}
if (true) {
function functionOne() {
console.log('hello, world!');
}
}
提示 - 您可以在 /example-transformers/hoist-function-declaration 查看此示例的源代码。如果想在本地运行,可以使用
yarn build hoist-function-declaration命令。
将变量声明提升到父作用域
TODO - 这是否可行?
检查局部变量是否被引用
TODO - 这是否可行?
定义唯一变量
有时您希望添加一个在其作用域内具有唯一名称的新变量,幸运的是,无需任何复杂操作即可实现。
if (ts.isVariableDeclarationList(node)) {
return ts.factory.updateVariableDeclarationList(node, [
...node.declarations,
ts.factory.createVariableDeclaration(
ts.factory.createUniqueName('hello'),
undefined /* 感叹号 */,
undefined /* 类型 */,
ts.factory.createStringLiteral('world')
),
]);
}
return ts.visitEachChild(node, visitor, context);
-const hello = 'world';
+const hello = 'world', hello_1 = "world";
提示 - 您可以在 /example-transformers/create-unique-name 查看此示例的源代码。如果想在本地运行,可以使用
yarn build create-unique-name命令。
重命名绑定及其引用
TODO - 是否有简洁的方法可以实现?
查找
获取行号和列号
sourceFile.getLineAndCharacterOfPosition(node.getStart());
高级用法
求值表达式
TODO - 这是否可行?
跟踪模块导入
这是可行的!
// 我们需要使用 Program 转换器来获取程序对象。
const transformerProgram = (program: ts.Program) => {
const transformerFactory: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
const visitor = (node: ts.Node): ts.Node => {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
const typeChecker = program.getTypeChecker();
const importSymbol = typeChecker.getSymbolAtLocation(node.moduleSpecifier)!;
const exportSymbols = typeChecker.getExportsOfModule(importSymbol);
exportSymbols.forEach(symbol =>
console.log(
`找到 "${
symbol.escapedName
}" 导出,其值为 "${symbol.valueDeclaration!.getText()}"`
)
);
return node;
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor, ts.isSourceFile);
};
};
return transformerFactory;
};
这将在控制台输出以下内容:
找到 "hello" 导出,其值为 "hello = 'world'"
找到 "default" 导出,其值为 "export default 'hello';"
你还可以使用 ts.visitChild 等方法遍历被导入的节点。
提示 - 你可以在 /example-transformers/follow-imports 查看此示例的源代码。如果想在本地运行,可以使用
yarn build follow-imports命令。
跟踪 Node.js 模块导入
就像跟踪你自己代码中的 TypeScript 导入一样,有时我们也可能希望检查我们正在导入的模块内部的代码。
使用与上述相同的代码,但在处理 node_modules 中的导入时,控制台会输出以下内容:
找到 "mixin" 导出,其值为:
export declare function mixin(): {
color: string;
};"
找到 "constMixin" 导出,其值为:
export declare function constMixin(): {
color: 'blue';
};"
咦?为什么我们得到的是类型定义的 AST 而不是源代码呢……真扫兴!
事实证明,要实现这一点稍微困难一些(至少不能直接开箱即用)。我们有两种选择:
- 在
tsconfig中启用allowJs,并删除类型定义……这样我们就能获得源代码的 AST……但同时也就失去了类型信息。因此这种方法并不理想。 - 创建另一个 TypeScript 程序,自己完成这项工作。
剧透: 我们将选择方案 2。它更加稳健,并且在关闭类型检查时也能正常工作——这也是我们在那种情况下跟踪 TypeScript 导入的方式!
const visitor = (node: ts.Node): ts.Node => {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
// 使用 require.resolve 找到文件系统中的导入位置
const pkgEntry = require.resolve(`${node.moduleSpecifier.text}`);
// 创建另一个程序
const innerProgram = ts.createProgram([pkgEntry], {
// 重要的是要将其设置为 true!
allowJs: true,
});
console.log(innerProgram.getSourceFile(pkgEntry)?.getText());
return node;
}
return ts.visitEachChild(node, visitor, context);
};
这将在控制台输出以下内容:
export function mixin() {
return { color: 'red' };
}
export function constMixin() {
return { color: 'blue' }
}
太棒了!顺便说一句,由于我们创建了一个“程序”,它所有的导入都会被自动跟踪!不过,如果这些模块也有类型定义,就会出现和之前一样的问题——所以如果你需要跨越多个导入,可能就需要采取更巧妙的方法了。
提示 - 你可以在 /example-transformers/follow-node-modules-imports 查看此示例的源代码。如果想在本地运行,可以使用
yarn build follow-node-modules-imports命令。
转换 JSX
TypeScript 也可以转换 JSX——有一些辅助方法可以帮助你入门。所有之前的遍历和操作方法都适用。
ts.isJsxXyz(node)ts.factory.updateJsxXyz(node, ...)ts.factory.createJsxXyz(...)
查阅 TypeScript 文档以获取更多详细信息。关键在于你需要创建有效的 JSX——不过,只要确保你的转换器中类型是正确的,就很难出错。
确定文件中的 pragma
当你想知道文件中的 pragma 是什么以便在转换中执行某些操作时,这非常有用。例如,假设我们想知道是否使用了自定义的 jsx pragma:
const transformer = sourceFile => {
const jsxPragma = (sourceFile as any).pragmas.get('jsx'); // 关于强制转换为 `any` 的说明见下文
if (jsxPragma) {
console.log(`找到了一个使用工厂 "${jsxPragma.arguments.factory}" 的 jsx pragma`);
}
return sourceFile;
};
下面的源文件会导致控制台输出 'a jsx pragma was found using the factory "jsx"':
/** @jsx jsx */
提示 - 你可以在 /example-transformers/pragma-check 查看此示例的源代码。如果想在本地运行,可以使用
yarn build pragma-check命令。
截至 2019 年 12 月 29 日,pragmas 尚未包含在 sourceFile 的类型定义中,因此你需要将其强制转换为 any 才能访问它。
重置文件中的 pragma
有时在转换过程中,你可能希望将 pragma 恢复为默认值(在我们的例子中是 React)。我发现以下代码可以成功实现这一点:
const transformer = sourceFile => {
sourceFile.pragmas.clear();
delete sourceFile.localJsxFactory;
};
技巧与窍门
组合转换器
如果你像我一样,有时会希望把大型转换器拆分成更小、更易于维护的部分。幸运的是,通过一点编码技巧,我们可以做到这一点:
const transformers = [...];
function transformer(
program: ts.Program,
): ts.TransformerFactory<ts.SourceFile> {
return context => {
const initializedTransformers = transformers.map(transformer => transformer(program)(context));
return sourceFile => {
return initializedTransformers.reduce((source, transformer) => {
return transformer(source);
}, sourceFile);
};
};
}
抛出语法错误以提升开发者体验
待办事项 - 这种方式是否像 Babel 那样可行? 或者我们是否可以使用 语言服务插件?
测试
通常情况下,对于转换器而言,单元测试的实用性相当有限。我建议编写集成测试,这样你的测试才会更加实用且具有韧性。归结起来就是:
- 优先编写集成测试,而非单元测试
- 避免使用快照测试——只有在有意义的情况下才使用;快照越大,其价值越低
- 尽量为每个测试拆解出特定的行为,并且每个测试只断言一件事
如果你愿意,可以使用 TypeScript 编译器 API 来设置你的转换器进行测试,不过我更推荐直接使用现成的库。
ts-transformer-testing-library
这个库让测试转换器变得非常简单。它专为与诸如 jest 之类的测试运行器配合使用而设计。它简化了转换器的配置流程,同时仍然允许你像编写其他软件一样编写测试。
以下是一个使用该库的示例测试:
import { Transformer } from 'ts-transformer-testing-library';
import transformerFactory from '../index';
import pkg from '../../../../package.json';
const transformer = new Transformer()
.addTransformer(transformerFactory)
.addMock({ name: pkg.name, content: `export const jsx: any = () => null` })
.addMock({
name: 'react',
content: `export default {} as any; export const useState = {} as any;`,
})
.setFilePath('/index.tsx');
it('should add react default import if it only has named imports', () => {
const actual = transformer.transform(`
/** @jsx jsx */
import { useState } from 'react';
import { jsx } from '${pkg.name}';
<div css={{}}>hello world</div>
`);
// 我们还在这里使用 `jest-extended` 为 jest 对象添加额外的匹配器。
expect(actual).toIncludeRepeated('import React, { useState } from "react"', 1);
});
已知问题
EmitResolver 无法处理并非源自解析树的 JsxOpeningLikeElement 和 JsxOpeningFragment
如果你用一个新的 JSX 元素替换某个节点,例如:
const visitor = node => {
return ts.factory.createJsxFragment(
ts.factory.createJsxOpeningFragment(),
[],
ts.factory.createJsxJsxClosingFragment()
);
};
那么,如果周围存在 const 或 let 变量,就会导致程序崩溃。一个 workaround 是确保将开始和结束标签传递给 ts.setOriginalNode:
ts.createJsxFragment(
- ts.createJsxOpeningFragment(),
+ ts.setOriginalNode(ts.factory.createJsxOpeningFragment(), node),
[],
- ts.createJsxJsxClosingFragment()
+ ts.setOriginalNode(ts.factory.createJsxJsxClosingFragment(), node)
);
更多信息请参阅:https://github.com/microsoft/TypeScript/issues/35686。
常见问题
相似工具推荐
everything-claude-code
everything-claude-code 是一套专为 AI 编程助手(如 Claude Code、Codex、Cursor 等)打造的高性能优化系统。它不仅仅是一组配置文件,而是一个经过长期实战打磨的完整框架,旨在解决 AI 代理在实际开发中面临的效率低下、记忆丢失、安全隐患及缺乏持续学习能力等核心痛点。 通过引入技能模块化、直觉增强、记忆持久化机制以及内置的安全扫描功能,everything-claude-code 能显著提升 AI 在复杂任务中的表现,帮助开发者构建更稳定、更智能的生产级 AI 代理。其独特的“研究优先”开发理念和针对 Token 消耗的优化策略,使得模型响应更快、成本更低,同时有效防御潜在的攻击向量。 这套工具特别适合软件开发者、AI 研究人员以及希望深度定制 AI 工作流的技术团队使用。无论您是在构建大型代码库,还是需要 AI 协助进行安全审计与自动化测试,everything-claude-code 都能提供强大的底层支持。作为一个曾荣获 Anthropic 黑客大奖的开源项目,它融合了多语言支持与丰富的实战钩子(hooks),让 AI 真正成长为懂上
LLMs-from-scratch
LLMs-from-scratch 是一个基于 PyTorch 的开源教育项目,旨在引导用户从零开始一步步构建一个类似 ChatGPT 的大型语言模型(LLM)。它不仅是同名技术著作的官方代码库,更提供了一套完整的实践方案,涵盖模型开发、预训练及微调的全过程。 该项目主要解决了大模型领域“黑盒化”的学习痛点。许多开发者虽能调用现成模型,却难以深入理解其内部架构与训练机制。通过亲手编写每一行核心代码,用户能够透彻掌握 Transformer 架构、注意力机制等关键原理,从而真正理解大模型是如何“思考”的。此外,项目还包含了加载大型预训练权重进行微调的代码,帮助用户将理论知识延伸至实际应用。 LLMs-from-scratch 特别适合希望深入底层原理的 AI 开发者、研究人员以及计算机专业的学生。对于不满足于仅使用 API,而是渴望探究模型构建细节的技术人员而言,这是极佳的学习资源。其独特的技术亮点在于“循序渐进”的教学设计:将复杂的系统工程拆解为清晰的步骤,配合详细的图表与示例,让构建一个虽小但功能完备的大模型变得触手可及。无论你是想夯实理论基础,还是为未来研发更大规模的模型做准备
NextChat
NextChat 是一款轻量且极速的 AI 助手,旨在为用户提供流畅、跨平台的大模型交互体验。它完美解决了用户在多设备间切换时难以保持对话连续性,以及面对众多 AI 模型不知如何统一管理的痛点。无论是日常办公、学习辅助还是创意激发,NextChat 都能让用户随时随地通过网页、iOS、Android、Windows、MacOS 或 Linux 端无缝接入智能服务。 这款工具非常适合普通用户、学生、职场人士以及需要私有化部署的企业团队使用。对于开发者而言,它也提供了便捷的自托管方案,支持一键部署到 Vercel 或 Zeabur 等平台。 NextChat 的核心亮点在于其广泛的模型兼容性,原生支持 Claude、DeepSeek、GPT-4 及 Gemini Pro 等主流大模型,让用户在一个界面即可自由切换不同 AI 能力。此外,它还率先支持 MCP(Model Context Protocol)协议,增强了上下文处理能力。针对企业用户,NextChat 提供专业版解决方案,具备品牌定制、细粒度权限控制、内部知识库整合及安全审计等功能,满足公司对数据隐私和个性化管理的高标准要求。
ML-For-Beginners
ML-For-Beginners 是由微软推出的一套系统化机器学习入门课程,旨在帮助零基础用户轻松掌握经典机器学习知识。这套课程将学习路径规划为 12 周,包含 26 节精炼课程和 52 道配套测验,内容涵盖从基础概念到实际应用的完整流程,有效解决了初学者面对庞大知识体系时无从下手、缺乏结构化指导的痛点。 无论是希望转型的开发者、需要补充算法背景的研究人员,还是对人工智能充满好奇的普通爱好者,都能从中受益。课程不仅提供了清晰的理论讲解,还强调动手实践,让用户在循序渐进中建立扎实的技能基础。其独特的亮点在于强大的多语言支持,通过自动化机制提供了包括简体中文在内的 50 多种语言版本,极大地降低了全球不同背景用户的学习门槛。此外,项目采用开源协作模式,社区活跃且内容持续更新,确保学习者能获取前沿且准确的技术资讯。如果你正寻找一条清晰、友好且专业的机器学习入门之路,ML-For-Beginners 将是理想的起点。
ragflow
RAGFlow 是一款领先的开源检索增强生成(RAG)引擎,旨在为大语言模型构建更精准、可靠的上下文层。它巧妙地将前沿的 RAG 技术与智能体(Agent)能力相结合,不仅支持从各类文档中高效提取知识,还能让模型基于这些知识进行逻辑推理和任务执行。 在大模型应用中,幻觉问题和知识滞后是常见痛点。RAGFlow 通过深度解析复杂文档结构(如表格、图表及混合排版),显著提升了信息检索的准确度,从而有效减少模型“胡编乱造”的现象,确保回答既有据可依又具备时效性。其内置的智能体机制更进一步,使系统不仅能回答问题,还能自主规划步骤解决复杂问题。 这款工具特别适合开发者、企业技术团队以及 AI 研究人员使用。无论是希望快速搭建私有知识库问答系统,还是致力于探索大模型在垂直领域落地的创新者,都能从中受益。RAGFlow 提供了可视化的工作流编排界面和灵活的 API 接口,既降低了非算法背景用户的上手门槛,也满足了专业开发者对系统深度定制的需求。作为基于 Apache 2.0 协议开源的项目,它正成为连接通用大模型与行业专有知识之间的重要桥梁。
PaddleOCR
PaddleOCR 是一款基于百度飞桨框架开发的高性能开源光学字符识别工具包。它的核心能力是将图片、PDF 等文档中的文字提取出来,转换成计算机可读取的结构化数据,让机器真正“看懂”图文内容。 面对海量纸质或电子文档,PaddleOCR 解决了人工录入效率低、数字化成本高的问题。尤其在人工智能领域,它扮演着连接图像与大型语言模型(LLM)的桥梁角色,能将视觉信息直接转化为文本输入,助力智能问答、文档分析等应用场景落地。 PaddleOCR 适合开发者、算法研究人员以及有文档自动化需求的普通用户。其技术优势十分明显:不仅支持全球 100 多种语言的识别,还能在 Windows、Linux、macOS 等多个系统上运行,并灵活适配 CPU、GPU、NPU 等各类硬件。作为一个轻量级且社区活跃的开源项目,PaddleOCR 既能满足快速集成的需求,也能支撑前沿的视觉语言研究,是处理文字识别任务的理想选择。