Genluo / Precipitation-and-Summary

梳理体系化的知识点&沉淀日常开发和学习
MIT License
16 stars 1 forks source link

TypeDoc规范化文档生成 #104

Open Genluo opened 3 years ago

Genluo commented 3 years ago

1. 前言&背景

背景: 随着团队越来越的场景使用TypeScript开发需要持续维护和迭代的项目,针对这些提供给二方使用的工具库或者组件库,其中文档编写成为一个必要的步骤,刚开始通过md来描述二方使用文档,但是随着项目的迭代,多个成员的参与开发,项目文档的规范越来越不一致或者更新不及时,逐渐项目文档和项目代码脱轨,不利于整个项目的迭代

思考: 经过大量的实践发现,编写的文档其中大部分和TypeScript代码中的注释非常相关,越来越多的认可到Docs as Code 的编码概念。如果整个TypeScript项目中能够遵循一种注释规范,同时如果能够根据这种注释规范生成对应的使用文档,并且将这种能力集成到项目的CI/CD中,将会对项目开发效率和迭代效果产生非常大的帮助

尝试: 针对业务经常使用的 utils 进行尝试实践文档生成,整个项目的代码注释规范统一遵循tsdoc,使用社区提供的TypeDoc方案完成文档的生成,同时将文档生成的过程集成到项目开发的CI/CD流程中,其中的实践项目onex-utils的通过TypeDoc生成的文档可以访问此地址。随着越来越多的项目迭代变更,也越发认同TypeDoc规范文档的生成的价值。同时也针对文档生成有更多的诉求,也为了更好地使用和理解整个TypeDoc的功能,阅读了其整个源码,通过自己的理解,将其中的代码执行流程、代码设计思路、优秀的编码思路抽离出来进行分析。同时也会结合onex-utils针对文档生成的诉求,提供一些TypeDoc插件化改造的思路。本文主要是源码分析和开发记录 ​

2. 运行逻辑

  1. Read options Necessary to determine which plugins should be loaded.
  2. Load plugins If --plugin was used, loads only those specified plugins, otherwise loads all npm plugins with the typedocplugin keyword.
  3. Read options again to pick up plugin options
  4. Convert the input files into the models (also called reflections) that live under src/lib/models
  5. Resolve the models Necessary to handle links to models that might not have been created when the source model is generated.
  6. Output the models by serializing to HTML and/or JSON

alt 属性文本

3. 项目文件树

| |____lib
| | |____converter       根据规则转化文件
| | |____serialization   输出JSON的相关逻辑
| | |____output          输出HTML的相关逻辑
| | |____utils           工具类型
| | |____models          文件模型,描述注释模型的类
| | |____ts-internal.ts
| | |____application.ts  应用启动真正的执行文件(bootstrap)

4. 核心思路

整个项目的核心运行逻辑在第一部分已经清晰的进行了梳理,通过阅读源码谈下项目的代码设计亮点和编码中看到设计优秀的代码进行介绍 ​

1) · 代码组织形式

整个项目通过继承和组合的方式将各个组件(功能)进行组合,所以整个项目的运行可以理解为一棵树结构,每颗树节点代表的一个功能,一个大的功能由多个子组件(功能)组合实现。同时由于树的组织结构,每个节点中都可以快速访问子节点的任意或者父节点,整体结构会比较灵活,也符合架构设计中的开放封闭原则。下文也比较硬核,会直接抽离并梳理源码中关于代码组织形式设计的基类的代码。 ​

1. 简介

  1. 父组件(宿主组件)可以通过调用this.addComponent添加子组件实例
  2. 父组件(宿主组件)可以指定childClass来定义子组件类型,通过装饰器让继承childClass的类,都随着父组件(宿主组件)实例化进行子组件的实例化

2. 基类实现

抽象基类实现 - 子组件

/**
 * application 类型需要指定为项目的顶层类型,暂定string
 */
export interface ComponentHost {
  readonly application: string;
}

const TOP_APPLICATION = Symbol();

export abstract class AbstractComponent<O extends ComponentHost> implements ComponentHost {
  private _componentOwner: O | typeof TOP_APPLICATION;

  componentName!: string;

  constructor(owner: O | typeof TOP_APPLICATION) {
    this._componentOwner = owner;
    this.initialize();
  }

  get application() {
    if (this._componentOwner === TOP_APPLICATION) {
      return this as unknown as string;
    }
    return this._componentOwner.application;
  }

  initialize() {
    /**
     * 子组件实例化执行的初始化操作
     */
  }
}

抽象基类实现 - 宿主组件实现(可以包含子组件类型)

export type Component = AbstractComponent<ComponentHost>;

export interface ComponentClass<
    T extends Component,
    O extends ComponentHost = ComponentHost
> extends Function {
  new (owner: O): T;
}

export abstract class ChildableComponent<
  O extends ComponentHost, // 宿主组件
  C extends Component, // 子组件
> extends AbstractComponent<O> {
  _componentChildren?: { [name: string]: C };
  _defaultComponents!: { [name: string]: ComponentClass<C> };

  constructor(owner: O | typeof TOP_APPLICATION) {
    super(owner);
    // 将_defaultComponents实例化,实例保存在_defaultComponents中
    this.addComponent();
  }

  addComponent() {}

  getComponent(name: string) {
    return (this._componentChildren || {})[name];
  }
}

抽象基类实现 - 组件装饰器实现(自动化关联宿主组件及其子组件)

export interface ComponentOptions {
  name?: string;
  childClass?: Function;
  internal?: boolean;
}

const childMappings: Array<{
  host: ChildableComponent<any, any>;
  child: Function;
}> = [];

/**
 * 组件的装饰器
 */
export function ComponentDecorator(options: ComponentOptions): ClassDecorator {
  return (target: Function) => {
    const proto = target.prototype;
    if (!(proto instanceof AbstractComponent)) {
      throw new Error(
        'The `Component` decorator can only be used with a subclass of `AbstractComponent`.',
      );
    }

    if (options.childClass) {
      if (!(proto instanceof ChildableComponent)) {
        throw new Error(
          'The `Component` decorator accepts the parameter `childClass` only when used with a subclass of `ChildableComponent`.',
        );
      }

      childMappings.push({
        host: proto,
        child: options.childClass,
      });
    }

    const { name } = options;
    // 已经确认 target extends AbstractComponent
    if (name) {
      proto.componentName = name;
    }

    const internal = !!options.internal;
    if (name && !internal) {
      for (const childMapping of childMappings) {
        if (!(proto instanceof childMapping.child)) {
          continue;
        }

        const { host } = childMapping;
        host._defaultComponents = host._defaultComponents || {};
        host._defaultComponents[name] = target as any;
        break;
      }
    }
  };
}

3. 使用方式

创建顶层的root节点,其中会添加Child子组件(宿主组件类型)

import { AbstractComponent, ChildableComponent, Component, DUMMY_APPLICATION_OWNER } from './component';

/**
 * 顶层application,宿主就是自身
 */
@Component({
  name: 'application',
  internal: true,
})
class Application extends ChildableComponent<Application, AbstractComponent<Application>> {
  constructor() {
    super(DUMMY_APPLICATION_OWNER);
    // 添加子类型
    this.addComponent<Child>('child', Child);
  }
}

二层宿主组件实现

import { AbstractComponent, ChildableComponent, Component, DUMMY_APPLICATION_OWNER } from './component';

/**
 * 三层子组件抽象基类
 */
abstract class ChildClass extends AbstractComponent<Child> {
}

/**
 * 二层子类型容器
 * 
 * @remarks
 * 1. 当前容器作为Application的子组件
 * 2. 当前容器作为ChildClass的宿主容器
 * 
 */
@Component({
  name: 'child',
  internal: true,
  childClass: ChildClass,
})
class  Child extends ChildableComponent<Application, ChildClass> {
}

三层子组件 - 实现及其通过装饰器注册

/**
 * 挂在三层子组件,自动注入对应的二层容器中
 */
@Component({
  name: 'testChild'
})
class TestChild extends ChildClass {
  initialize() {
    console.log('实例化子类')
  }
}

4. 个人理解

通过这种代码组织形式,可以非常好的将项目的每部分整理成为一颗树,尤其是涉及到其中的插件机制,会让插件的开发变得非常的灵活和方便,子组件可以通过这种方式访问到整个应用的任何一个组件。

2) · 插件机制

1.简介

typeDoc的插件机制主要是依赖事件监听,代码逻辑运行过程中,将其中关键步骤通过事件的形式通知给插件,同时由于TypeDoc的结构组织形式(前文提到的),事件触发可以根据树的维度进行监听和触发,其中TypeDoc组件在Converter节点中事件列表:

export const ConverterEvents = {
    BEGIN: "begin",
    END: "end",
    CREATE_DECLARATION: "createDeclaration",
    CREATE_SIGNATURE: "createSignature",
    CREATE_PARAMETER: "createParameter",
    CREATE_TYPE_PARAMETER: "createTypeParameter",
    RESOLVE_BEGIN: "resolveBegin",
    RESOLVE: "resolveReflection",
    RESOLVE_END: "resolveEnd",
} as const;

2. 核心机制

3) · Flags标识

1. 介绍

为什么作为标识需要使用2的n次幂

结合位运算,能够实现非常快速的判断逻辑,例如,remove、havaAll、haveAny

2. 判断逻辑

// T & {} reduces inference priority
export function removeFlag<T extends number>(flag: T, remove: T & {}): T {
    return ((flag ^ remove) & flag) as T;
}

export function hasAllFlags(flags: number, check: number): boolean {
    return (flags & check) === check;
}

export function hasAnyFlag(flags: number, check: number): boolean {
    return (flags & check) !== 0;
}

// 结合形成一组类型
const commonFlags = ts.SymbolFlags.Transient |
 ts.SymbolFlags.Assignment |
 ts.SymbolFlags.Optional |
 ts.SymbolFlags.Prototype

4) 实现的Event机制

1. 思路

2. 实现

// 事件对应的队列
interface EventHandlers {
    [name: string]: EventHandler[];
}

// 队列中单个EventHandler的实现方式
interface EventHandler {
    callback: EventCallback; // 事件回调
    context: any; // 监听处上下文
    ctx: any;
    listening: EventListener; // 触发监听的上下文
    priority: number; // 事件优先级
}

interface EventListener {
    obj: any;
    objId: string;
    id: string;
    listeningTo: EventListeners;
    count: number;
}

// 事件的迭代器,确定事件触发后,迭代的处理方式
interface EventIteratee<T, U> {
    (events: U, name: string, callback: Function | undefined, options: T): U;
}

3. 个人理解

5) · 生成Reflection对象

  1. 构建上下文对象
  2. 根据文件入口,进行第一次拆分
  3. 获取文件中对应exportsymbol
  4. 根据exportsymbol分析获取对应type及其typeParameters(使用Typescript Compiler API处理)
  5. 遍历获取单个export中的symbol中的所有声明转化成到Reflection对象中

6) · Reflection 输出

通过Reflect这种结构化的数据,我们可以将Reflect对象转化成HTML或者序列化JSON对象

1. 序列化对象输出 (强依赖Reflection结构)

  1. 类型确定:将Reflection中的部分属性抽离出来生成自己的序列化对象
  2. 输出转化:从ProjectReflection出发,将对象进行遍历转化

2. HTML输出(存在Theme的概念)

  1. 通过reflection结构确定输出的类型和URL地址
  2. 将对应的Reflect对象传入到HBS模板中进行渲染
  3. Render文件之后生成具体的Content,将具体的内容写入到文件夹中,最终生成整个文档

5. 代码摘录

1) · 参数绑定装饰器:

/**
 * Binds an option to the given property. Does not register the option.
 *
 * @since v0.16.3
 */
export function BindOption<K extends keyof TypeDocOptionMap>(
    name: K
): <IK extends PropertyKey>(
    target: ({ application: Application } | { options: Options }) &
        { [K2 in IK]: TypeDocOptionValues[K] },
    key: IK
) => void;

/**
 * Binds an option to the given property. Does not register the option.
 * @since v0.16.3
 *
 * @privateRemarks
 * This overload is intended for plugin use only with looser type checks. Do not use internally.
 */
export function BindOption(
    name: NeverIfInternal<string>
): (
    target: { application: Application } | { options: Options },
    key: PropertyKey
) => void;

export function BindOption(name: string) {
    return function (
        target: { application: Application } | { options: Options },
        key: PropertyKey
    ) {
        Object.defineProperty(target, key, {
            get(this: { application: Application } | { options: Options    }) {
                if ("options" in this) {
                    return this.options.getValue(name as keyof TypeDocOptions);
                } else {
                    return this.application.options.getValue(
                        name as keyof TypeDocOptions
                    );
                }
            },
            enumerable: true,
            configurable: true,
        });
    };
}

2)Flag操作函数

// T & {} reduces inference priority
export function removeFlag<T extends number>(flag: T, remove: T & {}): T {
    return ((flag ^ remove) & flag) as T;
}

export function hasAllFlags(flags: number, check: number): boolean {
    return (flags & check) === check;
}

export function hasAnyFlag(flags: number, check: number): boolean {
    return (flags & check) !== 0;
}

// 结合形成一组类型
const commonFlags = ts.SymbolFlags.Transient |
 ts.SymbolFlags.Assignment |
 ts.SymbolFlags.Optional |
 ts.SymbolFlags.Prototype

6. 插件化改造

1)短名称实现

描述:生成文档的页面是根据文件的目录生成的,但是针对onex-utils这个项目,整个项目路径没有什么意义,所以希望值保留关键的信息进行展示,首先onex-utils项目文件树如下,其中合理性的路径名称应该是src/utils 下一级具体的group名称和功能名称。

|____src
| |____utils
| | |____color
| | | |____hex2Rgb.ts
| | | |____...
| | |____base64
| | | |____decode.ts
| | | |____...
| | |____function
| | | |____uuid.ts
| | | |____....
| | |____....

思路:这个改造应该是很简单,只需要处理下生成Reflection对上中的某个名称字段,替换前置的src/utils 字符,这里也演示下如何实现一个插件(全局配置注入) ​

实现:

// 监听application的render节点的事件
export const load = (that: Application) => {
  that.listenTo(that.application.renderer, {
    [PageEvent.BEGIN]: changeAlias,
  });
};

/**
 * reflecttion开始转化,调用changeAlias方法
 * 针对 Global 页面处理
 */
function changeAlias(page: PageEvent) {
  page?.model?.groups?.forEach((element: any) => {
    if (element.categories) {
      element?.categories?.children?.forEach((cate: any) => {
        cate.name = cate.name.replace('src/utils/', '');
      });
      return;
    }
    element.children.forEach((ele: any) => {
      ele.name = ele.name.replace('src/utils/', '');
    });
  });
}

2)版本记录

描述:onex-utils是一个工具库,随着不断叠加新功能上线,每个新功能都会有版本支持的概念,如果随着每次发版本记录每个功能的版本,并将这个版本字段渲染到生成的文档中? 思路:如果需要生成版本记录的话,肯定需要有一个版本持久化记录的地方,那么可以使用文件存储或者数据库存储来进行持久化存储,综合考虑下,最终还是选择将版本记录作为静态文件存储到生成的文档中,每次重新发版的时候,会通过https请求访问上一次静态版本数据。然后刷新这份文件,重新进行上传。 实现:

export const load = (that: Application) => {
  that.listenTo(that.application.renderer, {
    [RendererEvent.BEGIN]: getVersionMap, // 渲染开始前,获取保本记录的map信息
    [RendererEvent.END]: saveVersionMap, // 渲染结束后,重新保存版本记录的map信息
    [PageEvent.BEGIN]: updateVersionMap, // 渲染过程中,页面开始渲染,更新版本记录的map信息
  });
};
... 整体思路就是这样,实现就不展开说了

7. 参考资料

  1. typescript compile API
  2. TypeDoc source code