Typescript - 接口属性抽取

抽取interface的属性信息在生成文档/组件渲染等场景比较常见,本文将通过使用tsc抽取interface的属性信息来学习如何使用tsc

相关概念

在正式开始前,先了解tsc涉及到的部分概念

Program(程序)

实质:编译上下文

Program包含编译选项和一系列关联的SourceFile。每个SourceFile本质上是对应文件生成的AST根结点。Program自身内容及下游调用关系如下图所示:

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的作用。

Symbo连接2处声明示例截图

可以看到,Symboldeclarations中保存了2处接口定义,通过Symbol将这些InterfaceDeclaration连接起来。

Cheker(类型检查器)

检查器是typescriptjavascript转译器更为强大的所在,其实现也是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.getLeadingCommentRangests.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;
});

上面的代码主要做了一下几件事:

  1. 获取属性的注释文本。
    通过ts. getLeadingCommentRanges方法获取注释文本在源码文件中的相应的位置信息,然后从源码文件字符串中截取指定起始位置的字符串,即为需要的注释字符串。

  2. 处理注释文本。
    示例中的注释是通过块注释语法编写,因此先将无用的*等文本删除,余下游有用的注释文本内容。再逐行遍历注释内容,根据 jsdoc规范来查找符合@default`规则的对应文本。

  3. JSON.parse默认值json串。
    注释中的值都是字符串,如果需要获取对应的js的值,则需要进行JSON.parse,但前提是默认值在书写时也需要符合json string的格式。

本文中的代码均为讲解整个过程和方便读者调试使用,并未做相应的边界判断和异常处理,读者如需在自己的场景中使用,根据实际情况进行修改。

小结:typescript compiler api并没有相应官方的详尽文档,需要开发者通过智能提示或者查阅源码使用,极其不方便。本文通过简单抽取接口属性的案例讲解,希望能帮助对此感兴趣的读者快速上手体验,增强信心。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270

推荐阅读更多精彩内容