本文档涵盖了如何编写一个 TypeScript Transformer.
简介
TypeScript 是 JavaScript 的类型超集,编译为纯 JavaScript。TypeScript 支持用户把一种代码转换成另一种代码,类似 Babel 通过插件实现那样,
关注我 @itsmadou 获取最新信息、参与讨论。
运行示例
本手册中有多个示例可供你使用,当你想要深入研究时,请确保执行以下步骤:
- 克隆仓库;
- 使用
yarn
安装依赖项; - 构建你想要的示例
yarn build example_name
;
基础知识
归根结底,转换器本质上是一个函数,它接收并返回一段代码,就像这样:
const Transformer = (code) => code;
不过,区别在于这里的 code
不是 string
类型,而是抽象语法树 (AST) 的,后面会进一步解释。借助 AST,我们可以做一些强大的事情,比如更新、替换、添加和删除 node
。
什么是抽象语法树 (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!你还可以在左下角面板中看到用于生成相同 AST 的代码,并在右侧面板中查看所选节点的元数据。非常有用!
在查看元数据时,你会注意到它们都有相似的结构(省略了一些属性):
{
kind: 288, // (SyntaxKind.SourceFile)
pos: 0,
end: 47,
statements: [{...}],
}
{
kind: 243, // (SyntaxKind.FunctionDeclaration)
pos: 0,
end: 47,
name: {...},
body: {...},
}
{
kind: 225, // (SyntaxKind.ExpressionStatement)
pos: 19,
end: 45,
expression: {...}
}
SyntaxKind
是 TypeScript 枚举,用于描述节点的类型,更多信息请阅读 Basarat 的 AST 小贴士。
上面的数据描述了一个个节点信息,AST 可以由一个到多个节点组成,它们共同描述了程序的语法结构,可用于静态分析。
每个节点都有一个 kind
属性,用来描述节点的类型,以及 pos 和 end 属性,用来描述节点在源代码中的位置。我们将在手册后面的章节中讨论如何将节点限定为特定类型的节点。
编译器的几个阶段
非常类似于 Babel , TypeScript 也有五个阶段:解析器(parser)、绑定器(binder)、检查器(checker)、转换(transform)、代码生成(emitting)。
有两个步骤是 TypeScript 独有的,即绑定器(binder)和检查器(checker)。我们将会略过检查器(checker),因为它涉及到 TypeScript 特有的类型检查细节。
为了更深入地理解 TypeScript 编译器的内部工作原理,请阅读Basarat 的手册。
TypeScript 中的 Program
在开始介绍编译器之前,我们需要快速理解在 TypeScript 中 Program 到底是什么,一个 Program 由一个或多个入口源文件组成的集合,这些源文件会引用一个或多个模块,整个集合会在每个编译阶段被使用。
这与 Babel 处理文件的方式有所不同,Babel 是对单个文件进行输入输出处理,而 TypeScript 则是对整个项目进行输入输出处理。这就是为什么在使用 Babel 解析 TypeScript 时,枚举(enums)无法正常工作的原因,Babel 解析时并没有所有必要的上下文信息。
Parser
TypeScript 解析器实际上由扫描器(scanner)和解析器(parser)两部分组成,这个一步会将源代码转换成抽象语法树(AST)。
SourceCode ~~ scanner ~~> Token Stream ~~ parser ~~> AST
解析器接收源代码,并在内存中转换成抽象语法树(AST)表示形式,这种表示形式可以让你在编译器中进行操作。可以参考 解析器。
Scanner
扫描器被解析器用来以线性方式(扫描器按顺序一个接一个地处理输入字符串中的字符)将字符串转换成一系列的词法单元(tokens),然后由解析器将这些词法单元构造成树状结构。可以参考扫描器。
Binder
创建符号映射表,并利用抽象语法树(AST)来提供类型系统,这对于链接引用以及能够识别导入和导出节点非常重要。可以参考绑定器。
Transforms
这是我们所期待的关键步骤,它使我们开发者能够以任何我们认为合适的方式改变代码,例如性能优化、编译时行为,几乎是我们能想象到的任何事情。
有三个阶段的转换是我们需要关心的:
- before - 在 TypeScript 的转换器之前运行(代码还未被编译);
- after - 在 TypeScript 的转换器之后运行(代码已经被编译);
- afterDeclarations - 在声明阶段之后运行(你可以在这里修改类型定义);
通常情况下,90%的需求会让我们编写 before 阶段的转换器,但如果需要进行编译后的转换或者修改类型,那么就会需要用到 after
和 afterDeclarations
。
Emitting
这一阶段发生在最后,负责将最终的代码输出到某个地方,一般来说是文件系统,但也可能是内存中。
遍历 AST
当你需要以任何方式修改抽象语法树(AST)时,你需要遍历这棵树,通常是递归。说得更具体一点,我们想要访问每一个节点,然后返回相同的节点、更新后的节点或者一个全新的节点。
如果我们采用之前例子中的抽象语法树(AST)的 JSON 格式(省略了一些值):
{
kind: 288, // (SyntaxKind.SourceFile)
statements: [{
kind: 243, // (SyntaxKind.FunctionDeclaration)
name: {
kind: 75 // (SyntaxKind.Identifier)
escapedText: "hello"
},
body: {
kind: 222, // (SyntaxKind.Block)
statements: [{
kind: 225, // (SyntaxKind.ExpressionStatement)
expression: {
kind: 195, // (SyntaxKind.CallExpression)
expression: {
kind: 193, // (SyntaxKind.PropertyAccessExpression)
name: {
kind: 75 // (SyntaxKind.Identifier)
escapedText: "log",
},
expression: {
kind: 75, // (SyntaxKind.Identifier)
escapedText: "console",
}
}
},
arguments: [{
kind: 10, // (SyntaxKind.StringLiteral)
text: "world",
}]
}]
}
}]
}
如果我们遍历它,我们会从 SourceFile 开始,然后逐一处理每个节点。你可能会认为你可以用自己的方式遍历它,比如像 source.statements[0].name 等等,但你会发现这些方法不容易扩展,并且很容易出错,因此要谨慎使用。
理想情况下,对于 90% 的情况,您需要使用内置方法遍历 AST。TypeScript 为我们提供了两种主要方法:
visitNode()
通常,你只会把初始的 SourceFile
节点传递给它,后续我们会详细介绍什么是 visitor
函数。
import * as ts from 'typescript';
ts.visitNode(sourceFile, visitor);
visitEachChild()
这是一个特殊函数,它内部使用了 visitNode
。它会处理遍历到最内层的节点,它知道如何去做,无需你费心考虑。我们稍后会详细介绍什么是 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);
};
注意:我们需要返回每一个节点。这是必需的!如果我们不这样做,你会看到一些奇怪的错误。
如果我们将此应用于之前使用的代码示例,我们将在控制台中看到此记录(在后面添加注释):
288 # ts.SyntaxKind.SourceFile
243 # ts.SyntaxKind.FunctionDeclaration
75 # ts.SyntaxKind.Identifier
222 # ts.SyntaxKind.Block
225 # ts.SyntaxKind.ExpressionStatement
195 # ts.SyntaxKind.CallExpression
193 # ts.SyntaxKind.PropertyAccessExpression
75 # ts.SyntaxKind.Identifier
75 # ts.SyntaxKind.Identifier
10 # ts.SyntaxKind.StringLiteral
您可以在 /example-transformers/log-every-node 路径下查看其源代码,如果想在本地运行,可以通执行
yarn build log-every-node
运行它。
它尽可能深入每一个节点,到达最底层后退出,然后再进入其他子节点。
context
每个转换器都会收到转换上下文。这个上下文不仅用于 visitEachChild
,还可以做一些有用的事情,比如获取当前 TypeScript 配置的信息,我们很快就会看到第一个简单的 TypeScript 转换器示例。
作用域
这部分内容大多直接取自Babel 手册,同样适用。
接下来,我们来介绍“作用域”的概念。JavaScript 具有词法作用域(闭包),这是一种树状结构,在这种结构中,代码块会创建新的作用域。
// global scope
function scopeOne() {
// scope 1
function scopeTwo() {
// scope 2
}
}
每当你在 JavaScript 中创建一个引用,无论是通过变量、函数、类、参数、导入、标签等,这个引用都属于当前的作用域。
var global = 'I am in the global scope';
function scopeOne() {
var one = 'I am in the scope created by `scopeOne()`';
function scopeTwo() {
var two = 'I am in the scope created by `scopeTwo()`';
}
}
在一个更深的作用域中的代码可以使用来自更高作用域的引用。
function scopeOne() {
var one = 'I am in the scope created by `scopeOne()`';
function scopeTwo() {
one = 'I am updating the reference in `scopeOne` inside `scopeTwo`';
}
}
一个较低的作用域也可能创建同名的引用,而不是对其进行修改。
function scopeOne() {
var one = 'I am in the scope created by `scopeOne()`';
function scopeTwo() {
var one = 'I am creating a new `one` but leaving reference in `scopeOne()` alone.';
}
}
在编写转换逻辑时,我们需要小心处理作用域问题,我们必须确保在修改代码时不会破坏已存在的代码。我们可能需要添加新的引用并确保它们不会与已有的引用发生冲突,或者,我们只想找到变量被引用的位置,我们需要能够在给定的作用域内追踪这些引用。
绑定
所有的引用都属于特定的作用域,这种关系被称为绑定。
function scopeOnce() {
var ref = 'This is a binding';
ref; // This is a reference to a binding
function scopeTwo() {
ref; // This is a reference to a binding from a lower scope
}
}
Transformer API
在编写转换器时,你希望通过 TypeScript 编写,你将使用 typescript
来完成大部分繁重的工作。TypeScript 被用于所有方面,这与 Babel 不同,Babel 有单独的小型包。
首先,让我们安装它:
npm i typescript --save
然后,我们导入它:
import * as ts from 'typescript';
提示:我强烈建议你在 VSCode 中使用智能提示来查询 API,这非常有用!
Visiting
这些方法对于遍历节点非常有用,我们已经在上面简单地介绍过其中的一些:
ts.visitNode(node, visitor)
- 通常用于访问根节点,一般情况下指的是SourceFile
;ts.visitEachChild(node, visitor, context)
- 用于访问节点的每个子节点;ts.isXyz(node)
- 用于判断节点类型, 比如ts.isVariableDeclaration(node)
用于判断是否为变量声明;
Nodes
提示 - 使用 ts-creator 可以快速获取 TypeScript 源代码的工厂函数,这样你就无需仔细地为一个节点编写抽象语法树(AST),而是可以写一个代码字符串,让系统自动帮你将其转换为 AST。
这些方法对于以某种形式修改节点非常有用:
-
ts.createXyz(...)
- 用于创建一个新的节点(然后返回), 例如ts.createIdentifier('world')
可以创建一个标识符节点; -
ts.updateXyz(node, ...)
- 用于更新一个节点(然后返回),例如ts.updateVariableDeclaration()
可以更新一个变量声明节点; -
ts.updateSourceFileNode(sourceFile, ...)
- 用于更新一个源文件并返回; -
ts.setOriginalNode(newNode, originalNode)
- 用于设置节点的原始节点; -
ts.setXyz(...)
- 设置属性值; -
ts.addXyz(...)
- 添加属性值;
context
如上所述,这些方法被提供给每一个转换器,并且包含了一些非常实用的功能(这里列出的并非全部功能,仅列举我们关心的部分):
getCompilerOptions()
- 获取传递给转换器的编译器配置;hoistFunctionDeclaration(node)
- 将函数声明提升到当前作用域的顶部;hoistVariableDeclaration(node)
- 将变量声明提升到当前作用域的顶部;
program
这是一种特殊的属性,在编写程序级别的转换器时可用,我们将在转换器类型一节中详细介绍这种类型的转换器。它包含了关于整个程序的元数据,例如(这里列出的并非所有功能,仅列举我们关心的部分):
getRootFileNames()
- 获取项目文件名数组;getSourceFiles()
- 获取项目中所有的SourceFile
;getCompilerOptions()
- 获取来自 tsconfig.json 文件、命令行或其他地方的编译器配置(也可以从上下文中获取);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) => {
// transformation code here
};
};
export default transformer;
由于我们使用 TypeScript 来编写转换器,因此我们不仅获得了类型安全性,更重要的是还获得了智能提示!如果你已经写到这里,你会注意到 TypeScript 提示我们没有返回一个 SourceFile 对象,让我们来修复这个问题。
import * as ts from "typescript";
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return sourceFile => {
// transformation code here
+ return sourceFile;
};
};
export default transformer;
太好了,我们解决了类型错误!
对于我们的第一个转换器,我们将从 Babel 手册中获取灵感,重命名一些标识符。
这是我们的源代码:
babel === plugins;
好的,让我们来编写一个访问者函数,记住访问者函数应该取一个节点,然后返回一个节点。
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);
-
- 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);
};
};
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)) {
+ // transform here
+ }
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
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':
+ // rename babel
+ case 'plugins':
+ // rename plugins
+ }
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
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.createIdentifier('typescript');
case 'plugins':
+ return ts.createIdentifier('transformers');
}
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
export default transformer;
太好了!当我们运行我们的源代码时,我们得到了这样的输出:
typescript === transformers;
你可以在这个路径下查看源代码: /example-transformers/my-first-transformer,如果你想在本地运行它,可以通过
yarn build my-first-transformer
来执行。
转换器类型
所有的转换器最终都会返回 TransformerFactory
类型签名,这些转换器类型都来源于 ttypescript
。
工厂型
也称为原始型,这与你在编写第一个转换器时所使用的相同。
// ts.TransformerFactory
(context: ts.TransformationContext) => (sourceFile: ts.SourceFile) => ts.SourceFile;
配置型
当你的转换器需要被使用者掌控的配置时,你可以使用如下签名来定义你的插件接口:
(config?: YourPluginConfigInterface) => ts.TransformerFactory;
程序型
当你需要访问程序对象时,应该使用这种签名,同样也需要返回一个 TransformerFactory,第二个参数是配置对象,由使用者提供。
(program: ts.Program, config?: YourPluginConfigInterface) => ts.TransformerFactory;
使用转换器
有趣的是,TypeScript 目前并没有官方支持通过 tsconfig.json 文件来使用转换器(transformers)。有一个专门的 GitHub issue 讨论引入这一功能的可能性。尽管如此,你仍然可以使用转换器,只是方法稍微有些迂回。
ttypescript
这是目前最值得推荐的做法!希望将来 TypeScript 能够正式支持这种方式。
这实质上是对 tsc
命令行工具的一个封装,它通过 tsconfig.json
文件为转换器提供了一流的支持,它将 TypeScript 列为一个对等依赖项,因此理论上来说,这种方式不太容易出现问题。
安装依赖:
npm i ttypescript typescript -D
在编译选项中添加你的转换器:
{
"compilerOptions": {
"plugins": [{ "transform": "my-first-transformer" }]
}
}
运行 ttsc
:
ttsc
ttypescript
支持 tsc
命令行工具、Webpack
、Parcel
、Rollup
、Jest
和 VSCode
,这些基本上涵盖了我们想要使用的所有工具。
webpack
使用 awesome-typescript-loader
或 ts-loader
,你可以使用 getCustomTransformers()
选项 (这两个 loader 的该选项签名相同) 或者使用ttypescript
之一来配置自定义转换:
{
test: /\.(ts|tsx)$/,
loader: require.resolve('awesome-typescript-loader'),
// or
loader: require.resolve('ts-loader'),
options: {
compiler: 'ttypescript' // recommended, allows you to define transformers in tsconfig.json
// or
getCustomTransformers: program => {
before: [yourBeforeTransformer(program, { customConfig: true })],
after: [yourAfterTransformer(program, { customConfig: true })],
}
}
}
parcel
使用 ttypescript 与 parcel-plugin-ttypescript 插件,参考: github.com/cevek/ttype…
转换操作
遍历
检查一个节点是否为某种类型
有各种各样的辅助方法可以判定一个节点属于哪种类型,当这些方法返回 true 时,缩小节点的类型范围,还能基于该类型获取额外的属性和方法。
提示:可以利用智能提示查询 TypeScript 导入中的可用方法,同时使用 TypeScript AST 查看器来了解节点的类型。
import * as ts from 'typescript';
const visitor = (node: ts.Node): ts.Node => {
if (ts.isJsxAttribute(node.parent)) {
// node.parent is a jsx attribute
// ...
}
};
检查两个标识符是否指向同一个符号
标识符由解析器创建,并且总是唯一的。比如,如果你创建了一个变量 foo 并在另一行中使用它,这将会创建两个具有相同文本 foo 的不同标识符。
之后,连接器会遍历这些标识符,并将指向同一个变量的标识符与一个共同的符号相连(同时考虑到作用域和遮蔽)。可以把符号理解为我们直观上认为的变量。
因此,要检查两个标识符是否指向同一个符号,只需获取与该标识符相关的符号,然后检查它们是否相同(通过引用)。
简例:
const symbol1 = typeChecker.getSymbolAtLocation(node1);
const symbol2 = typeChecker.getSymbolAtLocation(node2);
symbol1 === symbol2; // check by reference
全部代码:
import * as ts from 'typescript';
const transformerProgram = (program: ts.Program) => {
const typeChecker = program.getTypeChecker();
// Create array of found symbols
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);
// Check if array already contains same symbol - check by reference
if (foundSymbols.includes(relatedSymbol)) {
const foundIndex = foundSymbols.indexOf(relatedSymbol);
console.log(
`Found existing symbol at position = ${foundIndex} and name = "${relatedSymbol.name}"`
);
} else {
// If not found, Add it to array
foundSymbols.push(relatedSymbol);
console.log(
`Found new symbol with name = "${relatedSymbol.name}". Added at positon = ${
foundSymbols.length - 1
}`
);
}
return node;
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
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('string literal has a function declaration parent');
}
return node;
}
};
将会在控制台中记录如下信息,string literal has a function declaration parent
,源代码为:
function hello() {
if (true) {
('world');
}
}
在替换一个节点后进行遍历时要小心,因为父节点可能未被设置,如果你需要在转换后进行遍历,请确保自行设置节点的父节点。
你可以在这个路径下查看与此相关的源代码:/example-transformers/find-parent。如果你想在本地运行它,可以通过执行
yarn build find-parent
来实现。
停止遍历
在访问者函数中,你可以选择提前返回而不是继续遍历子节点,例如,如果我们遇到一个节点并且知道无需进一步深入:
const visitor = (node: ts.Node): ts.Node => {
if (ts.isArrowFunction(node)) {
// return early
return node;
}
};
操作节点
更新节点
if (ts.isVariableDeclaration(node)) {
return ts.updateVariableDeclaration(node, node.name, node.type, ts.createStringLiteral('world'));
}
-const hello = true;
+const hello = "updated-world";
你可以在这个位置查看源代码: /example-transformers/update-node,如果你想本地运行,可以通过执行
yarn build update-node
来运行它 。
另外,我们也可以通过 getMutableClone(node)
来修改节点,顺便提一下,ts-loader 中存在一个 bug,这使得这种方法不太可行;目前强烈建议暂时不要使用这种方法。
if (ts.isVariableDeclaration(node)) {
const newNode = ts.getMutableClone(node) as ts.VariableDeclaration;
newNode.initializer = ts.createStringLiteral('mutable-world');
return newNode;
}
-const hello = true;
+const hello = "mutable-world";
你可以在这个位置查看源代码: /example-transformers/update-mutable-node,如果你想本地运行,可以通过执行
yarn build update-mutable-node
来运行它 。
你会注意到,除非你先获取可变克隆(getMutableClone),否则无法进行修改操作,这是设计时有意为之的。
替换节点
有时候我们可能不想只是更新一个节点,而是想彻底地替换它。要做到这一点,我们只需返回一个全新的节点即可。
if (ts.isFunctionDeclaration(node)) {
// Will replace any function it finds with an arrow function.
return ts.createVariableDeclarationList(
[
ts.createVariableDeclaration(
ts.createIdentifier(node.name.escapedText),
undefined,
ts.createArrowFunction(
undefined,
undefined,
[],
undefined,
ts.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
ts.createBlock([], false)
)
),
],
ts.NodeFlags.Const
);
}
-function helloWorld() {}
+const helloWorld = () => {};
你可以在这个位置查看源代码: /example-transformers/replace-node,如果你想本地运行,可以通过执行
yarn build replace-node
来运行它 。
用个节点替换单节点
有趣的是,访问者函数不仅可以返回一个单一的新节点,还可以返回一个包含多个节点的数组,这意味着即使输入的是一个节点,也可以用多个节点来替代这个输入节点。
export type Visitor = (node: Node) => VisitResult<Node>;
export type VisitResult<T extends Node> = T | T[] | undefined;
让我们用每个表达式语句的两个副本(即复制原语句)来替换掉每一个表达式语句。
const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
return (sourceFile) => {
const visitor = (node: ts.Node): ts.VisitResult<ts.Node> => {
// If it is a expression statement,
if (ts.isExpressionStatement(node)) {
// Return it twice.
// Effectively duplicating the statement
return [node, node];
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
因此
let a = 1;
a = 2;
变成了
let a = 1;
a = 2;
a = 2;
你可以在这个位置查看源代码: /example-transformers/return-multiple-node,如果你想本地运行,可以通过执行
yarn build return-multiple-node
来运行它 。
声明语句(第一行)被忽略,因为它不是一个表达式语句。
请注意,确保你要做的事情在抽象语法树(AST)中是合理的。例如,返回两个表达式而不是一个,这样通常是无效的。
假设有一个赋值表达式(使用等号操作符=的二元表达式),如 a = b = 2,现在如果返回两个节点而不是 b = 2 这个表达式则是无效的(因为赋值表达式的右侧不能包含多个节点),这时,TypeScript(TS)会抛出一个错误,调试失败,错误信息:输出中的节点数量过多。
插入兄弟节点
这实际上与上一节相同,只需返回一个包含自身和其他兄弟节点的节点数组即可。
删除节点
如果你不再需要某个特定的节点了,那就返回 undefined!
if (ts.isImportDeclaration(node)) {
// Will remove all import declarations
return undefined;
}
import lodash from 'lodash';
-import lodash from 'lodash';
你可以在这个位置查看源代码: /example-transformers/remove-node,如果你想本地运行,可以通过执行
yarn build remove-node
来运行它 。
添加新的导入声明
有时你的转换过程可能需要一些运行时组件,为此你可以添加自己的导入声明。
ts.updateSourceFileNode(sourceFile, [
ts.createImportDeclaration(
/* decorators */ undefined,
/* modifiers */ undefined,
ts.createImportClause(
ts.createIdentifier('DefaultImport'),
ts.createNamedImports([
ts.createImportSpecifier(undefined, ts.createIdentifier('namedImport')),
])
),
ts.createLiteral('package')
),
// Ensures the rest of the source files statements are still defined.
...sourceFile.statements,
]);
+import DefaultImport, { namedImport } from "package";
你可以在这个位置查看源代码: /example-transformers/add-import-declaration,如果你想本地运行,可以通过执行
yarn build add-import-declaration
来运行它 。
作用域
将变量声明推送到作用域的顶部
有时候,你可能想要将变量声明移动到其所在作用域的最前面,这样就可以对它进行赋值操作了。需要注意的是,这样做只会提升变量本身的作用域范围,让变量在整个作用域内可访问,而原始的赋值语句仍然保留在源代码中的原位置不变。
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
来运行它 。
定义唯一变量
有时候,你可能想要添加一个在其作用域内具有唯一名称的新变量,幸运的是,这无需经历任何繁琐步骤就可以实现。
if (ts.isVariableDeclarationList(node)) {
return ts.updateVariableDeclarationList(node, [
...node.declarations,
ts.createVariableDeclaration(
ts.createUniqueName('hello'),
undefined,
ts.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
来运行它 。
查找
获取行号和列号
sourceFile.getLineAndCharacterOfPosition(node.getStart());
高级
关联模块导入
// We need to use a Program transformer to get ahold of the program object.
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(
`found "${
symbol.escapedName
}" export with value "${symbol.valueDeclaration.getText()}"`
)
);
return node;
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
return transformerFactory;
};
控制台输出:
found "hello" export with value "hello = 'world'"
found "default" export with value "export default 'hello';"
你也可以使用 ts.visitChild
等方法来遍历导入的节点。
你可以在这个位置查看源代码: /example-transformers/follow-imports,如果你想本地运行,可以通过执行
yarn build follow-imports
来运行它 。
关联模块节点导入
就像跟踪自己代码中的 TypeScript 导入一样,有时我们可能也想审查我们所导入模块内部的代码。使用上面相同的代码,只是运行在对 node_modules 的导入上,我们在控制台上记录到了这样的信息:
found "mixin" export with value:
export declare function mixin(): {
color: string;
};"
found "constMixin" export with value:
export declare function constMixin(): {
color: 'blue';
};"
嗯,怎么会这样,我们得到的是类型定义的抽象语法树(AST),而不是源代码……真没劲!要实现这个功能对我们来说有点困难(至少不是开箱即用那么简单)。实际上我们有两种选择:
- 在 tsconfig 中启用 allowJs 并删除类型定义……,这会给我们源代码的 AST……,但这样一来我们就不再有类型定义了……,所以这不是一个理想的选择。
- 创建另一个 TypeScript 程序并自己完成这些工作;
剧透一下:我们会选择第二种方案,这种方式更具弹性,即使关闭类型检查也能工作,这也是我们在那种情况下追踪 TypeScript 导入的方式!”
const visitor = (node: ts.Node): ts.Node => {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
// Find the import location in the file system using require.resolve
const pkgEntry = require.resolve(`${node.moduleSpecifier.text}`);
// Create another program
const innerProgram = ts.createProgram([pkgEntry], {
// Important to set this to 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.updateJsxXyz(node, ...)
ts.createJsxXyz(...)
更多细节可以查询 TypeScript 的导入,主要的一点是你需要创建有效的 JSX,如果你确保在转换器中类型是有效的的话,很难出错。
获取文件的 pragma
获取文件的 pragma 很有用,它可以让你在转换过程中执行某些操作。例如,如果我们想了解是否使用了自定义的 JSX pragma:
const transformer = (sourceFile) => {
const jsxPragma = sourceFile.pragmas.get('jsx');
if (jsxPragma) {
console.log(`a jsx pragma was found using the factory "${jsxPragma.arguments.factory}"`);
}
return sourceFile;
};
下面的源文件会导致 'a jsx pragma was found using the factory "jsx"'
这条消息被输出到控制台:
/** @jsx jsx */
你可以在这个位置查看源代码: /example-transformers/pragma-check,如果你想本地运行,可以通过执行
yarn build pragma-check
来运行它。
截至 2019 年 12 月 29 日,pragma 属性尚未被加入到 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 compiler 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>
`);
// We are also using `jest-extended` here to add extra matchers to the jest object.
expect(actual).toIncludeRepeated('import React, { useState } from "react"', 1);
});
已知问题
"EmitResolver" 无法处理非同源解析树的 JsxOpeningLikeElement 和 JsxOpeningFragment
如果你用一个新的 JSX 元素替换一个节点时像这样:
const visitor = (node) => {
return ts.createJsxFragment(ts.createJsxOpeningFragment(), [], ts.createJsxJsxClosingFragment());
};
如果周围存在任何 const
或 let
变量的话,这种情况会导致错误。一个解决办法是确保打开/关闭元素被传递给 ts.setOriginalNode
:
ts.createJsxFragment(
- ts.createJsxOpeningFragment(),
+ ts.setOriginalNode(ts.createJsxOpeningFragment(), node),
[],
- ts.createJsxJsxClosingFragment()
+ ts.setOriginalNode(ts.createJsxJsxClosingFragment(), node)
);
更多信息参考:microsoft/TypeScript#35686
getMutableClone(node) 与 ts-loader 使用时,引发问题
ts-loader 应该存在一个问题,在使用 getMutableClone(node) 时会导致类型检查第二次触发,这会导致在转换器(transformers)中的未定义行为,并通常导致程序崩溃。目前强烈建议避开使用这种方法。