这篇文章是对之前在知乎上本人投稿(使用 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
是一款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 {}
TypeDoc
的 entryPoints
选项接收一个数组,如果 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 是一款基于 Vite 与 Vue 的轻量级的静态网站构建工具,它擅长承载博客、文档等静态内容。比起传统开发方式,具有易上手、构建快的特点。
我们的文档主要内容是基于 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 .",
},
// ...
}
这样,我们就实现了 TypeDoc
与 VitePress
的结合使用。
更多问题
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 初步实现了离线搜索,不过暂不支持中文内容搜索。