TypeScript 中利用 transformer 获取 interface keys | 张先森的代码小屋
明镜止水
Menu Close
TypeScript 中利用 transformer 获取 interface keys
本文分成四个部分:
- 需求和灵感
- TypeScript 的抽象语法树简介
- TypeScript transformer 简介
- 编写获取 TypeScript interface keys 的 transformer
需求和灵感
使用过 TypeScript 写代码的同学都对 interface 这个东西不陌生,借助 interface 来定义一些纯值对象的类型是再简单不过了。最开始我的需求很简单,想用 interface 来定义一个 HTTP API 的 response DTO,在对一个 API 进行测试的时候,可以验证这个 API 的 response 结构是否和我用 interface 定义的结构相同。
刚开始想到可以使用 ES 6 的 class 来定义 DTO,然后通过在运行时获取 class 的属性。这确实可以,但是用起来有点麻烦,比如下面的代码:
class X {
a: number;
b: string;
}
console.log(Object.getOwnPropertyNames(new X())); // []
这还不够,需要对每个属性赋值:
class X {
a = 0;
b = '';
}
console.log(Object.getOwnPropertyNames(new X())); // [ 'a', 'b' ]
或者在X
的constructor
里初始化一下属性(如果只是为了拿到属性名字,直接对每个属性赋值 null 即可):
class X {
a: number;
b: string;
constructor() {
this.a = null;
this.b = null;
}
}
console.log(Object.getOwnPropertyNames(new X())); // [ 'a', 'b' ]
虽然这样做也许可行,但是很快我就否定了这种用法。我只是想简单地声明一种类型,然后再需要的时候可以获取这个类型的所有属性。现在不仅要显式初始化所有属性(在constructor
中或者直接在 class 声明属性的时候赋值),还要用new
生成一个实例,实在不够优雅。其实在 TypeScript 中声明 DTO 一类的东西用 interface 会好一些,声明的代码简洁,支持直接嵌套属性,也可以声明属性的类型为其他 interface,这和真实的 HTTP Response Data 的结构几乎一模一样:
interface X {
a: number;
b: {
c: string;
d: Y;
};
}
interface Y {
u: string;
v: {
w: number;
}
}
遗憾的是,虽然 interface 很适合用来描述 HTTP Response Data,但正常情况下如果想在运行时获取 interface 的 keys 用来和真正的 HTTP Response Data 结构做对比是不行的,因为 TypeScript 的 interface 实际上并不存在于 runtime,要理解这个问题需要知道 TypeScript 针对 JavaScript 提供了一整套的类型辅助系统,但仅仅是辅助,最终的代码还是要转换成 JavaScript 来执行。由于 JavaScript 中并不存在 interface,因此也就无法在 runtime 获得 interface 的 keys 了。
不过也不是完全没有希望,经过一番搜索,我发现了ts-transformer-keys这个包,该包宣称可以获得 interface 的 keys。仔细研究了一下,发现这个包提供一个keys<T>()
方法,其实现原理是使用了自定义的 transformer 在将代码转换成 JavaScript 时获取了 interface 的信息,然后修改了调用keys<T>()
处的抽象语法树 (Abstract Syntax Tree, AST) 节点信息。换句话说,这个包提供的 transformer 在将代码转换成 JavaScript 时直接从 AST 中找到相应 interface 的 keys,然后创建一个包含所有 keys 数组,并将这个数组直接输出到转换出来的 JavaScript 代码中。
举个简单的例子:
interface Foo {
a: number;
b: string;
}
console.log(keys<Foo>());
上面这几行代码在被转换成 JavaScript 时被替换成了下面这行:
console.log(["a", "b"]);
正如上面所描述的,ts-transformer-keys
对 AST Nodes 做了遍历 - 转换,这种能力正是我所需要的。进一步说,由于 response DTO 内部经常是嵌套结构的,因此很自然想到是否可以支持嵌套 interface,比如下面这种情况:
interface Foo {
a: number;
b: Bar;
}
interface Bar {
c: boolean;
d: string;
}
console.log(keys<Foo>());
但是ts-transformer-keys
的输出还是只有 a 和 b,看来ts-transformer-keys
尚未支持这种用法。
console.log(["a", "b"]);
再进一步,我还想要得到 interface 各个 key 的类型和存在性,目前ts-transformer-keys
也不支持。不过没关系,知道了内部的实现原理,完全可以自己写一个 transformer。
TypeScript 的抽象语法树简介
在真正开始编写自己的 transformer 之前,有必要简单了解一下 TypeScript 的抽象语法树和 TypeScript 对操作抽象语法树所提供的支持。
抽象语法树 (Abstract Syntax Tree,AST),下文简称为 AST,是源代码语法结构的一种抽象表示。为了更直观地观察 TypeScript 的 AST,可以借助ts-ast-viewer这个工具来以树形结构将其可视化。先看一个基本的 TypeScript interface 的抽象语法树表示,假设有如下代码:
interface Foo {
a?: number;
b: string;
}
使用 ts-ast-viewer 可以得到上面代码的 AST 结构:
从图中可以很清楚地看到 Foo 的 AST 表示,另外在右边的 Node 部分,还能查看到其 AST 中具体节点的信息,对于 TypeScript 的 interface 我们关心的属性名称、存在性和类型都可找到相应的字段来对应。
图形化表示如下:
源代码的几乎每一个细节,在 AST 中都有体现。让我们从上到下走马观花一下:
- 最顶层是
SourceFile
,每一个 TypeScript 源代码文件都会对应一个 SourceFile。 SourceFile
下直接包含的SyntaxList
包括了这个文件中的所有语法结构,在这里只有这个 interface 声明,如果还有其他语法结构,也将被包含在内。InterfaceDeclaration
表示这个 interface 的声明。InterfaceKeyword
表示关键字 interface。- 紧接着的
Identifier
对应的是 interface 的名字Foo
。 OpenBraceToken
表示{
。- 接下来又是一个
SyntaxList
,这个 SyntaxList 和刚才看到的那个不一样,它只包括了 interface Foo 中声明的所有语法结构,这样的结构划分有点类似作用域。 - 之后的
PropertySignature
是一个属性签名,表示a?: number;
。 - PropertySignature 下的一些属性,
Identifier
表示属性名a
,QuestionToken
表示?
,ColonToken
表示:
,NumberKeyword
表示属性名 a 的类型是number
,SemicolonToken
则表示;
。
后面的结构和前面差不多就不赘述了。
值得一提的是,在 TypeScript 的类型声明文件typeacript.t.ts
的SyntaxKind
这个enum
声明中,可以找到上面列举的 AST 语法结构类型的声明,编写 transformer 的时候我们还会用到它。另外,之前提到ts-transformer-keys
是使用 transformer 来遍历 AST Nodes 以获取 interface keys,并就地创建一个 Array,将 keys 数组(是一个字符串数组)复制给原来 TypeScript 代码中keys<T>()
对应的左值。因此我们还需要能遍历,修改和创建 AST Nodes,实际上 TypeScript 对这些操作已经提供了支持,具体细节之后会谈到。
上面 AST 内部的细节部分将在实际编写 transformer 的时候再来研究,现在只需要大致知道它的结构就可以了。
TypeScript transformer 简介
在介绍 transformer 之前需要大致了解一下 TypeScript 的编译过程。
在TypeScript的 Wiki 中可以找到一篇和 TypeScript 内部架构和编译过程有关的文章,大部分网络上涉及 TypeScript 编译过程的文章大都参考它:TypeScript Architectural Overview。
根据文章中的介绍,TypeScript 的核心编译过程中涉及的编译组件主要有下面几个:
- Pre-processor: 预处理器(包含 Scanner)。
- Parser: 语法分析器。
- Binder: 绑定器。
- Type resolver/ Checker: 类型检查器,解析每种类型的构造,负责处理、检查针对每个类型的语义操作,并生成合适的诊断信息。
- Emitter:生成器,负责根据输入的. ts 和. d.ts 文件生成最终的结果,它有三种可能的输出:JavaScript 源码 (.js)、类型定义文件(.d.ts) 或 source map 文件 (.js.map),其中类型定义文件可以帮助开发者在各种 IDE 中获取 TypeScript 的类型信息,source map 文件则是一个存储源代码与编译代码对应位置映射的信息文件,在 debug 时我们需要利用 source map 文件来找到实际运行的代码(最终生成的. js 文件) 和其原始代码 (开发者实际编写的. ts 文件) 的位置对应关系。
TypeScript 的编译过程简单归纳如下:
- 在编译过程的开始阶段,输入是一些. ts 源代码,Pre-processor 会计算出有哪些源代码文件将参与编译过程(它会查找 import 语句和用
///
的引用语句),并在内部调用扫描器 (Scanner) 对所有源文件进行扫描,并封装成 Tokens 流,作为之后 Parser 的输入。 - Parser 以预处理器产生的 Tokens 流作为输入,根据语言语法规则生成抽象语法树 (AST),每个源文件的 AST 都有一个 SourceFile 节点。
- Binder 会遍历 AST,并使用符号 (Symbol) 来链接相同结构的声明(例如对于具有相同结构的 interface 或模块,或者同名的函数或模块)。这个机制能帮助类型系统推导出这些具名声明。Binder 也会处理作用域,确保每个 Symbol 都在正确的作用域中被创建。到目前为止,编译过程已经对每个单独的. ts 文件进行了处理,得到了每个. ts 文件的 AST(每个 AST 都有一个 SourceFile 节点作为根节点)。接下来还需要将所有. ts 文件的 SourceFile 合并在一起形成一个程序(Program),TypeScript 提供了一个
ts.createProgram
API 来创建 Program。我们知道源代码文件经常互相引用,下一步还将处理这些引用关系。 - 生成 Program 后,TypeChecker 会负责计算出不同 SourceFile 中的 Symbol 引用关系,并将
Type
赋值给Symbol
,并在此时生成语义诊断(如果有错误的话)。 - 对于一个 Program,会生成一个 Emitter,Emitter 要做的就是针对每个 SourceFile 生成输出 (.js/.d.ts/.js.map)。
另外,在 TypeScript 的 Wiki 还能找到一篇比较 “残缺” 的文章(估计是项目开发人员忙于具体实现懒得更新 Wiki 了),提到了 transformer:TypeScript Compiler-Internals
摘录 transformer 部分的内容如下,其中translated
和transforms
颇为微妙:
The transformer is nearing completion to replace the emitter. The change in name is because the emitter translated TypeScript to JavaScript. The transformer transforms TypeScript or JavaScript (various versions) to JavaScript (various versions) using various module systems. The input and output are basically both trees from the same AST type, just using different features. There is still a small printer that writes any AST back to text.
这里对 emitter 的功能描述是translated TypeScript to JavaScript
,emitter 的作用是将 TypeScript 代码翻译
成 JavaScript 代码。而翻译的意思是保持原文意思不变,也就是说 emitter 对 TypeScript 代码没有添油加醋,是照原样转成 JavaScript 的。而对 transformer 的功能描述是transforms TypeScript or JavaScript (various versions) to JavaScript (various versions) using various module systems
,这里的 transforms 还有转换、变换的功能。
一言以蔽之,transformer 对开发者暴露了 AST,使我们能按照我们的意愿遍历和修改 AST(这种修改包括删除、创建和直接修改 AST Nodes)。
有了这些信息做铺垫后,可以用一张流程图来表示 TypeScript 的编译过程:
编写获取 TypeScript interface keys 的 transformer
终于到了实际写代码的环节了。在真正实现获取 interface keys 的 transformer 之前我们还有几个准备工作要做:
- 实现一个最简单的 transformer,之后的工作将在此基础上展开。
- 研究如何将 transformer 集成到 TypeScript 项目中。
首先我们需要一种能在项目中使用 transformer 的方式,这里我选择ttypescript,因为它使用起来非常简单,另外还有一种方式是使用ts-loader结合 webpack,篇幅关系这里就只介绍使用ttypescript
的方式。
以ttypescript
提供的例子为基础,我们可以先写一个基础的 transformer(部分代码来自于ts-transformer-keys):
// src/transformer.ts
import * as ts from 'typescript';
export default (program: ts.Program): ts.TransformerFactory<ts.SourceFile> => {
return (ctx: ts.TransformationContext) => {
return (sourceFile: ts.SourceFile): ts.SourceFile => {
const visitor = (node: ts.Node): ts.Node => {
return ts.visitEachChild(visitNode(node, program), visitor, ctx);
};
return <ts.SourceFile> ts.visitEachChild(visitNode(sourceFile, program), visitor, ctx);
};
};
}
const visitNode = (node: ts.Node, program: ts.Program): ts.Node => {
const typeChecker = program.getTypeChecker();
if (!isKeysCallExpression(node, typeChecker)) {
return node;
}
return ts.createStringLiteral('will be replaced by interface keys later');
};
const indexTs = path.join(__dirname, './index.ts');
const isKeysCallExpression = (node: ts.Node, typeChecker: ts.TypeChecker): node is ts.CallExpression => {
if (!ts.isCallExpression(node)) {
return false;
}
const signature = typeChecker.getResolvedSignature(node);
if (typeof signature === 'undefined') {
return false;
}
const { declaration } = signature;
return !!declaration
&& !ts.isJSDocSignature(declaration)
&& (path.join(declaration.getSourceFile().fileName) === indexTs)
&& !!declaration.name
&& declaration.name.getText() === 'keys';
};
几个地方解释一下:
- 在导出方法中,
ts.visitEachChild
可以使用开发者提供的 visitor 来访问 AST Node 的每个子节点,并且在 visitor 中允许返回一个相同类型的新节点来替换当前被访问的节点。 visitNode
接受一个ts.Node
和ts.Program
类型的参数会在访问指定节点的每个子节点时被调用,这个方法需要放回一个ts.Node
类型的对象,如果不想对当前节点做任何改变的话,直接返回实参中的node
即可,如果想要做一些转换,那就需要自己编码实现了,这也是这个 transformer 实际发挥作用的地方。目前这里的做法是遇到keys<T>()
调用就将节点替换为一个字符串’will be replaced by interface keys later’。- 这里会沿用
ts-transformer-keys
的调用方式keys<T>()
,我们需要判断调用点,isKeysCallExpression
就是用来判断源码中调用keys<T>()
的地方。
写个测试来验证一下:
// test/transformer.test.ts
import { keys } from '../index';
describe('Test transformer.', () => {
test('Should output \"will be replaced by interface keys later\".', () => {
interface Foo {}
expect(keys<Foo>()).toEqual('will be replaced by interface keys later'); // true
});
});
测试通过说明我们的 transformer 生效了。
接下来要进入本文最重要的部分(请原谅我前面铺垫了这么多 =。=):编写获取 interface keys 的代码了。在第一部分已经列出了一个包含 interface 的 SourceFile 的 AST 结构,不过里面的 interface 的结构是平坦的,没有嵌套的层级关系。而我们的目的是能够支持具有层级关系和嵌套的 interface,一个有层级关系的 interface 的 AST 结构如下:
我们需要嵌套地对 interface 的 property 做处理,完整的代码如下:
import * as ts from 'typescript';
import * as path from 'path';
export default (program: ts.Program): ts.TransformerFactory<ts.SourceFile> => {
return (ctx: ts.TransformationContext) => {
return (sourceFile: ts.SourceFile): ts.SourceFile => {
const visitor = (node: ts.Node): ts.Node => {
return ts.visitEachChild(visitNode(node, program), visitor, ctx);
};
return <ts.SourceFile> ts.visitEachChild(visitNode(sourceFile, program), visitor, ctx);
};
};
}
interface InterfaceProperty {
name: string;
optional: boolean;
}
const symbolMap = new Map<string, ts.Symbol>();
const visitNode = (node: ts.Node, program: ts.Program): ts.Node => {
if (node.kind === ts.SyntaxKind.SourceFile) {
(<any>node).locals.forEach((value: any, key: string) => {
if (!symbolMap.get(key)) {
symbolMap.set(key, value);
}
});
}
const typeChecker = program.getTypeChecker();
if (!isKeysCallExpression(node, typeChecker)) {
return node;
}
if (!node.typeArguments) {
return ts.createArrayLiteral([]);
}
const type = typeChecker.getTypeFromTypeNode(node.typeArguments[0]);
let properties: InterfaceProperty[] = [];
const symbols = typeChecker.getPropertiesOfType(type);
symbols.forEach(symbol => {
properties = [ ...properties, ...getPropertiesOfSymbol(symbol, [], symbolMap) ];
});
return ts.createArrayLiteral(properties.map(property => ts.createRegularExpressionLiteral(JSON.stringify(property))));
};
const getPropertiesOfSymbol = (symbol: ts.Symbol, outerLayerProperties: InterfaceProperty[], symbolMap: Map<string, ts.Symbol>): InterfaceProperty[] => {
let properties: InterfaceProperty[] = [];
let propertyPathElements = JSON.parse(JSON.stringify(outerLayerProperties.map(property => property)));
const property = symbol.escapedName;
propertyPathElements.push(property);
let optional = true;
for (let declaration of symbol.declarations) {
if (undefined === (<any>declaration).questionToken) {
optional = false;
break;
}
}
const key = <InterfaceProperty> {
name: propertyPathElements.join('.'),
optional,
};
properties.push(key);
const propertiesOfSymbol = _getPropertiesOfSymbol(symbol, propertyPathElements, symbolMap);
properties = [
...properties,
...propertiesOfSymbol,
];
return properties;
};
const isOutermostLayerSymbol = (symbol: any): boolean => {
return symbol.valueDeclaration && symbol.valueDeclaration.symbol.valueDeclaration.type.members;
};
const isInnerLayerSymbol = (symbol: any): boolean => {
return symbol.valueDeclaration && symbol.valueDeclaration.symbol.valueDeclaration.type.typeName;
};
const _getPropertiesOfSymbol = (symbol: ts.Symbol, propertyPathElements: InterfaceProperty[], symbolMap: Map<string, ts.Symbol>): InterfaceProperty[] => {
if (!isOutermostLayerSymbol(symbol) && !isInnerLayerSymbol(symbol)) {
return [];
}
let properties: InterfaceProperty[] = [];
let members: any;
if ((<any>symbol.valueDeclaration).type.symbol) {
members = (<any>symbol.valueDeclaration).type.members.map((member: any) => member.symbol);
} else {
const propertyTypeName = (<any>symbol.valueDeclaration).type.typeName.escapedText;
const propertyTypeSymbol = symbolMap.get(propertyTypeName);
if (propertyTypeSymbol) {
if (propertyTypeSymbol.members) {
members = propertyTypeSymbol.members;
} else {
members = (<any>propertyTypeSymbol).exportSymbol.members;
}
}
}
if (members) {
members.forEach((member: any) => {
properties = [
...properties,
...getPropertiesOfSymbol(member, propertyPathElements, symbolMap),
];
});
}
return properties;
};
const indexTs = path.join(__dirname, './index.ts');
const isKeysCallExpression = (node: ts.Node, typeChecker: ts.TypeChecker): node is ts.CallExpression => {
if (!ts.isCallExpression(node)) {
return false;
}
const signature = typeChecker.getResolvedSignature(node);
if (typeof signature === 'undefined') {
return false;
}
const { declaration } = signature;
return !!declaration
&& !ts.isJSDocSignature(declaration)
&& (path.join(declaration.getSourceFile().fileName) === indexTs)
&& !!declaration.name
&& declaration.name.getText() === 'keys';
};
完整的 repo 可以移步ts-interface-keys-transformer。
使用该 transformer 非常简单,首先安装ttypescript
:
npm i ttypescript
然后在 tsconfig.json 的compilerOptions
下增加如下信息:
"plugins": [
{ "transform": "ts-interface-keys-transformer/transformer" }
]
例子如下:
import { keys } from 'ts-interface-keys-transformer';
interface Foo {
a: number;
b?: string;
c: {
d: number;
e?: boolean;
}
f: Bar;
}
interface Bar {
x: string;
y: number;
}
console.log(keys<Foo>());
// output:
// [ { name: 'a', optional: false },
// { name: 'b', optional: true },
// { name: 'c', optional: false },
// { name: 'c.d', optional: false },
// { name: 'c.e', optional: true },
// { name: 'f', optional: false },
// { name: 'f.x', optional: false },
// { name: 'f.y', optional: false } ]
在 build TypeScript 项目时,一般用的是tsc
命令,现在由于使用了 ttypescript,需要改用ttsc
,这里有一个ts-interface-keys-transformer-demo展示了用法。
参考资料
0 条评论
未登录用户
支持 Markdown 语法预览使用 GitHub 登录
来做第一个留言的人吧!
[上一篇
CSAPP 读书笔记 (书已看完,剩下的读书笔记都在心里(逃。。)
](/2019/09/29/CSAPP 读书笔记 (长期更新)/)[下一篇
node.js 中利用 IPC 和共享内存机制实现计算密集型任务转移
](/2019/03/23/node.js 中利用 IPC 和共享内存机制实现计算密集型任务转移 /)
文章目录
- 1. 需求和灵感
- 2. TypeScript 的抽象语法树简介
- 3. TypeScript transformer 简介
- 4. 编写获取 TypeScript interface keys 的 transformer
- 5. 参考资料
© 2020 张先森的代码小屋 All Rights Reserved.
Theme by hipaper