头图

作者:徐海峰

背景

2021年的5月20日, Docgeni 正式对外宣布开源:  Docgeni,开箱即用的 Angular 组件文档工具 ,4个月后, Docgeni 1.1.0​​ 版本的发布 ,时隔一年多,中间发布了无数个 next 版本,今天终于迎来 2.0 的正式发布 ,其中的心酸只有做过开源项目的人最有体会,因为业务的压力,只能偶尔抽空写写代码。
如果你还没有关注 Docgeni,可以访问  https://github.com/docgeni/do...  Star ,你的 Star 是对开源项目最大的鼓励。
Docgeni 自发布之日起为了快速让我们的组件库使用,所以很多特性对于当时来说采用了最快速最简单的设计,那么自 v1.1.0发布后很多高级功能都已经补全了(比如:搜索、自定义首页、目录、自定义组件等等),之后从2021年Q4开始也逐渐开启了自动化之路,那么以下分别介绍一下那些自动化的新特性。

示例模块和组件自动生成

Docgeni 过去的版本中:  示例模块和示例组件必须手动编写且按照约定的规则命名 ​。
假设组件库有一个按钮组件,它有两个示例,分别为: basic ​和 advance ​,Angular 所有的组件需要定义在一个特性模块上,所以需要在组件示例根目录新建一个 module.ts ​:

需要遵循如下规则:
文件名需要按照约定的规则定义,其次对于示例组件 basic ​ 和  advance ​来说,组件命名规则为: 类库缩写+组件名+示例名+ExampleComponent ​​(假如类库为 alib,组件为 button,最终这两个示例组件命名必须为: AlibButtonBasicExampleComponent ​和  AlibButtonAdvanceExampleComponent​ ​ )。
module.ts 中的模块命名规则为: 类库缩写+组件名+ExamplesModule ​,最终的模块命名为: AlibButtonExamplesModule ​

对应的  module.ts ​ 和示例组件的代码如下:

为什么需要按照约定的规则命名呢?因为示例组件渲染是动态的,需要知道导出的模块名和组件名,过去这样做的问题有:
需要时刻记住命名规则,一旦报错需要看文档(但是考虑到大多数场景都是复制其他示例过来改一下名字也还好)
对于  AlibButtonExamplesModule ​ 来说需要在 declarations 中显示声明所有的示例组件,比较繁琐,每加一个示例就要操作一次

在 2.0 版本中 Docgeni 会读取当前组件模块下的所有示例组件,然后动态创建示例模块,对于示例模块来说需要自定义 imports 和 providers,需要新增一个 module.ts, export default {} :

同时为了兼容之前的版本,最终出现三种情况:
当 module.ts 有 NgModule 定义时不做任何转换,以用户定义的模块为主
当 module.ts 无内容时生成一个  AlibButtonExamplesModule  并把所有示例组件加入到声明中
当 module.ts 有  export default { imports: [] ...} ​ 时动态在底部生成一个  AlibButtonExamplesModule  并把 default 相关的声明加上,同时把所有示例组件也加上

简单一张图表示如下:

合并生成 NgModule 内部流程为:
通过 TypeScript 的 Compiler-API 实现 AST 语法树的解析,主要是获取到示例组件和示例模块的文件内容,手动创建一个  ts.SourceFile ,sourceFile 就是 TypeScript 的 AST 语法树
通过 ts.forEachChild ​获取到导出的 ClassDeclaration,然后判断是否有 Component ​装饰器装饰,获取示例组件元数据,具体 API 细节就不介绍了,感兴趣可以阅读  ng-source-file.ts#L49  代码
获取到示例组件元数据和模块进行组合,生成一个最终可以运行的  AlibButtonExamplesModule 

import ts from 'typescript';

const sourceText = `
@Component({
    selector: 'alib-button-basic-example'
})
export class AlibButtonBasicComponent {}
`;

const sourceFile = ts.createSourceFile('component.ts', sourceText, ts.ScriptTarget.Latest, true);
ts.forEachChild(sourceFile, (node) => {
    // 为了简化,这里没有判断是否导出,export class xxxx
    if (ts.isClassDeclaration(node) && node.decorators) {
        const ngDecorator = node.decorators.find((decorator) => {
            return ts.isCallExpression(decorator.expression) && decorator.expression.expression.getText() === 'Component';
        });
        if (ngDecorator) {
            console.log(`Component name is: ${node.name?.getText()}`);
            // Component name is: AlibButtonBasicComponent
        }
    }
});

支持这个特性后对于有很多示例的组件来说,加示例就变得简单,只需要定义需要 imports 的模块即可。

自动生成组件 API

在之前,组件 API 文档是通过在  some-component/api 文件夹下定义一个以多语言为命名的 json 或者 js 文件按照约定的格式编写 API JSON 实现的,以下是 js 格式的文件示例:

// .../button/api/zh-cn.js
module.exports = [
  {
    type: 'directive',
    name: 'alibButton',
    description: '按钮组件,支持 alibButton 指令和 alib-button 组件两种形式',
    properties: [
        {
            name: 'alibType',
            type: 'string',
            default: 'primary',
            description: '按钮的类型,支持 \`primary | info | warning | danger\`' 
        },
        {
            name: 'alibSize',
            type: 'string',
            default: 'null', 
            description: '按钮的大小,支持 \`sm | md | lg\`'
        }
    ]
  }
];

这种方式的缺点就是 API 定义和组件代码分离,加一个参数的时候需要单独修改 API 定义文件,经常容易忘记,其次就是不容易记住 API 格式。
那么最好的方式肯定是通过代码注释自动生成 API,所以在 2.0 版本中,Lib 新增了一个 apiMode 参数配置,类型: 'compatible' | 'manual' | 'automatic' ,默认: manual 
 manual : 手动模式,以配置的形式定义组件 API,和之前的版本行为一样
 automatic : 自动模式,通过组件的注释自动生成 API
 compatible : 兼容模式,如果存在 API 定义文件 以配置优先,否则通过注释自动生成

如果你是刚开始使用 Docgeni,选择 automatic 模式即可,如果之前你已经通过 API 定义的方式写了一些组件的 API,那么选择 compatible 逐渐在新组件中使用注释自动生成。
比如编写如下的 Button 组件:

import {
    Component,
    OnInit,
    HostBinding,
    Input,
    ElementRef,
    Output,
    EventEmitter,
    Injectable,
    ContentChild,
    TemplateRef
} from '@angular/core';

/**
 * General Button Component description.
 * @name alib-button
 */
@Component({
    selector: 'alib-button,[alibButton]',
    template: '<ng-content></ng-content>'
})
export class AlibButtonComponent implements OnInit {
    @HostBinding(`class.dg-btn`) isBtn = true;

    private type: string;
    private loading = false;

    /**
     * Button Type: `'primary' | 'secondary' | 'danger'`
     * @description 按钮类型,类型为 `'primary' | 'secondary' | 'danger'`
     * @default primary
     */
    @Input() set alibButton(value: string) {
        this.alibType = value;
    }

    /**
     * 和 alibButton 含义相同,一般使用 alibButton,为了减少参数输入, 设置按钮组件通过 alib-button 时,只能使用该参数控制类型
     * @default primary
     */
    @Input() set alibType(value: string) {
        if (this.type) {
            this.elementRef.nativeElement.classList.remove(`dg-btn-${this.type}`);
        }
        this.type = value;
        this.elementRef.nativeElement.classList.add(`dg-btn-${this.type}`);
    }

    /**
     * Button Size
     * @default md
     */
    @Input() alibSize: 'xs' | 'sm' | 'md' | 'lg' = 'xs';

    /**
     * Input  of alib button component
     * @type string
     */
    @Input('alibAliasName') alibLengthTooLongLengthTooLong: 'TypeLengthTooLongLengthTooLongLengthTooLong';

    /**
     * Button loading status
     * @default false
     */
    @Input() set thyLoading(loading: boolean) {
        this.loading = loading;
    }

    /**
     * Loading Event
     */
    @Output() thyLoadingEvent = new EventEmitter<boolean>();

    @ContentChild('template') templateRef: TemplateRef<unknown>;

    constructor(private elementRef: ElementRef<HTMLElement>) {}

    ngOnInit(): void {}
}

最终生成的 API 文档为:

独立  @docgeni/ngdoc 库

React 下有一个  react-docgen-typescript  类库就是根据源文件生成 React 组件 API 文档的,dumi 是基于这个类库之上做的 API 自动生成功能, Angular 框架没有找到直接可用的类库,三年前有人做了一个简易版本的  angular-docgen  但是功能不是很全面,所以最终在 docgeni 中新增了  @docgeni/ngdoc ​类库,核心是根据组件源文件生成 API 定义 JSON 数据,这个类库是可以脱离于 docgeni 独立使用的,docgeni 自动生成组件 API 就是基于此类库。

const { NgDocParser } = require('@docgeni/ngdoc');
const docs = NgDocParser.parse(__dirname + '/button.component.ts');

button.component.ts 代码如下:


/**
 * General Button Component description.
 * @name alib-button
 */
@Component({
    selector: 'alib-button,[alibButton]',
    template: '<ng-content></ng-content>'
})
export class AlibButtonComponent implements OnInit {
    @HostBinding(`class.dg-btn`) isBtn = true;

    private type: string;
    private loading = false;

    /**
     * Button Type: `'primary' | 'secondary' | 'danger'`
     * @description 按钮类型
     * @default primary
     * @type 'primary' | 'secondary' | 'danger'
     */
    @Input() set alibButton(value: string) {
        this.alibType = value;
    }

    /**
     * 和 alibButton 含义相同,一般使用 alibButton,为了减少参数输入, 设置按钮组件通过 alib-button 时,只能使用该参数控制类型
     * @default primary
     */
    @Input() set alibType(value: string) {
        if (this.type) {
            this.elementRef.nativeElement.classList.remove(`dg-btn-${this.type}`);
        }
        this.type = value;
        this.elementRef.nativeElement.classList.add(`dg-btn-${this.type}`);
    }

    /**
     * Button Size
     * @default md
     */
    @Input() alibSize: 'xs' | 'sm' | 'md' | 'lg' = 'xs';

    /**
     * Input  of alib button component
     * @type string
     */
    @Input('alibAliasName') alibLengthTooLongLengthTooLong: 'TypeLengthTooLongLengthTooLongLengthTooLong';

    /**
     * Button loading status
     * @default false
     */
    @Input() set thyLoading(loading: boolean) {
        this.loading = loading;
    }

    /**
     * Loading Event
     */
    @Output() thyLoadingEvent = new EventEmitter<boolean>();

    @ContentChild('template') templateRef: TemplateRef<unknown>;

    constructor(private elementRef: ElementRef<HTMLElement>) {}

    ngOnInit(): void {}
}js

生成的 JSON 为:

[
  {
    "type": "component",
    "name": "alib-button",
    "className": "AlibButtonComponent",
    "description": "General Button Component description.",
    "order": 9007199254740991,
    "selector": "alib-button,[alibButton]",
    "templateUrl": null,
    "template": "<ng-content></ng-content>",
    "styleUrls": null,
    "styles": null,
    "exportAs": null,
    "properties": [
      {
        "kind": "Input",
        "name": "alibButton",
        "aliasName": "",
        "type": {
          "name": " 'primary' | 'secondary' | 'danger'",
          "options": null
        },
        "description": "按钮类型",
        "default": "primary",
        "tags": {
          "description": {
            "name": "description",
            "text": [
              {
                "text": "按钮类型",
                "kind": "text"
              }
            ]
          },
          "default": {
            "name": "default",
            "text": [
              {
                "text": "primary",
                "kind": "text"
              }
            ]
          },
          "type": {
            "name": "type",
            "text": [
              {
                "text": "",
                "kind": "text"
              },
              {
                "text": " ",
                "kind": "space"
              },
              {
                "text": "'primary' | 'secondary' | 'danger'",
                "kind": "text"
              }
            ]
          }
        }
      },
      {
        "kind": "Input",
        "name": "alibType",
        "aliasName": "",
        "type": {
          "name": "string",
          "options": null
        },
        "description": "和 alibButton 含义相同,一般使用 alibButton,为了减少参数输入, 设置按钮组件通过 alib-button 时,只能使用该参数控制类型",
        "default": "primary",
        "tags": {
          "default": {
            "name": "default",
            "text": [
              {
                "text": "primary",
                "kind": "text"
              }
            ]
          }
        }
      },
      {
        "kind": "Input",
        "name": "alibSize",
        "aliasName": "",
        "type": {
          "name": "\"xs\" | \"sm\" | \"md\" | \"lg\"",
          "options": [
            "xs",
            "sm",
            "md",
            "lg"
          ],
          "kindName": "UnionType"
        },
        "description": "Button Size",
        "default": "md",
        "tags": {
          "default": {
            "name": "default",
            "text": [
              {
                "text": "md",
                "kind": "text"
              }
            ]
          }
        }
      },
      {
        "kind": "Input",
        "name": "alibLengthTooLongLengthTooLong",
        "aliasName": "alibAliasName",
        "type": {
          "name": "string",
          "options": null,
          "kindName": "LiteralType"
        },
        "description": "Input  of alib button component",
        "default": null,
        "tags": {
          "type": {
            "name": "type",
            "text": [
              {
                "text": "string",
                "kind": "text"
              }
            ]
          }
        }
      },
      {
        "kind": "Input",
        "name": "thyLoading",
        "aliasName": "",
        "type": {
          "name": "boolean",
          "options": null
        },
        "description": "Button loading status",
        "default": "false",
        "tags": {
          "default": {
            "name": "default",
            "text": [
              {
                "text": "false",
                "kind": "text"
              }
            ]
          }
        }
      },
      {
        "kind": "Output",
        "name": "thyLoadingEvent",
        "aliasName": "",
        "type": {
          "name": "EventEmitter<boolean>",
          "options": null
        },
        "description": "Loading Event",
        "default": "",
        "tags": {}
      },
      {
        "kind": "ContentChild",
        "name": "templateRef",
        "aliasName": "template",
        "type": {
          "name": "TemplateRef<unknown>",
          "options": null,
          "kindName": "TypeReference"
        },
        "description": "",
        "default": "",
        "tags": {}
      }
    ]
  } 
]

目前自动生成 API 支持:
组件/指令/服务的 API 生成(包括输入输出参数,服务函数)
自定义 @name
排序 @order
组件/指令参数 @type 设置类型
组件/指令参数 @default 设置默认值,不设置会自动推导
通过 @internal 和 @private 设置属性或者组件/指令/服务为私有,这样不会出现文档中

不支持的功能:
API 多语言
继承组件的参数合并
独立的注释生成参数(比如 ngModel,ngModelChange 参数的生成)
Pipe API 生成

在组件概览文档中插入所有示例  <examples /> 

Docgeni 过去支持在文档中插入某个示例,使用方式如下:
<example name="alib-button-basic-example" /> 
这种方式只能添加单个示例,如果某个组件有多个示例,需要手动一个一个添加,比较麻烦,所以此次新增了在当前组件文档中插入所有组件示例的语法:  <examples /> 

这种方式插入后不仅可以在概览中展示,同时还会在右侧 toc 中展示并快速跳转。

支持根模块自定义 declarations、imports、providers

Docgeni 默认会自动生成站点所有文件,有时候需要在站点根模块导入一个第三方模块,因为 AppModule 是自动生成的,无法实现这样的需求,只能采用自定义站点,但是自定义站点又需要配置很多东西,比较麻烦,所以此次新增了自定义 AppModule 的部分 declarations、imports、providers 元数据,最终会生成一个站点启动的 AppModule 。
在  .docgeni/app 文件夹中新增一个  module.ts ,通过 export default 导出自定义的元数据

最终生成站点的 app.module.ts 如下:

这样在自动生成站点的情况下也可以自定义 declarations、imports、providers。
通过  export default { imports: [...]} 的语法实现模块的自定义除了设置 AppModule 外,前面说的示例模块、以及自定义组件的模块都是可以的,基本 API 都是类似的。

示例支持 background, compact 和 className

在示例中支持了 background, compact 和 className FrontMatter 增强示例渲染的自定义场景。

---
title: Button Base
order: 1
compact: false
---

·background 设置示例的背景色
·compact 设置示例为紧凑模式,此参数为 true 会去除示例边距
·className 自定义 class 样式类,实现更灵活的控制

最后总结

以上是 Docgeni 2.0 核心的功能点,整个基本围绕 自动化 这个主题,让使用者做更少的事情,其余的事情工具帮你完成,当然除此之外还新增了如下特性:
·内置的搜索(之前只支持 algolia 搜索)
·示例支持 stackblitz 显示
·支持配置 favicon.ico
·通过配置生成 sitemap
·以及修复一大波缺陷

从 2020 年我们开始使用 Docgeni 重构组件库的文档,目前 60+ 个组件基本重构完毕,大大提升了写组件文档的效率。

最后最后非常欢迎 Angular 的开发者使用 Docgeni,一款更加自动化的 Angular 组件文档生成工具。
如果喜欢请记得点击 Star  https://github.com/docgeni/do... 
同时也欢迎研发团队使用我们团队开发的智能化研发管理工具  PingCode  ,25人以下 免费使用!25人以下 免费使用!25人以下 免费使用


PingCode研发中心
129 声望24 粉丝