抽取
interface
的属性信息在生成文档
/组件渲染
等场景比较常见,本文将通过使用tsc
抽取interface
的属性信息来学习如何使用tsc
。
相关概念
在正式开始前,先了解tsc
涉及到的部分概念
Program(程序)
实质:编译上下文
Program
包含编译选项
和一系列关联的SourceFile
。每个SourceFile
本质上是对应文件生成的AST
根结点。Program
自身内容及下游调用关系如下图所示:
在代码中,可以使用
ts.createProgram
创建Program
实例,根据函数的要求传入根入口文件列表和编译选项即可。
import ts from 'typescript';
const program = ts.createProgram({
// 加入到program中的根文件列表,可以理解为入口文件列表
rootNames: [],
// ts编译选项
options: {},
});
对编译选项感兴趣的朋友,可以点此详细了解,本文不做赘述。
Binder(绑定器)
在typescript
中,为协助类型检查,绑定器将源码的各部分连接成一个相关的类型系统,供下文提到的检查器使用。其主要职责是创建符号(Symbols)。
- 符号(Symbol)
这里的Symbol
不是指javascript
语言中的Symbol
,符号将AST
中声明的节点与其他声明连接到相同的实体上。这句话会有点抽象,下面看一个简单的示例。
代码如下:
interface Person {
name: string;
}
interface Person {
age: number;
}
const p: Person = {
name: 'cluo',
age: 26
};
上面的代码中,名为Person
的接口被声明了2
次,我们知道同名接口会进行合并。在ts-ast-viewer中分析这段代码,来一起看看Symbol
的作用。
可以看到,Symbol
的declarations
中保存了2
处接口定义,通过Symbol将这些InterfaceDeclaration
连接起来。
Cheker(类型检查器)
检查器是
typescript
比javascript
转译器更为强大的所在,其实现也是ts中最为复杂的部分。
真正的类型检查在启动发射器之后,检查器合并全局命名空间,并对SourceFile进行类型检查及报错错误。
属性解析
对现有的指定接口进行属性解析,接口代码如下:
interface Person {
/**
* 姓名
* @default "cluo"
*/
name: string;
/**
* 性别
* @default "Male"
*/
gender: 'Male' | 'Female';
/**
* 年龄
* @default 18
*/
age: number;
}
上面的Person
接口包含3个属性,我们下面演示如何通过tsc
来解析出相应的属性信息,包括从注释中获取属性的默认值。
1.创建Program
实例
所有的解析都是从Program
开始,成功创建Program的实例就是成功了一半。代码如下:
import ts from 'typescript';
const demo1Path = './demos/demo1.ts';
const program = ts.createProgram({ rootNames: [demo1Path], options: {} });
2.获取interface
的声明
ts.Node
可以使用forEachChild
遍历子节点,从SourceFile
根结点递归调用该方法就可以实现遍历AST
中所有节点。利用此方法,遍历根节点中的子节点,查找给定名称的接口定义。代码如下:
const findInterfaceByName = (sf: SourceFile, iName: string): InterfaceDeclaration | null => {
let interfaceDec: InterfaceDeclaration | null = null;
sf.forEachChild(node => {
if (ts.isInterfaceDeclaration(node)) {
const curNodeName = (node as InterfaceDeclaration).name.escapedText;
if (curNodeName === iName) {
interfaceDec = node;
}
}
});
return interfaceDec;
};
const sf = program.getSourceFile(demo1Path);
const personDec = findInterfaceByName(sf!, 'Person');
上面的代码稍微需要注意的地方是ts.Node.name
的类型,从字面上很容易认为是字符串类型,但它是Identifier
类型。在typescript.d.ts
中,Identifier
的接口定义如下:
export interface Identifier extends PrimaryExpression, Declaration {
readonly kind: SyntaxKind.Identifier;
/**
* Prefer to use `id.unescapedText`. (Note: This is available only in services, not internally to the TypeScript compiler.)
* Text of identifier, but if the identifier begins with two underscores, this will begin with three.
*/
readonly escapedText: __String;
readonly originalKeywordKind?: SyntaxKind;
isInJSDocNamespace?: boolean;
}
通过escapedText
获取标识符对应的名称字符串。
3.抽取接口的属性信息
- 获取属性名称列表。
const props = personDec?.members.map(m => m.name?.getText());
最简单的场景就是获取接口中所有的属性名,并组成属性名列表。遍历接口定义的members
属性,将每个member
对应的属性名提取出来。
- 获取属性的类型。
只有属性名在实际的应用场景中作用不大,在组件渲染和文档生成的时候,需要知道属性对应的类型。
const props = personDec?.members.map(m => {
const propName = ((m as PropertySignature).name as Identifier).escapedText;
const propType = (m as PropertySignature).type;
return { name: propName, type: propType?.getText() };
});
/*
[
{ name: 'name', type: 'string' },
{ name: 'gender', type: "'Male' | 'Female'" },
{ name: 'age', type: 'number' }
]
*/
通过对属性节点的类型进行处理,就可以生成{属性名: 属性类型信息}
的列表信息。
- 获取属性的默认值。
接口的定义代码中,注释中标注了属性的default
值,在解析属性时,自然而然也希望能将其也解析到目标数据中。
在具体处理之前,我们先了解一下编译器如何处理注释,这里涉及到AST杂项
的相关知识。
杂项(Trivia)是指源文本中对编译器理解代码不那么重要的部分,比如:
空白
/注释
等。因此这些信息不会直接存储到AST
中,但注释对于开发人员理解代码是有帮助的,因此编译器同样提供获取这些信息的的API
。
因为杂项并不存储于AST
,那怎么确定哪些注释是用来说明指定节点的呢?编译器给杂项的所有权确定了相关的原则。
-
token
拥有它后面同一行
到下一个token
之前的所有杂项 -
该行(换行开始)之后
的注释都与下个token
有关
基于上述的原则,相应的获取源文件中注释文本的方法就呼之欲出。可以用下面的示例代码来进行理解
const name = 'cluo'; //这里是姓名。 你知道了吗?
//new line
/*
new block
*/
function sayHi() {
console.log(`Hello, ${name});
}
假设需要获取function
的注释,那也就是获取起点(const语句的换行后坐标)
-- 终点(function关键词的起始坐标)
范围内的文本。
前面提到,编译器提供了获取注释文本的方法,分别是ts.getLeadingCommentRanges
和ts.getTrailingCommentRanges
,前一个是获取token
所拥有的前面的注释,后一个是获取token所拥有的后面的注释。
经过上面的讲解,一起看看如何从属性的注释中提取默认值。
const CharCodes = {
ASTERISK: "*".charCodeAt(0),
NEWLINE: "\n".charCodeAt(0),
CARRIAGE_RETURN: "\r".charCodeAt(0),
SPACE: " ".charCodeAt(0),
TAB: "\t".charCodeAt(0),
CLOSE_BRACE: "}".charCodeAt(0),
};
function getTextWithoutStars(inputText: string) {
const innerTextWithStars = inputText.replace(/^\/\*\*[^\S\n]*\n?/, "").replace(/(\r?\n)?[^\S\n]*\*\/$/, "");
return innerTextWithStars.split(/\n/).map(line => {
const starPos = getStarPosIfFirstNonWhitespaceChar(line);
if (starPos === -1)
return line;
const substringStart = line[starPos + 1] === " " ? starPos + 2 : starPos + 1;
return line.substring(substringStart);
}).join("\n");
function getStarPosIfFirstNonWhitespaceChar(text: string) {
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
if (charCode === CharCodes.ASTERISK)
return i;
else if (!StringUtils.isWhitespaceCharCode(charCode))
break;
}
return -1;
}
}
function getDefault(str: string): string | undefined {
let defaultVal: string | undefined = undefined;
str.split('\n').forEach(line => {
if (line.startsWith('@default')) {
defaultVal = line.split(/\s/)[1];
}
});
return defaultVal;
}
const props = personDec?.members.map(m => {
const propName = ((m as PropertySignature).name as Identifier).escapedText;
const propType = (m as PropertySignature).type;
const propMeta: { name: string, type: string, defaultValue?: string } = { name: propName as string, type: propType?.getText() || '' }
const commentRanges = ts.getLeadingCommentRanges(sf?.getFullText()!, m.getFullStart());
commentRanges?.forEach(mr => {
const commentText = sf?.getFullText().substring(mr.pos, mr.end);
if ((commentText ?? '').length > 0) {
const escapeStars = getTextWithoutStars(commentText!);
const defaultVal = getDefault(escapeStars);
propMeta.defaultValue = JSON.parse(defaultVal ?? '""');
}
});
return propMeta;
});
上面的代码主要做了一下几件事:
-
获取属性的注释文本。
通过ts. getLeadingCommentRanges
方法获取注释文本在源码文件中的相应的位置信息,然后从源码文件字符串中截取指定起始位置的字符串,即为需要的注释字符串。 -
处理注释文本。
示例中的注释是通过块注释语法编写,因此先将无用的*
等文本删除,余下游有用的注释文本内容。再逐行遍历注释内容,根据 jsdoc规范来查找符合
@default`规则的对应文本。 -
JSON.parse
默认值json串。
注释中的值都是字符串,如果需要获取对应的js的值,则需要进行JSON.parse
,但前提是默认值在书写时也需要符合json string
的格式。
本文中的代码均为讲解整个过程和方便读者调试使用,并未做相应的边界判断和异常处理,读者如需在自己的场景中使用,根据实际情况进行修改。
小结:
typescript compiler api
并没有相应官方的详尽文档,需要开发者通过智能提示或者查阅源码使用,极其不方便。本文通过简单抽取接口属性的案例讲解,希望能帮助对此感兴趣的读者快速上手体验,增强信心。
网友评论