typescript-transformer-handbook

GitHub
1k 34 简单 1 次阅读 今天语言模型
AI 解读 由 AI 自动生成,仅供参考

typescript-transformer-handbook 是一本专为 TypeScript 开发者编写的实战指南,旨在系统性地讲解如何编写自定义代码转换器(Transformers)。在 TypeScript 编译过程中,开发者有时需要深度干预代码的转换逻辑,例如自动注入代码、优化语法结构或实现特定的领域特定语言(DSL),而官方文档往往缺乏具体的实操细节。这份手册正是为了解决这一痛点,填补了从理论概念到工程落地之间的空白。

它非常适合希望深入理解 TypeScript 编译器内部机制、需要定制编译流程的高级前端工程师及工具链开发者阅读。内容不仅涵盖了抽象语法树(AST)的基础概念和编译阶段解析,更提供了大量可运行的代码示例,详细演示了如何遍历节点、修改语法树、管理作用域以及处理复杂的类型检查逻辑。

其独特的技术亮点在于对 Transformer API 的深度剖析,包括如何安全地替换节点、动态添加导入声明、重命名绑定以及在 Webpack 或 ttypescript 等主流构建工具中集成自定义转换器。此外,手册还分享了组合转换器、抛出友好语法错误等实用技巧,并介绍了专门的测试库,帮助开发者高效构建稳定可靠的编译插件。无论你是想扩展 TypeScript 的能力边界,还是致力于开发高效的构建工具,这份手册都是不可或缺的参考资源。

使用场景

某大型前端团队在构建内部低代码平台时,需要自定义 TypeScript 编译流程,将特定的业务注解自动转换为运行时校验逻辑。

没有 typescript-transformer-handbook 时

  • 开发者面对 TypeScript 复杂的抽象语法树(AST)文档无从下手,难以理解 Scanner、Binder 到 Transforms 的具体编译阶段。
  • 尝试手动编写转换器时,常因不懂如何正确操作 contextvisitNode,导致节点替换失败或破坏原有的作用域绑定。
  • 缺乏系统的操作指南,在处理“变量提升”、“重命名引用”或“动态插入 Import"等高级需求时只能盲目试错。
  • 调试过程极其痛苦,无法快速定位是遍历逻辑错误还是节点克隆方式不当,严重拖慢开发进度。

使用 typescript-transformer-handbook 后

  • 团队通过手册清晰的“编译阶段”图解,迅速掌握了从解析到发射的全流程,精准定位代码注入的最佳时机。
  • 依据手册中关于 visitor 模式和 context 用法的最佳实践,轻松实现了安全的节点遍历与无损替换。
  • 直接复用手册提供的“转换操作”食谱(如添加导入声明、检查符号同一性),高效完成了业务注解到校验代码的自动化生成。
  • 借助手册推荐的测试库和常见陷阱提示,大幅减少了运行时报错,确保了自定义转换器在 Webpack 和 ttypescript 中的稳定运行。

typescript-transformer-handbook 将晦涩的编译器底层原理转化为可落地的实战指南,让开发者能像搭积木一样安全、高效地定制 TypeScript 编译能力。

运行环境要求

操作系统
  • 未说明
GPU

不需要 GPU

内存

未说明

依赖
notes该工具是 TypeScript 编译器插件开发指南,非独立运行的 AI 模型。运行示例需要安装 Node.js 和 Yarn 包管理器。主要依赖为 TypeScript 及其相关构建工具(如 ttypescript, webpack, parcel)。无需 GPU 或特定 Python 环境。
python不需要 Python
typescript
ttypescript
webpack
parcel
typescript-transformer-handbook hero image

快速开始

TypeScript 转换器手册

本文档介绍了如何编写一个 TypeScript转换器

目录

引言

TypeScript 是 JavaScript 的带类型超集,最终会被编译成普通的 JavaScript。TypeScript 支持消费者将代码从一种形式“转换”为另一种形式的功能,这与 Babel 使用“插件”来实现类似。

关注我 @itsmadou,获取最新动态和讨论

运行示例

本手册中提供了多个可供使用的示例。如果你想深入学习,请确保:

  1. 克隆仓库
  2. 使用 yarn 安装依赖
  3. 构建你想要的示例: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 属性,用于描述节点的类型;此外还有 posend 属性,分别表示该节点在源代码中的起始和结束位置。我们将在手册的后续部分讨论如何将节点缩小到特定类型。

阶段

与 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 阶段的转换器, 但如果你需要在编译后进行一些转换,或者修改类型, 那么你可能会选择使用 afterafterDeclarations 阶段。

提示:类型检查不应该发生在转换之后。 如果发生了这种情况,很可能是出现了 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。注意,visitNodetest 参数可以用来确保返回特定类型的节点。

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 而不是源代码呢……真扫兴!

事实证明,要实现这一点稍微困难一些(至少不能直接开箱即用)。我们有两种选择:

  1. tsconfig 中启用 allowJs,并删除类型定义……这样我们就能获得源代码的 AST……但同时也就失去了类型信息。因此这种方法并不理想。
  2. 创建另一个 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 无法处理并非源自解析树的 JsxOpeningLikeElementJsxOpeningFragment

如果你用一个新的 JSX 元素替换某个节点,例如:

const visitor = node => {
  return ts.factory.createJsxFragment(
    ts.factory.createJsxOpeningFragment(), 
    [], 
    ts.factory.createJsxJsxClosingFragment()
  );
};

那么,如果周围存在 constlet 变量,就会导致程序崩溃。一个 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 真正成长为懂上

142.7k|★★☆☆☆|今天
开发框架Agent语言模型

LLMs-from-scratch

LLMs-from-scratch 是一个基于 PyTorch 的开源教育项目,旨在引导用户从零开始一步步构建一个类似 ChatGPT 的大型语言模型(LLM)。它不仅是同名技术著作的官方代码库,更提供了一套完整的实践方案,涵盖模型开发、预训练及微调的全过程。 该项目主要解决了大模型领域“黑盒化”的学习痛点。许多开发者虽能调用现成模型,却难以深入理解其内部架构与训练机制。通过亲手编写每一行核心代码,用户能够透彻掌握 Transformer 架构、注意力机制等关键原理,从而真正理解大模型是如何“思考”的。此外,项目还包含了加载大型预训练权重进行微调的代码,帮助用户将理论知识延伸至实际应用。 LLMs-from-scratch 特别适合希望深入底层原理的 AI 开发者、研究人员以及计算机专业的学生。对于不满足于仅使用 API,而是渴望探究模型构建细节的技术人员而言,这是极佳的学习资源。其独特的技术亮点在于“循序渐进”的教学设计:将复杂的系统工程拆解为清晰的步骤,配合详细的图表与示例,让构建一个虽小但功能完备的大模型变得触手可及。无论你是想夯实理论基础,还是为未来研发更大规模的模型做准备

90.1k|★★★☆☆|今天
语言模型图像Agent

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 提供专业版解决方案,具备品牌定制、细粒度权限控制、内部知识库整合及安全审计等功能,满足公司对数据隐私和个性化管理的高标准要求。

87.6k|★★☆☆☆|2天前
开发框架语言模型

ML-For-Beginners

ML-For-Beginners 是由微软推出的一套系统化机器学习入门课程,旨在帮助零基础用户轻松掌握经典机器学习知识。这套课程将学习路径规划为 12 周,包含 26 节精炼课程和 52 道配套测验,内容涵盖从基础概念到实际应用的完整流程,有效解决了初学者面对庞大知识体系时无从下手、缺乏结构化指导的痛点。 无论是希望转型的开发者、需要补充算法背景的研究人员,还是对人工智能充满好奇的普通爱好者,都能从中受益。课程不仅提供了清晰的理论讲解,还强调动手实践,让用户在循序渐进中建立扎实的技能基础。其独特的亮点在于强大的多语言支持,通过自动化机制提供了包括简体中文在内的 50 多种语言版本,极大地降低了全球不同背景用户的学习门槛。此外,项目采用开源协作模式,社区活跃且内容持续更新,确保学习者能获取前沿且准确的技术资讯。如果你正寻找一条清晰、友好且专业的机器学习入门之路,ML-For-Beginners 将是理想的起点。

85k|★★☆☆☆|今天
图像数据工具视频

ragflow

RAGFlow 是一款领先的开源检索增强生成(RAG)引擎,旨在为大语言模型构建更精准、可靠的上下文层。它巧妙地将前沿的 RAG 技术与智能体(Agent)能力相结合,不仅支持从各类文档中高效提取知识,还能让模型基于这些知识进行逻辑推理和任务执行。 在大模型应用中,幻觉问题和知识滞后是常见痛点。RAGFlow 通过深度解析复杂文档结构(如表格、图表及混合排版),显著提升了信息检索的准确度,从而有效减少模型“胡编乱造”的现象,确保回答既有据可依又具备时效性。其内置的智能体机制更进一步,使系统不仅能回答问题,还能自主规划步骤解决复杂问题。 这款工具特别适合开发者、企业技术团队以及 AI 研究人员使用。无论是希望快速搭建私有知识库问答系统,还是致力于探索大模型在垂直领域落地的创新者,都能从中受益。RAGFlow 提供了可视化的工作流编排界面和灵活的 API 接口,既降低了非算法背景用户的上手门槛,也满足了专业开发者对系统深度定制的需求。作为基于 Apache 2.0 协议开源的项目,它正成为连接通用大模型与行业专有知识之间的重要桥梁。

77.1k|★★★☆☆|3天前
Agent图像开发框架

PaddleOCR

PaddleOCR 是一款基于百度飞桨框架开发的高性能开源光学字符识别工具包。它的核心能力是将图片、PDF 等文档中的文字提取出来,转换成计算机可读取的结构化数据,让机器真正“看懂”图文内容。 面对海量纸质或电子文档,PaddleOCR 解决了人工录入效率低、数字化成本高的问题。尤其在人工智能领域,它扮演着连接图像与大型语言模型(LLM)的桥梁角色,能将视觉信息直接转化为文本输入,助力智能问答、文档分析等应用场景落地。 PaddleOCR 适合开发者、算法研究人员以及有文档自动化需求的普通用户。其技术优势十分明显:不仅支持全球 100 多种语言的识别,还能在 Windows、Linux、macOS 等多个系统上运行,并灵活适配 CPU、GPU、NPU 等各类硬件。作为一个轻量级且社区活跃的开源项目,PaddleOCR 既能满足快速集成的需求,也能支撑前沿的视觉语言研究,是处理文字识别任务的理想选择。

75k|★★★☆☆|今天
语言模型图像开发框架