TypeScript 转换器手册

114 阅读17分钟

文章来源:github.com/itsdouges/t…

本文档涵盖了如何编写一个 TypeScript Transformer.

简介

TypeScript 是 JavaScript 的类型超集,编译为纯 JavaScript。TypeScript 支持用户把一种代码转换成另一种代码,类似 Babel 通过插件实现那样,

关注我 @itsmadou 获取最新信息、参与讨论。

运行示例

本手册中有多个示例可供你使用,当你想要深入研究时,请确保执行以下步骤:

  1. 克隆仓库;
  2. 使用 yarn 安装依赖项;
  3. 构建你想要的示例 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 阶段的转换器,但如果需要进行编译后的转换或者修改类型,那么就会需要用到 afterafterDeclarations

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 命令行工具、WebpackParcelRollupJestVSCode,这些基本上涵盖了我们想要使用的所有工具。

webpack

使用 awesome-typescript-loaderts-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),而不是源代码……真没劲!要实现这个功能对我们来说有点困难(至少不是开箱即用那么简单)。实际上我们有两种选择:

  1. 在 tsconfig 中启用 allowJs 并删除类型定义……,这会给我们源代码的 AST……,但这样一来我们就不再有类型定义了……,所以这不是一个理想的选择。
  2. 创建另一个 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());
};

如果周围存在任何 constlet 变量的话,这种情况会导致错误。一个解决办法是确保打开/关闭元素被传递给 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)中的未定义行为,并通常导致程序崩溃。目前强烈建议避开使用这种方法。