美文网首页
Typescript - 接口属性抽取

Typescript - 接口属性抽取

作者: 前端C罗 | 来源:发表于2022-09-02 20:21 被阅读0次

    抽取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 - 接口属性抽取

          本文链接:https://www.haomeiwen.com/subject/sbwmrrtx.html