写点什么

Docgeni 2.0 发布,开启自动化

  • 2022-11-15
    北京
  • 本文字数:8067 字

    阅读完需:约 26 分钟

Docgeni 2.0 发布,开启自动化

作者:徐海峰

背景

2021 年的 5 月 20 日, Docgeni 正式对外宣布开源:  Docgeni,开箱即用的 Angular 组件文档工具 ,4 个月后, Docgeni 1.1.0​​ 版本的发布 ,时隔一年多,中间发布了无数个 next 版本,今天终于迎来 2.0 的正式发布 ,其中的心酸只有做过开源项目的人最有体会,因为业务的压力,只能偶尔抽空写写代码。

如果你还没有关注 Docgeni,可以访问  https://github.com/docgeni/docgeni  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 内部流程为:

  1. 通过 TypeScript 的 Compiler-API 实现 AST 语法树的解析,主要是获取到示例组件和示例模块的文件内容,手动创建一个  ts.SourceFile ,sourceFile 就是 TypeScript 的 AST 语法树

  2. 通过 ts.forEachChild ​获取到导出的 ClassDeclaration,然后判断是否有 Component ​装饰器装饰,获取示例组件元数据,具体 API 细节就不介绍了,感兴趣可以阅读  ng-source-file.ts#L49  代码

  3. 获取到示例组件元数据和模块进行组合,生成一个最终可以运行的  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.jsmodule.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 Baseorder: 1compact: 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/docgeni 

同时也欢迎研发团队使用我们团队开发的智能化研发管理工具  PingCode  ,25 人以下 免费使用!25 人以下 免费使用!25 人以下 免费使用

用户头像

还未添加个人签名 2021-02-01 加入

还未添加个人简介

评论

发布
暂无评论
Docgeni 2.0 发布,开启自动化_PingCode研发中心_InfoQ写作社区