前端开发者如何用 API Extractor 管理 API

API Extractor 是由微软提供的针对 Typescript 的 API 分析工具,如果你是一个 Typescript 库的开发人员,你可能需要它。它能解决以下问题:

  1. API 变化如何追踪?
  2. 怎么避免将内部 api 暴露到外部?
  3. 怎么避免忘了导出 API 的类型声明?
  4. 发布 alpha、beta 版本的包时,api 如何管理?
  5. 我们导出的 d.ts 十分杂乱,如何整理(webpack 等工具能将代码打包压缩)?
  6. API 文档如何自动生成?

如何描述 API

API Extractor 需要配合 TSDoc 使用。TSDoc 类似于 JSDoc,是微软提议的 typescript 注释规范。参考:TSDoc

跟 jsDoc 不同的是,TSDoc 语法更严格,另外 JSDoc 更多的关注点在给 js 提供类型注释,但针对强类型语言 typescript 设计的 TSDoc 关注点在于文档和 API 管理

比如下面 JSDoc 的 Tag,在 TypeScript 里面毫无用处:

这也能说明为什么我们写 TypeScript 应当使用 TSDoc,而不是 JS Doc。

我们这里暂时只需要关注 TSDoc 中标记 API 发布状态的 4 个 Tag:

当我们把所有对外发布的 API 用 TSDoc 的 Release Tag 标记起来之后,我们就有了管理和追踪的可能。原因是:

  1. 对外暴露的 API 是有明确标注的。库的维护者能清楚一个模块的导出哪些是给内部在用,哪些是暴露在外的。从而避免不小心修改了对外提供的 API,也降低了修改内部接口的心理压力;
  2. 如果发现最终打包出的 d.ts 中有未标注的暴露在外的 API,我们可以检查是否是不小心暴露出去的。

如何使用 API Extractor

工作流程

它的大致工作流程如下:

  1. tsc 将 ts 源码转成 js 之后,会生成一堆 *.d.ts
  2. API Extractor 通过读取这些 d.ts

生成 api 报告

当我们标记好 Release Tag 后,API Extractor 可以帮助我们生成 API 报告。

这里用一个简单的 Demo 来测试以下,我的项目代码如下:

src/index.ts

export type Cat = {
    name: string;
}
​
/**
 * type for person
 * @public
 */
export type Person = {
    name: string,
    age: number
}
/**
 * foo function
 * @Public
 */
export function foo(arg1: Person) {}

假设已经安装了 @microsoft/api-extractor, 并在配置文件 api-extractor.json里面开启了apiReport。(配置项: apiReport.enabled)那么只需要执行以下命令,就能生成一份 API 报告。

npx api-extractor run --local --verbose

生成的 API 报告如下:

api-extractor-test.api.md

## API Report File for "api-extractor-test"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
// @public (undocumented)
export type Cat = {
    name: string;
};
​
// @public
export function foo(arg1: Person): void;
​
// @public
export type Person = {
    name: string;
    age: number;
};
​
// (No @packageDocumentation comment for this package)
​

可以看到所有的API 类型声明汇总到了一份文档。同时,在执行生成API报告时,会在命令行给出Warning:

Warning: src/index.tsx:1:1 - (ae-missing-release-tag) "Cat" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)

这时我需要再去检查一下代码,会发现 Cat 这个类型其实并需不要导出,我可以去掉 export 关键字。

注意

API 报告类似于快照测试,应当放到 git 管理,这样每次提交代码能看到 API 的变化。

下面是 Api-extractor 能检测出来的问题:

  1. 多导出了东西,如上面的例子,多导出了 Cat
  2. 忘了导出类型声明
// uncallable forgotten export
enum ReportType {
  Full,
  Condensed
}
​
// forgotten export
interface IShowReportOptions {
  reportTitle: string;
  validation?: boolean;
  reportType?: ReportType;
}
​
/**
 * Shows a report.
 * @public
 */
export function showReport(options: IShowReportOptions): void {
}
​
// Warning: "The symbol "IShowReportOptions" needs to be exported by the entry point src/index.d.ts."

比如说上面这个例子,我们导出了 showReport方法,但没导出 IShowReportOptions及 ReportType , 此时用户如果想构造出一个 option 传给 shoeReport,却不知道怎么声明类型。

  1. release Tag 冲突,如下面的例子:
/** @public */
interface Size {
  width: number;
  height: number;
}
​
/** @beta */
function Size(width: number, height: number): Size {
  return { width, height };
}
​
// Warning: This symbol has another declaration with a different release tag.

  1. 导出了,却没标记 release tag. (@public @internal @beta 等)
    这个也可以用来防止暴露了内部类型。
  2. 其他注释相关的检查

注意: 生成的 API 报告也应当放到 git 管理,这样每次 MR 能看到 API 的变化。
所有 API Extractor 能给出的提醒可见: Message Reference

打包 d.ts

类似于 webpack 的打包,从一个入口文件开始,将所有依赖文件打包成一个文件,api-extractor 也可以从 index.d.ts 开始,把所有导出的类型打包到一起。

d.ts trimming

简言之,可以根据发布场景裁剪 d.ts。假如是正式版本的发布,可以将 release tag 为 @internal @beta 的类型声明删掉。如果是预览版本的发布,需要保留 @beta @public。如果是开发版本,会将所有的类型声明保留。

具体配置项如下:

{
  . . .
  "dtsRollup": {
    "enabled": true,
    "untrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>.d.ts",
    "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",,
    "publicTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-public.d.ts"
  },
}

在不同发布场景,修改 package.json 的 “typing” 字段指向来切换 d.ts

打包 d.ts 慎用的场景

打包 d.ts 的前提是你的库只有单一入口,如果你的包支持按路径引入,例如:

import {Button} from 'xxx-ui/lib/Button';
import {DatePicker} from 'xxx-ui/lib/DatePicker';

那么势必 d.ts 需要分散在各文件夹。这种情况下不要使用打包 d.ts 的功能。

基于路径的导入会使得文件架结构也成为你的库的一部分,可能文件架结构的调整也会成为 breakin change。所以是否考虑下不支持按路径导入?

生成 API 文档

API Extractor 能够生成一份 json 格式的文档模型(api-model)。相关配置在配置文件的 docModel 字段。

使用 @microsoft/api-documenter 生成 Markdown 文档

难度: 1 颗星

可以使用 @microsoft/api-documenter 直接将 api-model.json 转成 Markdown 或 yaml 格式的文档。具体步骤参考:Generating API docs

大致步骤只需要安装 @microsoft/api-documenter ,然后执行以下命令,指定下 Api Extractor 生成的 *.api.json 所在的文件夹以及你希望生成的文档存放在那个文件夹即可。

api-documenter markdown  -i json-model-folder  -o out-put-doc-folter

生成一堆 Markdown 文件之后,也很容易使用一些工具进而转成 html 或者直接生成站点。

通过@microsoft/api-extractor-model自己生成文档

难度: 4 颗星

也可以通过@microsoft/api-extractor-model自己解析 api-model.json。它能将 xxx.api.json 解析成以下数据结构:

- ApiModel   // api-model 入口
  - ApiPackage  // 可能会有多个包,对应 menorepo 的每个npm 包  
    - ApiEntryPoint // 入口文件,可以想象为某个包的 index.js
      - ApiClass  // 从入口文件导出的所有的 Class 类型, 数组
        - ApiMethod // 方法
        - ApiProperty // 属性
      - ApiEnum // 从入口文件导出的所有的 Enum 类型
        - ApiEnumMember // 枚举的每一项
      - ApiInterface   // 从入口文件导出的所有的 Interface
        - ApiMethodSignature // 方法
        - ApiPropertySignature // 属性
      - ApiNamespace  // ts 的namespace
        - (ApiClass, ApiEnum, ApiInterace, ...)
​

我们能很容易从 ApiModel 入口开始,逐层遍历这个树形结构。 api-documenter 实际上也是使用 api-extractor-model 来解析遍历所有 API。这里摘抄一段代码:

     // 先判断 apiItem 的类型,根据不同类型采取不同的解析方法。
     switch (apiItem.kind) {
      case ApiItemKind.Class:
        this._writeClassTables(output, apiItem as ApiClass); // 将 class 的属性和方法生成一个表格
        break;
      case ApiItemKind.Enum:
        this._writeEnumTables(output, apiItem as ApiEnum);  // 将 enum 的属性和方法生成一个表格
        break;
      case ApiItemKind.Interface:
        this._writeInterfaceTables(output, apiItem as ApiInterface); // 将 interface 的属性和方法生成一个表格
        break;
      case ApiItemKind.Constructor:
      case ApiItemKind.ConstructSignature:
      case ApiItemKind.Method:
      case ApiItemKind.MethodSignature:
      case ApiItemKind.Function:
         // 函数相关的类型,记录参数信息
        this._writeParameterTables(output, apiItem as ApiParameterListMixin);
        this._writeThrowsSection(output, apiItem);
        break;
      // .... 省略一些判断
      default:
        throw new Error('Unsupported API item kind: ' + apiItem.kind);
    }

这里再摘抄一段生成 interface 文档的代码:

  private _writeInterfaceTables(output: DocSection, apiClass: ApiInterface): void {
    const configuration: TSDocConfiguration = this._tsdocConfiguration;
​
    const propertiesTable: DocTable = new DocTable({
      configuration,
      headerTitles: ['Property', 'Type', 'Description']
    });
    // 遍历 interface的每一个成员
    for (const apiMember of apiClass.members) {
      switch (apiMember.kind) {
          // 如果成员是 属性的话,记录到属性表里面
        case ApiItemKind.PropertySignature: {
            propertiesTable.addRow(
              new DocTableRow({ configuration }, [
                this._createTitleCell(apiMember),  // 属性名
                this._createPropertyTypeCell(apiMember), // 属性类型
                this._createDescriptionCell(apiMember) // 属性描述
              ])
            );
          }  
      }
      // 这里略去对 方法成员的处理
    }
    if (propertiesTable.rows.length > 0) {
      // 添加文档标题, 如  XX interface Properties
      output.appendNode(new DocHeading({ configuration: this._tsdocConfiguration, title: 'Properties' }));
     // 添加上面的 属性表格
      output.appendNode(propertiesTable);
    }
  }

自己解析并生成文档的主要难点在于对各种 case 的处理,树的每个节点大概有 20 种 case。所以可以 魔改一下 api-documenter。