TypeScript 编译器 API 实战:写一个自定义 Transformer 改写 AST
TypeScript 允许把代码从一种形式 transform 到另一种形式,做的事情和 Babel 的 plugin 类似。项目地址:itsdouges/typescript-transformer-handbook。
什么是 AST
AST(抽象语法树)是描述已解析代码的数据结构。调 AST 时建议打开 ts-ast-viewer.com 观察。下面这段代码:
function hello() {
console.log('world');
}
对应的 AST 表示:
-> SourceFile
-> FunctionDeclaration
- Identifier
-> Block
-> ExpressionStatement
-> CallExpression
-> PropertyAccessExpression
- Identifier
- Identifier
- StringLiteral
- EndOfFileToken
每个节点都有 kind 属性(SyntaxKind 枚举值)描述节点类型,以及 pos / end 描述它在源码中的位置。
编译阶段(Stages)
TypeScript 一共五个阶段:parser、binder、checker、transform、emitting。其中 binder 和 checker 是 TS 特有的。
一个 Program 是一组入口源文件消费一个或多个模块的集合,整个集合在每个阶段都会被用到。这点和 Babel 不同:Babel 是 file in file out,TypeScript 是 project in, project out。这也是 Babel 解析 TypeScript 时 enum 不能工作的原因——它拿不到全部信息。
- Parser:实际分两部分,scanner 和 parser。
SourceCode ~~ scanner ~~> Token Stream ~~ parser ~~> AST。scanner 把字符串线性转成 token,parser 负责树化。 - Binder:创建 symbol map,基于 AST 提供类型系统,用来链接引用、知道 import/export 的节点。
- Transforms:写 transformer 的阶段,可以任意改代码。
- Emitting:最后阶段,把最终代码写到某处(通常是文件系统,也可能是内存)。
三个 transform 阶段
before—— 在 TypeScript 自己的 transformer 之前运行(代码还没编译)after—— 在 TypeScript 自己的 transformer 之后运行(代码已编译)afterDeclarations—— 在 declaration 阶段之后运行(可以改类型定义)
90% 的情况写 before。需要编译后处理或改类型时用 after / afterDeclarations。
transformer 之后不应该再做类型检查,如果发生多半是 bug。
遍历(Traversal)
TS 提供两个主要方法:
import * as ts from 'typescript';
ts.visitNode(sourceFile, visitor, test);
ts.visitEachChild(node, visitor, context);
visitor 模式每次写 transformer 都会用到。最简单的 visitor:
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);
};
必须 return 每个节点,否则会出怪错。
context 是每个 transformer 都会收到的转换上下文,除了给 visitEachChild 用,还能拿到当前 TypeScript 配置。
Transformer API
写 transformer 用 TypeScript,主要靠 typescript 包。安装 npm i typescript --save,然后 import * as ts from 'typescript'。
Visiting:
ts.visitNode(node, visitor, test)ts.visitEachChild(node, visitor, context)ts.isXyz(node)—— 收窄 node 类型,如ts.isVariableDeclaration(node)
Nodes:
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 上常用:
getCompilerOptions()hoistFunctionDeclaration(node)hoistVariableDeclaration(node)
program(写 Program transformer 时可用的特殊属性):
getRootFileNames()/getSourceFiles()/getCompilerOptions()getSourceFile(fileName)/getSourceFileByPath(path)getCurrentDirectory()getTypeChecker()
typeChecker(program.getTypeChecker() 的结果):
getSymbolAtLocation(node)getExportsOfModule(symbol)
写第一个 transformer
import * as ts from 'typescript';
const transformer: ts.TransformerFactory = context => {
return sourceFile => {
return sourceFile;
};
};
export default transformer;
transformer factory 能拿到 context。接着加 visitor 遍历每个节点,再找 Identifier 改名。源:
babel === plugins;
改写后:
typescript === transformers;
完整逻辑:ts.isIdentifier(node) 判断,node.escapedText 匹配,return ts.factory.createIdentifier('typescript') 替换。
transformer 的类型
- Factory:
ts.TransformerFactory,通过 context 拿到配置,最常见。 - Config:从 tsconfig 读自定义配置。
- Program:能拿到整个 program 的信息(所有文件、typeChecker 等)。
怎么消费 transformer
官方 tsc 命令不支持直接加载自定义插件,但有几种办法:
- 直接调用 TS 编译器 API 编译代码。
- 用社区的 TTypeScript 项目:cevek/ttypescript。
- Webpack + ts-loader,配置
getCustomTransformers:
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
getCustomTransformers(program) {
return {
before: [myTransformer],
after: []
};
}
}
}
Parcel 也有对应支持。
转换操作(Transformation operations)
遍历相关:
- 判断节点类型:
ts.isXyz(node)或node.kind === ts.SyntaxKind.xxx - 判断两个 identifier 是否指向同一个 symbol:
typeChecker.getSymbolAtLocation() - 找特定父节点:向上遍历
node.parent - 停止遍历:返回当前节点,而不是 visitEachChild
操作节点:
- 更新节点:
ts.factory.updateXyz - 替换节点:visitor 返回新节点
- 一个节点替换成多个:返回数组
- 插入兄弟节点:
ts.factory.createXyz后插到父节点 statements 里 - 删除节点:返回
undefined或过滤数组 - 添加新 import 声明:创建 ImportDeclaration 加到 sourceFile.statements 开头
作用域相关:
- 把变量声明推到作用域顶部:
context.hoistVariableDeclaration(node) - 检查局部变量是否被引用
- 定义唯一变量、重命名 binding 及其所有引用
查找:
- 获取行列号:
sourceFile.getLineAndCharacterOfPosition(node.pos)
高级:
- 表达式求值
- 跟随模块 import / node_modules import
- 转换 jsx、判断与重置文件 pragma(
/* @jsx */)
技巧
- 组合多个 transformer:按顺序放进数组
- 抛语法错误改善开发体验
测试
ts-transformer-testing-library。
已知 bug
EmitResolver无法处理不是来自 parse tree 的JsxOpeningLikeElement/JsxOpeningFragmentgetMutableClone(node)在配合ts-loader使用时爆炸
相关项目与工具
- 项目地址:itsdouges/typescript-transformer-handbook
- AST 查看器:ts-ast-viewer.com
- TTypeScript:cevek/ttypescript
- ts-patch:nonara/ts-patch
tags: TypeScript编译器, AST, Transformer, Babel