使用 TypeDoc 自动为 TypeScript 项目生成 API 文档

这篇文章是对之前在知乎上本人投稿(使用 TypeDoc 自动为 TypeScript 项目生成 API 文档)的转载。系列文章 从 0 到 1 搭建 Vue 组件库框架 将要更新的第 6 章(本周更新)涉及对此文章的引用,故将其搬运至掘金。文章中演示使用的 TypeDoc 版本为 0.24

本文是一篇介绍前端基础设施方面实践经验的文章,讲述如何通过文档工具 TypeDoc 自动为 TypeScript 项目生成文档,通过阅读本文你将了解:

  • TypeScript 编写文档注释。
  • 使用 TypeDoc 自动生成文档。
  • TypeDoc 与静态页面工具 VitePress 的配合使用。

本文默认读者具有 TypeScript 的开发经验,如果对内容感兴趣,但对 TypeScript 了解不足,可以先通过以下链接进行学习:

背景

大家日常的项目开发中,往往编写代码就要花费绝大多数的精力,抽不出足够的时间去为代码编写完善的文档。

可试想如果你造的轮子缺少文档,就意味着许多潜在的用户将会流失,或者你需要花费更多的精力去解决他们的问题。

另外,在实际的业务开发中,需要文档的不仅仅是用户。即使是最常规的 Web 开发场景,如果一些接口、公用模块的代码存在文档,新老程序员之间、前后端程序员之间、负责不同模块的前端程序员之间沟通交流的效率都能够得到有效的提高。

目前在我们的项目组中,后端开发的规范暂时没有统一,成员抽不出精力去维护接口文档,也没有从 Java 生态中引入一款工具去自动生成接口文档。 在接口文档不完善的情况下,前端的出错率是非常高的,在调接口时频频出现空指针错误。

之后,我引入了 TypeScript,要求其他前端的同学在封装 api 层的时候,一定要正确地声明入参和出参的类型,按照类似下面的格式。

ts 复制代码
import { openxRequest, OpenxRequestConfig } from '../../request';

/** 获取条目化文档全文导航数据,入参 */
export interface IGetDfxDocAll {
  /** 社区 id */
  communityId: number;

  /** dfx的条目化文档 id */
  ibid: string;

  /** wiki id */
  wikiId?: string;

  /** 历史版本 id */
  versionId?: string;
}

/** 条目化文档全文片段 */
export interface IDfxDocSection {
  /** 片段 markdown 文本 */
  itemStr: string;

  /** 导航 id */
  navid: string;
}

/** 获取条目化文档菜单数据 */
export function getDfxDocAll(params: IGetDfxDocAll, options?: OpenxRequestConfig) {
  const url = `${openxRequest.baseUrls.api}/community/operate-data/query/all-pages`;
  return openxRequest.get<any, IDfxDocSection[]>(url, { params, ...options });
}

如此执行之后,后续调用接口出错的概率大幅下降,TS 编译器大多数情况下都通过类型检查正确提示了错误。

但是在实际开发过程中还是会有一些问题:

  • 在缺少文档的情况下,后端开发会经常来询问一些祖传接口,甚至直接破罐破摔直接再写一个新的接口。要求他们拉前端代码下来自己搜索明显是不合理的。
  • 前端开发以前不愿意为代码写注释,造成后来者搜索代码缺少依据,往往对同一个接口进行重复封装。甚至与后端沟通后得出了重复开发接口的决定。

所以,我希望落实 TypeScript 开发 "代码即文档" 的理念,统一前端的注释规范,形成文档,更好地帮助大家,降低项目组内部的沟通成本。对此我有以下三点期望:

  • 文档根据代码内容自动生成,最大限度节省精力。
  • 文档能够与代码同步更新,及时保持时效性。
  • 文档支持按照内容关键字进行搜索。

TypeDoc 概述

TypeDoc 官方网站

TypeDoc 是一款 TypeScript 文档生成工具,它能够读取你的 TypeScript 源文件,对其进行语法分析,根据其中的类型标注与注释内容,自动为你的代码生成包含文档内容的静态网站。

经过调研,我最终选择 TypeDoc 主要有以下理由:

  • 充分利用了 TypeScript 的编译能力,文档内容并不是完全依赖注释,TypeScirpt 的原生类型标注也将成文文档的重要内容。
  • 注释符合官方的 tsdoc 标准。
  • 可拓展性强,有许多插件可以满足可能产生的个性化需求。typedoc 插件

前期准备

我们创建一个示例项目,来一步步演示这篇文章的实践过程(演示中的包管理工具使用 npm,但是更加推荐 pnpm)。

首先通过 npm init -y 生成 package.json 文件,并按照我们的需要进行修改:

json 复制代码
// package.json
{
  "name": "learning-typedoc",
  "version": "1.0.0",
  "scripts": {}
}

之后通过 npm install -D typescript 安装 TypeScript,并创建 tsconfig.json 提供语言服务:

json 复制代码
// tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "importHelpers": true,
    "experimentalDecorators": true,
    "esModuleInterop": true,
    "sourceMap": false,
    "noEmit": true,
    "strict": true,
    "resolveJsonModule": true,
    "jsx": "preserve"
  },
  "include": ["./src/*"]
}

按照 tsconfig.json 中的 include 字段创建 src 目录,在其中编写自己的代码。这里的代码用于演示 TypeDoc 文档生成,大家可以按照自己的想法自行修改。

ts 复制代码
// src/index.ts
// 示例代码,大家可以按照自己的想法自行修改
/** 接口 */
export interface MyInterface {
  /** 属性1 */
  key1: number;

  /** 属性2 */
  key2: string;
}

/** 类 */
export class MyClass {
  /** 类的属性 */
  prop1: number

  /** 构造函数 */
  constructor() {
    this.prop1 = this.privateMethod1(1, 2)
  }

  /**
   * 静态方法
   * @param param 参数,字符串列表
   * @returns 返回 Promise 对象
   */
  static staticMethod1(param: string[]) {
    return Promise.resolve(param)
  }

  /**
   * 私有方法
   * @param param1 第一个参数
   * @param param2 第二个参数
   * @returns 两数之和
   */
  private privateMethod1(param1: number, param2: number) {
    return param1 + param2
  }
  
  /** 公共方法 */
  publicMethod(param1: number, param2: number) {
    return this.prop1 + param1 + param2
  }
}

/** 类型 */
export type MyType = 1 | 2 | 3 | 4

/**
 * 函数
 * @param param 参数
 */
export function myFunction(param: MyInterface) {
  return param
}

/** 没有导出的成员,不会出现在文档中 */
class MyClassNotExport {}

再创建 doc 目录,在此目录中放置所有与文档相关的内容 ,在此目录下也创建 package.json 文件。

bash 复制代码
D:\learning\learning-typedoc> cd doc
D:\learning\learning-typedoc\doc> npm init -y
json 复制代码
// doc/package.json
{
  "name": "learning-typedoc-doc",
  "version": "1.0.0",
  "scripts": {}
}

目前的目录结构如下:

go 复制代码
learning-typedoc
├─ doc
│  └─ package.json
├─ src
│  └─ index.ts
├─ package.json
└─ tsconfig.json

TypeDoc 基本使用

doc 目录下安装 TypeDoc

bash 复制代码
D:\learning\learning-typedoc\doc> npm install -D typedoc

可以通过命令 typedoc --version 检验 TypeDoc 是否正确安装:

bash 复制代码
D:\learning\learning-typedoc\doc> npx typedoc --version

TypeDoc 0.23.15
Using TypeScript 4.8.4 from ./node_modules/typescript/lib

之后执行 TypeDoc 命令,我们就可以看到自己的文档了:

bash 复制代码
D:\learning\learning-typedoc\doc> npx typedoc --entryPoints ../src/index.ts --out ./dist

Documentation generated at ./dist

TypeDoc 配置

上面那条 TypeDoc 命令中,我们通过 entryPoints 指定了代码的入口位置,out 指定了生成文档产物的位置。

它们都是 TypeDoc 的配置项,可以在 TypeDoc 配置项 中详细了解。

但是如果每次都通过命令行指定所有的配置项,未免过于麻烦。TypeDoc 支持在配置文件中填写所有配置项。

json 复制代码
// doc/typedoc.json
{
    "$schema": "https://typedoc.org/schema.json",
    "entryPoints": ["../src/index.ts"],
    "out": "./dist"
}

之后通过命令 typedoc --options <filename>,就可以以配置文件中指定的选项运行。

bash 复制代码
D:\learning\learning-typedoc\doc> npx typedoc --options ./typedoc.json

(可选) 也可以把配置文件写在 tsconfig.json

json 复制代码
// tsconfig.json
{
  // 省略常规 typescript 配置。。。

  // typedoc 配置
  "typedocOptions": {
    "entryPoints": ["../src/index.ts"],
    "out": "./dist"
  }
}

通过命令 typedoc --tsconfig <filename> 运行,也可以达成一样的效果。

bash 复制代码
D:\learning\learning-typedoc\doc> npx typedoc --tsconfig ../tsconfig.json

(可选) 第三种方式,就是使用 TypeDoc 提供的 Node API,自己编写脚本后,执行 node <scriptname> 运行脚本,这种方式适合需要进行灵活定制的场景。 官方文档中也有简要介绍:TypeDoc Node module。 本文后续介绍与 VitePress 结合使用的过程中,将使用这种方式。

TypeDoc 文档组织

本节主要介绍 TypeDoc 的文档组织方式,这对我们后续做一些定制功能,例如与 VitePress 集成使用、编写插件是非常有帮助的。

对照之前 src/index.ts 的内容与实际生成的文档,我们发现 TypeDoc 根据入口代码文件的导出内容(export)生成文档,下面的例子做了更直观的解释。

ts 复制代码
// 将会生成文档
export class A {}

// 将深入遍历 utils 文件,utils 中的所有 export 将会生成文档
export * from './utils'

// B 只引入但是没导出,不会生成文档
import { B } from './others'

// C 接口没有导出,不会生成文档
interface C {}

TypeDocentryPoints 选项接收一个数组,如果 entryPoints 中指定了多个入口,每一个入口将被视作一个模块。这里我们修改项目的源码目录,建立多个模块作为入口(模块代码可以自行编写):

go 复制代码
learning-typedoc
├─ doc
│  ├─ typedoc.json
│  └─ package.json
├─ src
│  ├─ module1.ts
│  ├─ module2.ts
│  ├─ module3.ts
│  └─ index.ts
├─ package.json
└─ tsconfig.json

之后修改 typedoc.json 指定多个入口:

json 复制代码
// doc/typedoc.json
{
    "$schema": "https://typedoc.org/schema.json",
    "entryPoints": ["../src/index.ts", "../src/module1.ts", "../src/module2.ts", "../src/module3.ts"],
    "out": "./dist"
}

再次执行命令生成文档:

bash 复制代码
D:\learning\learning-typedoc\doc> npx typedoc --options ./typedoc.json

可见 TypeDoc 为每一个入口都单独生成了一个模块。模块是整个文档的子项,是二级内容。

对于每一个模块,展示其中导出的类、接口、类型、函数。类、接口、类型、函数又是各个模块的子项,是三级内容。

当然,类、接口中的属性、方法又可以进一步细分,它们是四级内容。

这样的组织方式可以用以中树形结构表示,类似下面的形式:

json 复制代码
{
  "name": "learning-typedoc",
  "type": "Document",
  "children": [
    {
      "name": "index",
      "type": "Module",
      "children": [
        { "name": "MyClass", "type": "Class", "children": [ /* Properties, Methods... */ ] },
        { "name": "MyInterface", "type": "Interface", "children": [ /* Properties, Methods... */ ] },
        { "name": "MyType", "type": "Type" },
        { "name": "myFunction", "type": "Function" },
      ]
    },
    {
      "name": "module1",
      "type": "Module",
      "children": [ /* Classes, Interfaces, Types, Functions... */ ]
    },
    {
      "name": "module2",
      "type": "Module",
      "children": [ /* Classes, Interfaces, Types, Functions... */ ]
    }
    // ...
  ]
}

关于如何写 TS 注释,这不在本文的讨论范畴中,大家可以前往 TypeDoc 官方文档 或者 tsdoc 标准 自行学习。

与 VitePress 结合使用

VitePress 是一款基于 ViteVue 的轻量级的静态网站构建工具,它擅长承载博客、文档等静态内容。比起传统开发方式,具有易上手、构建快的特点。

我们的文档主要内容是基于 VitePress 构建的,我们希望通过 TypeDoc 生成的文档也能以一某种方式集成到 VitePress 文档中,而无需再单独部署。接下来将介绍如何将两者进行结合使用(VuePress 也可以参考)。

TypeDoc 生成 markdown 产物

首先要解决的问题是将 TypeDoc 的输出产物转为 markdown 格式,这个可以通过现成的插件 typedoc-plugin-markdown 实现,我们为之前的示例项目安装此插件,之后再次执行 TypeDoc 命令:

bash 复制代码
D:\learning\learning-typedoc\doc> npm i -D typedoc-plugin-markdown
D:\learning\learning-typedoc\doc> npx typedoc --options ./typedoc.json

我们会发现,产物已经由 html 形式变为了 markdown 形式。

集成 VitePress

下一步,我们需要把生成的 markdown 产物与 VitePress 联系起来。

VitePress 的配置与使用不是本文的讨论重点,在此列出参考文档供大家自行查阅:

我们首先为示例项目安装 VitePress

bash 复制代码
D:\learning\learning-typedoc\doc> npm i -D vitepress vue

doc/package.json 中配好 VitePress 启动脚本。

diff 复制代码
// doc/package.json
{
  // ...
+ "scripts": {
+   "dev": "vitepress dev .",
+   "build": "vitepress build .",
+   "serve": "vitepress serve ."
+ },
  // ...
}

doc 目录下建立一个最简单的文档入口 index.md

md 复制代码
---
layout: home

title: 首页

hero:
  name: OpenX
  text: 前端开发文档
  actions:
    - theme: brand
      text: API 文档
      link: /dist/modules
---

在其中,我们声明了一个按钮,能够跳转到 /dist/modules,作为我们 API 文档的入口索引。

运行效果如下:

bash 复制代码
PS D:\learning\learning-typedoc\doc> npm run dev

> learning-typedoc@1.0.0 dev
> vitepress dev .

vitepress v1.0.0-alpha.22

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

为 VitePress 生成更丰富的导航

接下来,我们要在文档页面生成一个侧边导航,便于用户去索引内部的类、接口、方法等。按照 VitePress 文档的说明,我们需要建立 .vitepress/config.js 进行自定义配置:

js 复制代码
// doc/.vitepress/config.js
import { defineConfig } from 'vitepress';

export default defineConfig({
  title: 'Learning TypeDoc',
  themeConfig: {
    sidebar: {
      '/dist/': [
        {
          text: 'API 文档',
          items: [
            { text: 'Entry', link: '/dist/modules' },
            { text: 'Class', link: '/dist/classes/index.MyClass' },
            { text: 'Interface', link: '/dist/interfaces/index.MyInterface' },
          ],
        },
      ],
    }
  }
})

该配置文件对应的导航如下图:

之后,我们需要在 TypeDoc 构建文档的过程中,解析文档数据结构,转换成 VitePress 支持的导航对象,将 themeConfig.sidebar['/dist/'] 的值替换成我们输出的导航对象即可。

我们规定,导航对象以 JSON 的形式位于 doc/apidocConfig.json 中(建议提前创建文件,并初始化内容为空数组 []),于是我们如此修改 .vitepress/config.js 文件。

diff 复制代码
// doc/.vitepress/config.js
import { defineConfig } from 'vitepress';
+import apidocConfig from '../apidocConfig.json';

export default defineConfig({
  // ...
  themeConfig: {
    sidebar: {
-     '/dist/': [
-       {
-         // ...
-       },
-     ],
+     '/dist/': apidocConfig
    }
  }
  // ...
})

之后,我们需要修改 TypeDoc 的启动方式,通过 Node API 的方式,在生成文档的同时,将文档的数据结构以 JSON 文件的形式一并输出,作为我们下一步自动生成侧边导航的依据。

我们在 doc 目录下建立 typedoc.js 文件,为整个启动流程编写脚本。

js 复制代码
// doc/typedoc.js
const TypeDoc = require('typedoc');
const path = require('path');
const fs = require('fs');

// 根目录
function rootPath (...args) {
  return path.join(__dirname, '..', ...args)
}

// 主函数
async function main() {
  // 初始化 TypeDoc
  const app = new TypeDoc.Application();

  // 使 TypeDoc 拥有读取 tsconfig.json 的能力
  app.options.addReader(new TypeDoc.TSConfigReader());

  // 指定代码入口
  const entries = [
    rootPath('src/index.ts'),
    rootPath('src/module1.ts'),
    rootPath('src/module2.ts'),
    rootPath('src/module3.ts'),
  ];

  // 指定 TypeDoc 配置项
  await app.bootstrapWithPlugins({
    entryPoints: entries,
    tsconfig: rootPath('tsconfig.json'),
    plugin: ['typedoc-plugin-markdown'],
    allReflectionsHaveOwnDocument: true,
  });

  const project = app.convert();

  if (project) {
    // 输出产物位置
    const outputDir = path.join(__dirname, 'dist');

    // 生成文档内容
    await app.generateDocs(project, outputDir);

    // 生成文档数据结构
    const jsonDir = path.join(outputDir, 'documentation.json');
    await app.generateJson(project, jsonDir);

    // 解析数据结构,生成 VitePress Config 所需的 Sidebar 配置项
    await resolveConfig(jsonDir);
  }
}

main().catch(console.error);

上述代码中执行 app.generateJson(project, jsonDir) 即生成文档的树形数据结构:documentation.json,这个数据结构在上文 TypeDoc 文档组织 中已有介绍, 在接下来的 resolveConfig 方法中,我们将 模块(Module) 划分一级导航,将 函数(Function)、类(Class)、类型(Type)、接口(Interface) 作为模块下的子内容,进一步划分二级导航。

js 复制代码
// doc/typedoc.js
// ...

/** 生成 sidebar 目录配置项 */
async function resolveConfig(jsonDir) {
  const result = [];

  // 读取文档数据结构的 json 文件
  const buffer = await fs.promises.readFile(jsonDir, 'utf8');
  const data = JSON.parse(buffer.toString());
  if (!data.children || data.children.length <= 0) {
    return;
  }

  data.children.forEach((module) => {
    if (module.kind !== 2) {
      return;
    }
    // Module 作为一级导航
    const moduleConfig = {
      text: module.name,
      items: [
        { text: module.name, link: getModulePath(module.name) },
      ],
    };
    module.children.forEach((sub) => {
      // 类、接口、类型、函数作为二级导航
      if (sub.kind === 128) {
        moduleConfig.items.push({ text: `Class:${sub.name}`, link: getClassPath(module.name, sub.name) });
      } else if (sub.kind === 256) {
        moduleConfig.items.push({ text: `Interface:${sub.name}`, link: getInterfacePath(module.name, sub.name) });
      } else if (sub.kind === 4191304) {
        moduleConfig.items.push({ text: `Type:${sub.name}`, link: getTypePath(module.name, sub.name) });
      } else if (sub.kind === 64) {
        moduleConfig.items.push({ text: `Function:${sub.name}`, link: getFunctionPath(module.name, sub.name) });
      }
    });
    result.push(moduleConfig);
  });

  // 转换成的导航数据输出到 doc/apidocConfig.json
  await fs.promises.writeFile(path.join(__dirname, 'apidocConfig.json'), JSON.stringify(result), 'utf8');
}

function transformModuleName(name) {
  return name.replace(/\//g, '_');
}

function getModulePath(name) {
  return path.join('/dist/modules', `${transformModuleName(name)}`).replace(/\\/g, '/');
}

function getClassPath(moduleName, className) {
  return path.join('/dist/classes', `${transformModuleName(moduleName)}.${className}`).replace(/\\/g, '/');
}

function getInterfacePath(moduleName, interfaceName) {
  return path.join('/dist/interfaces', `${transformModuleName(moduleName)}.${interfaceName}`).replace(/\\/g, '/');
}

function getTypePath(moduleName, typeName) {
  return path.join('/dist/types', `${transformModuleName(moduleName)}.${typeName}`).replace(/\\/g, '/');
}

function getFunctionPath(moduleName, functionName) {
  return path.join('/dist/functions', `${transformModuleName(moduleName)}.${functionName}`).replace(/\\/g, '/');
}

最后,我们修改 VitePress 启动命令,在启动前先完成 TypeDoc 输出文档的流程。

diff 复制代码
// doc/package.json
{
  // ...
  "scripts": {
-   "dev": "vitepress dev .",
+   "dev": "node typedoc.js && vitepress dev .",
-   "build": "vitepress build .",
+   "build": "node typedoc.js && vitepress build .",
-   "serve": "vitepress serve ."
+   "serve": "node typedoc.js && vitepress serve .",
  },
  // ...
}

这样,我们就实现了 TypeDocVitePress 的结合使用。

更多问题

  • VuePress 能按照类似的方式集成 TypeDoc 吗?

大同小异,只要掌握本文介绍的思路:将 TypeDoc 文档数据结构转换为 Sidebar 主题配置对象,实现起来不会有什么问题。

还有一个好消息,就是 VuePress 有现成的插件哦 vuepress-plugin-typedoc

  • 本文介绍的集成思路还是需要在项目里面编写脚本,能不能实现一款插件 vitepress-plugin-typedoc,更轻松地集成这个能力?

VitePress 的插件系统目前还没有文档,只能以 Vite 插件的形式实现,我目前的思路是修改 VitePress 内置的虚拟模块,可能不是很优雅。

有时间我会尝试把这个插件开发出来。

  • 如何集成搜索功能?

VitePress 的搜索能力还没有正式对外开放(文档中没有提及)。

当前条件下,一种方式配置 themeConfig.algolia,参考:vitepress/blob/main/docs/.vitepress/config.ts

algolia 是在线搜索服务,在内网环境下是不可用的,线下搜索服务需要自己编写或者寻找插件实现,目前 vitepress-plugin-search 插件集成 lunr 初步实现了离线搜索,不过暂不支持中文内容搜索。

相关推荐
Myli_ing30 分钟前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
dr李四维1 小时前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
雯0609~1 小时前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ1 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z1 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
前端百草阁2 小时前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜2 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4042 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish2 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小五Five2 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript