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

你可能感兴趣的:(Typescript - 接口属性抽取)