导航
导航:0. 导论
上一章节: 6. 建立带有 Demo 示例功能的文档网站 - 上
(编写中)下一章节:7. 接入单元测试与集成测试
本章节示例代码仓:Github
本章节文档展示效果:gkn1234.github.io/openx-ui/
紧接着上篇,我们在下篇实现组件库文档的另外两个核心需求:API 说明、代码演练场。
组件 API 文档的自动生成
API 文档建设方案选型
经过调研,在实现 API 文档方面我考虑了三种方案:
- 第一种方案就是最常规的方案——手动维护,目前 element-plus 文档的 API 说明部分也依然是完全手动编写的。(参考:button.md)
- 第二种方案是受到一篇文章 【Typescript 的 interface 自动化生成 API 文档】 的启发,我也试着像文章中一样,用 ts-morph 分析
.ts
文件,将组件Props
对应的接口解析成 AST,再以 markdown 内容的形式输出。 - 第三种方案源于我去年的实践经验:使用 TypeDoc 自动为 TypeScript 项目生成 API 文档。TypeDoc 是一款成熟的文档生成工具,输入
.ts
文件入口,它会自动分析代码,生成包含 API 文档的静态网站。
初步对比这三种方案,不难看出它们各自的优势和缺陷:
方案 | 灵活性 | 工作量 | 可维护性 |
---|---|---|---|
手动维护 | 完全自由 | 较高 | 人工输入可能犯错导致说明与源码实际情况不一致,源码更新也要同步修改文档,时效性难以保证 |
ts-morph 解析转化 | 灵活性较高,对 ts 文件的格式有一定要求,拓展展示策略需要一定的开发量 | 前期非常高,实现完善的解析与转换规则并不容易 | 自动化工具能够确保准确性、时效性 |
TypeDoc 生成 | 灵活性较低,对 ts 文件有较多要求,成品工具在拓展展示策略方面会受到更多限制 | 较低,前期需要接入工具,之后内容由工具自动生成 | 自动化工具能够确保准确性、时效性 |
首先我完全不看好手动维护的方式,因为在实际开发过程中,项目 Owner 往往精力有限,大部分情况下,文档都没办法及时同步最新版本。特别是公司内部的项目,往往是两三匹牛马承担十个人的开发工作量,成员的精力都集中在版本迭代上,文档落后几个大版本也丝毫不稀奇,问题解决全靠低成本的客服拉会处理。
如果手动造轮子,用 ts-morph
做源码解析,再自己实现转换规则,理论上可以满足任何展示需求。不过 ts-morph
的使用并不容易,涉及到 TypeScript 编译方面的概念非常多。如果你对 API 展示有比较高的要求,而不仅仅是简单列一个表格的话,也需要大量的精力做调试,前期开发的工作量非常大。
TypeDoc
是成品化程度最高的工具,往往封装程度越高的工具,在灵活性方面就会损失得越多,因此我们需要重点考察的就是 TypeDoc
现有的能力能够多大程度地覆盖我们现有和未来的需求。
经过考量,我认为 TypeDoc
在易用性方面的优势很大,自己造轮子并不容易达到它已有功能的完善度。最终我们选用 TypeDoc
来生成 API 文档。
TypeDoc 简介
TypeDoc 是一款
TypeScript
文档生成工具,它能够读取你的TypeScript
源文件,对其进行语法分析,根据其中的类型标注与注释内容,自动为你的代码生成包含文档内容的静态网站。
TypeDoc
具有的能力可以覆盖我们组件库文档的需求:
-
转换的目标对象为:
类 Classes
、接口 Interfaces
、函数 Functions
、类型 Types
。在Vue3
中,组件库的 Props 属性、Emits 事件、Slots 插槽、 Exposes 实例 API 都支持以接口 Interfaces
的方式定义,正好契合了TypeDoc
的功能。 -
按照
TypeScript
官方的tsdoc
标准编写注释,注释的内容会按照一定规则转换成文档内容。同时,TypeDoc
也充分利用了TypeScript
的编译能力,文档内容并不是完全依赖注释,TypeScirpt
的原生类型标注也将成为文档的重要内容。 -
可拓展性强,支持通过插件满足可能产生的个性化需求(typedoc 插件)。例如
VitePress
支持的内容格式是markdown
,社区中提供了 typedoc-plugin-markdown 将输出内容转换为 md 文件。
本文不再赘述关于 TypeDoc
的基础知识,将直接进入正题,演示如何使用 TypeDoc
自动生成组件库的 API 文档。
TypeDoc
如何集成,可以参考我以前的文章:使用 TypeDoc 自动为 TypeScript 项目生成 API 文档。
TypeDoc
如何通过 TSDoc 编写注释,从而生成文档,请参考:TypeDoc 官网 - Tags 说明。
前期准备
TypeDoc
生成 API 文档的规则是:指定入口文件(entryPoints
)中,所有通过 export
导出的内容。
入口文件很容易理解,我们需要将每一个组件的 Props 属性、Emits 事件、Slots 插槽、 Exposes 实例 API 都集中定义在一个 API 声明文件中,例如 props.ts
。之后将所有的 API 声明文件路径都传给 TypeDoc
配置的 entryPoints
选项。
📦packages
┣ 📂button
┃ ┃ ┣ 📂src
┃ ┃ ┃ ┣ 📜Button.vue
┃ ┃ ┃ ┣ 📜props.ts # API 集中声明文件
┃ ┃ ┃ ┗ 📜index.ts
┣ 📂input
┃ ┃ ┣ 📂src
┃ ┃ ┃ ┣ 📜Input.vue
┃ ┃ ┃ ┣ 📜props.ts # API 集中声明文件
┃ ┃ ┃ ┗ 📜index.ts
// TypeDoc 配置对象
{
// ...
"entryPoints": [
"packages/button/src/props.ts",
"packages/input/src/props.ts",
// 更多组件
// "packages/xxx/src/props.ts",
],
}
对于每个入口,只有通过 export
导出的内容会生成 API 文档,下面的例子可以做直观的解释:
// 将会生成文档
export class A {}
// 将深入遍历 utils 文件,utils 中的所有 export 将会生成文档
export * from './utils'
// B 只引入但是没导出,不会生成文档
import { B } from './others'
// C 接口没有导出,不会生成文档
interface C {}
了解规则后,我们来为组件 Button
和 Input
来准备示例代码。注意,下面的示例代码只是为了满足演示需要,并不是组件实际应有的业务逻辑。
我们会充分用到 <script setup>
中用于声明组件的编译器宏,这也是目前官方比较推崇的实践,具体可以参考:Vue 官方文档 - script setup。
在 Button 组件中,我们演示以下要素的 API 文档生成:
- props 属性:
ButtonProps
。 - 带有参数的插槽 slots:
ButtonSlots
。
// packages/button/src/props.ts
/** @module Button */
import { InferVueDefaults } from '@openxui/shared';
export type ButtonType = '' | 'primary' | 'success' | 'info' | 'warning' | 'danger';
/** 按钮组件的属性 */
export interface ButtonProps {
/**
* 按钮的类型
* @default ''
*/
type?: ButtonType;
/**
* 按钮是否为朴素样式
* @default false
*/
plain?: boolean | undefined;
/**
* 按钮是否不可用
* @default false
*/
disabled?: boolean;
}
export function defaultButtonProps() {
return {
type: '',
plain: false,
disabled: false,
} satisfies Required<InferVueDefaults<ButtonProps>>;
}
/** 按钮组件的插槽信息 */
export interface ButtonSlots {
default(props: {
/** 按钮的类型 */
type: ButtonType
}): any;
}
接口声明的文件修改后,我们也要同步更新单文件模板 button.vue
以及组件出口 index.ts
。
<!-- packages/button/src/button.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import {
defaultButtonProps,
ButtonProps,
ButtonSlots,
} from './props';
const props = withDefaults(
defineProps<ButtonProps>(),
defaultButtonProps(),
);
defineSlots<ButtonSlots>();
const classes = computed(() => {
const result: string[] = [];
if (props.type) {
result.push(`op-button--${props.type}`);
}
if (props.plain) {
result.push('op-button--plain');
}
if (props.disabled) {
result.push('op-button--disabled');
}
return result;
});
</script>
<template>
<button
class="op-button"
:class="classes">
<slot :type="type" />
</button>
</template>
// packages/button/src/index.ts
import Button from './button.vue';
import './button.scss';
import 'virtual:uno.css';
export { Button };
export type ButtonInstance = InstanceType<typeof Button>;
export * from './props';
在 Input 组件中,我们来演示一些更复杂的要素:
- 带有继承的 props:
InputProps
。 - emits 事件:
InputEmits
。 - 组件对外暴露的方法 expose:
InputExpose
。
需要注意,由于新增了对 @openxui/button
的依赖,所以不要忘记安装依赖:pnpm --filter @openxui/input i -S @openxui/button
。
// packages/input/src/props.ts
/** @module Input */
import { Ref } from 'vue';
import { InferVueDefaults } from '@openxui/shared';
import { ButtonProps, defaultButtonProps } from '@openxui/button';
import type Input from './input.vue';
/** 输入框组件的属性 */
export interface InputProps extends ButtonProps {
/**
* 输入值,支持 v-model 双向绑定
* @default ''
*/
modelValue?: string;
}
/** @hidden */
export function defaultInputProps(): Required<InferVueDefaults<InputProps>> {
return {
...defaultButtonProps(),
modelValue: '',
};
}
/** 输入框组件的事件 */
export interface InputEmits {
/**
* 11111
* @param val 输入框的值
*/
(event: 'update:modelValue', val: string): void;
/** 22222 */
(event: 'input', val: string): void;
}
/** 输入框组件对外暴露的方法 */
export interface InputExpose {
/** 清空输入框 */
clear: () => void;
/** 响应式变量 */
a: Ref<number>;
}
export type InputInstance = InstanceType<typeof Input>;
<!-- packages/input/src/input.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { hello } from '@openxui/shared';
import {
defaultInputProps,
InputProps,
InputEmits,
InputExpose,
} from './props';
withDefaults(
defineProps<InputProps>(),
defaultInputProps(),
);
const emit = defineEmits<InputEmits>();
function inputHandler(e: any) {
const { value } = e.target;
emit('update:modelValue', value);
hello(`${value}`);
}
function clear() {
emit('update:modelValue', '');
}
const a = ref(0);
defineExpose<InputExpose>({
clear,
a,
});
</script>
<template>
<input
class="openx-input"
type="text"
:value="modelValue"
@input="inputHandler">
</template>
// packages/input/src/index.ts
import Input from './input.vue';
export { Input };
export type InputInstance = InstanceType<typeof Input>;
export * from './props';
在我们的示例项目中,还有一个 ConfigProvider
组件,我们也按照类似的方式进行修改,这里便不再重复说明。
使用 TypeDoc 自动生成 API 文档
下一步,我们要在文档项目 @openxui/docs
中接入 TypeDoc
,首先要安装工具本体以及必要的插件:
pnpm --filter @openxui/docs i -D typedoc typedoc-plugin-markdown
typedoc-plugin-markdown 可以使 TypeDoc
以 markdown 的形式输出文档产物,从而更好地与 VitePress
结合。
由于生成文档的过程稍微有些复杂,无法通过配置文件的方式完成。因此我们要创建 docs/scripts/typedoc.ts
脚本,对文档生成的过程进行定制:
- 指定文件入口为所有组件包内的
src/props.ts
文件。 - 将产物输出到
docs/api
目录下。
// docs/scripts/typedoc.ts
import {
Application,
TSConfigReader,
} from 'typedoc';
import { join } from 'node:path';
/** 从整个工程的根目录计算路径 */
const fromRoot = (...paths: string[]) => join(
__dirname,
'..',
'..',
...paths,
);
const tsConfigPath = fromRoot('tsconfig.src.json');
/** 文档输出目录 */
const OUT_DIR = join(__dirname, '..', 'api');
async function main() {
const app = await Application.bootstrapWithPlugins({
// 指定文件入口,支持 globs 匹配多文件。规定为所有组件包内的 src/props.ts 文件。
entryPoints: [fromRoot('packages', '**', 'props.ts')],
// tsconfig 配置
tsconfig: tsConfigPath,
// 启用 markdown 转化插件
plugin: ['typedoc-plugin-markdown'],
// 更多配置项参考:https://typedoc.org/options/
disableSources: true,
readme: 'none',
skipErrorChecking: true,
}, [
new TSConfigReader(),
]);
const project = await app.convert();
if (project) {
// 生成并输出产物
await app.generateDocs(project, OUT_DIR);
}
}
main().catch(console.error);
由于 docs/api
目录将作为产物目录,建议在 docs/.gitignore
中补充此路径:
/.vitepress/cache
+/api
之前我们在根目录下安装过 tsx,这里我们可以直接使用命令运行 ts
脚本:
pnpm --filter @openxui/docs exec tsx scripts/typedoc.ts
生成的产物如下:
📦api
┣ 📂interfaces
┃ ┣ 📜Button.ButtonProps.md
┃ ┣ 📜Button.ButtonSlots.md
┃ ┣ 📜ConfigProvider.ConfigProviderProps.md
┃ ┣ 📜Input.InputEmits.md
┃ ┣ 📜Input.InputExpose.md
┃ ┗ 📜Input.InputProps.md
┣ 📂modules
┃ ┣ 📜Button.md
┃ ┣ 📜ConfigProvider.md
┃ ┗ 📜Input.md
┣ 📜.nojekyll
┗ 📜README.md
接下来,我们回顾 6. 建立带有 Demo 示例功能的文档网站 - 上 部分提到 VitePress
的路由规则,下一步应该将 VitePress
的导航配置与这些自动生成的 markdown 产物建立起联系。
VitePress
的 config.mts
文件支持 json
格式文件的引入,因此我们可以采取这样的思路:根据 TypeDoc
生成的产物数据,自动生成章节导航对象,输出为 json
文件。之后在 docs/.vitepress/config.mts
配置文件中引入。
首先我们要研究如何分析 TypeDoc
的产物数据。通过下面的方法,可以输出 json
格式的 TypeDoc
产物数据:
import { Application } from 'typedoc';
const app = await Application.bootstrapWithPlugins(/** ... */);
const project = await app.convert();
await app.generateJson(project, jsonDir);
这个产物数据是以树形结构组织起来的,展示了 TypeDoc
中各种概念之间的从属关系:
TypeDoc
为每一个入口都单独生成了一个模块。模块是整个文档的子项,是二级内容。- 对于每一个模块,展示其中导出的类、接口、类型、函数。类、接口、类型、函数又是各个模块的子项,是三级内容。
- 类、接口中的属性、方法又可以进一步细分,它们是四级内容。
这个数据的丰富度完全足够我们动态生成导航对象了,想必
typedoc-plugin-markdown
这种转化产物格式的插件也是通过解析这样的数据结构实现的。
{
"name": "openx-ui",
"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... */ ]
}
// ...
]
}
其次,我们要考虑转换后的导航对象如何被 VitePress
获取到。我们先建立一个 docs/configs
目录,专门用于存放导航配置对象的 json
文件,再将 docs/.vitepress/config.mts
中的导航配置部分由直接声明对象改为文件引入:
📦configs
┣ 📜api.json
┗ 📜components.json
// docs/configs/components.json
[
{
"text": "基础组件",
"items": [{ "text": "Button 按钮", "link": "/components/button" }]
},
{
"text": "配置组件",
"items": [{ "text": "ConfigProvider 配置", "link": "/components/config-provider" }]
},
{
"text": "表单组件",
"items": [{ "text": "Input 输入", "link": "/components/input" }]
}
]
// docs/configs/api.json
// 先初始化为空数组,等待后续动态生成
[]
// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'
// 其他 import ...
import apiConfig from '../configs/api.json';
import componentsConfig from '../configs/components.json';
export default defineConfig({
// 其他配置 ...
themeConfig: {
sidebar: {
// 指南部分的章节导航
'/guide/': [
{
text: '指引',
items: [
{ text: '组件库介绍', link: '/guide/' },
{ text: '快速开始', link: '/guide/quick-start' },
],
},
],
// 组件部分的章节导航
'/components/': componentsConfig,
// API 文档部分的章节导航
'/api/': apiConfig,
}
}
})
明确了思路后,我们继续完善 docs/scripts/typedoc.ts
中的代码,补充动态生成章节导航部分的逻辑:API 文档的导航对象完全可以以组件文档的导航对象为基础,进一步向下衍生出三级导航链接到具体的 interface。
// docs/scripts/typedoc.ts
import {
Application,
TSConfigReader,
ReflectionKind,
ProjectReflection,
} from 'typedoc';
import { join } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { DefaultTheme } from 'vitepress';
/** 从整个工程的根目录计算路径 */
const fromRoot = (...paths: string[]) => join(
__dirname,
'..',
'..',
...paths,
);
const tsConfigPath = fromRoot('tsconfig.src.json');
/** 文档输出目录 */
const OUT_DIR = join(__dirname, '..', 'api');
/** 章节导航配置所在目录 */
const CONFIGS_DIR = join(__dirname, '..', 'configs');
async function main() {
const app = await Application.bootstrapWithPlugins({
// 指定文件入口,支持 globs 匹配多文件。规定为所有组件包内的 src/props.ts 文件。
entryPoints: [fromRoot('packages', '**', 'props.ts')],
// tsconfig 配置
tsconfig: tsConfigPath,
// 启用 markdown 转化插件
plugin: ['typedoc-plugin-markdown'],
// 更多配置项参考:https://typedoc.org/options/
disableSources: true,
readme: 'none',
skipErrorChecking: true,
}, [
new TSConfigReader(),
]);
const project = await app.convert();
if (project) {
// 生成并输出产物
await app.generateDocs(project, OUT_DIR);
// 生成产物 json 文件
const jsonDir = join(OUT_DIR, 'documentation.json');
await app.generateJson(project, jsonDir);
// 根据产物信息,动态生成 API 文档部分的章节导航
await resolveConfig(jsonDir, join(CONFIGS_DIR, 'components.json'));
}
}
main().catch(console.error);
/** 生成 sidebar 目录 config */
async function resolveConfig(
documentJsonDir: string,
componentsConfigJsonDir: string,
) {
// 读取 TypeDoc 产物
const buffer = await readFile(documentJsonDir, 'utf8');
const data = JSON.parse(buffer.toString()) as ProjectReflection;
if (!data.children || data.children.length <= 0) {
return;
}
// 读取 components.json,使 API 文档的一、二级导航与组件说明文档保持一致
const componentsConfig = await readComponentsConfig(componentsConfigJsonDir);
data.children.forEach((module) => {
if (module.kind !== ReflectionKind.Module) return;
const moduleConfig = findComponentFromConfig(componentsConfig, module.name);
if (!moduleConfig) return;
moduleConfig.collapsed = true;
moduleConfig.link = `/api/modules/${module.name}`;
// 每个模块下的 interface、class 继续细分为三级导航
moduleConfig.items = [];
module.children?.forEach((sub) => {
// 将三级导航的跳转路径与产物文件路径对应起来
if (sub.kind === ReflectionKind.Class) {
moduleConfig.items?.push({ text: sub.name, link: `/api/classes/${module.name}.${sub.name}` });
} else if (sub.kind === ReflectionKind.Interface) {
moduleConfig.items?.push({ text: sub.name, link: `/api/interfaces/${module.name}.${sub.name}` });
}
});
});
// 输出最终的导航对象
await writeFile(join(CONFIGS_DIR, 'api.json'), JSON.stringify(componentsConfig, null, 2), 'utf8');
}
async function readComponentsConfig(jsonDir: string) {
const buffer = await readFile(jsonDir, 'utf8');
return JSON.parse(buffer.toString()) as DefaultTheme.SidebarItem[];
}
function findComponentFromConfig(config: DefaultTheme.SidebarItem[], name: string) {
let itemIndex = -1;
const targetCategory = config.find((category) => {
if (!category.items || category.items.length <= 0) return false;
itemIndex = category.items.findIndex((comp) => comp.text?.startsWith(name));
return itemIndex >= 0;
});
return itemIndex >= 0 ?
targetCategory?.items?.[itemIndex] || null :
null;
}
脚本完善后,我们修改 package.json
中 scripts
字段,加入 TypeDoc
自动生成 API 文档的过程:
// docs/package.json
{
// 其他配置 ...
"scripts": {
+ "api": "tsx scripts/typedoc.ts",
"dev": "vitepress dev . --host",
- "build": "vitepress build .",
+ "build": "pnpm run api && vitepress build .",
"preview": "vitepress preview . --host"
},
}
当然,我们还可以给 API 文档增加更多的入口:
- 修改对应的标题栏导航。
// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'
// ...
export default defineConfig({
+ ignoreDeadLinks: true,
// 其他配置 ...
themeConfig: {
nav: [
{ text: '指南', link: '/guide/' },
{ text: '组件', link: '/components/' },
- { text: 'API', link: '/api/' },
+ { text: 'API', link: '/api/README' },
{ text: '演练场', link: '/playground' },
],
+ // 每篇文档右侧的大纲开启支持三级的深度
+ outline: {
+ level: [2, 3],
+ },
// 其他配置 ...
}
})
- 修改对应的首页导航。
<!-- docs/index.md -->
---
layout: home
title: OpenxUI
hero:
name: OpenxUI
text: Vue3 组件库
tagline: 从 0 到 1 搭建 Vue 组件库
image:
src: /logo.png
alt: OpenX
actions:
# ...
- theme: brand
text: API 文档
- link: /api/
+ link: /api/README
---
- 每一篇组件文档也提供传送到对应 API 文档的链接。(这里以 button 组件的文档为例)
<!-- docs/components/button.md -->
# Button 按钮
常用的操作按钮。
## 基础用法
基础的按钮用法。
:::demo
../demo/button/demo1.vue
:::
## 基础用法2
111
## [Button Props](../api/interfaces/Button.ButtonProps.md)
## [Button Slots](../api/modules/Button.ButtonSlots.md)
最后,运行自动生成 API 文档的命令:
pnpm --filter @openxui/docs run api
生成的 API 文档导航对象如下:
[
{
"text": "基础组件",
"items": [
{
"text": "Button 按钮",
"link": "/api/modules/Button",
"collapsed": true,
"items": [
{
"text": "ButtonProps",
"link": "/api/interfaces/Button.ButtonProps"
},
{
"text": "ButtonSlots",
"link": "/api/interfaces/Button.ButtonSlots"
}
]
}
]
},
{
"text": "配置组件",
"items": [
{
"text": "ConfigProvider 配置",
"link": "/api/modules/ConfigProvider",
"collapsed": true,
"items": [
{
"text": "ConfigProviderProps",
"link": "/api/interfaces/ConfigProvider.ConfigProviderProps"
}
]
}
]
},
{
"text": "表单组件",
"items": [
{
"text": "Input 输入",
"link": "/api/modules/Input",
"collapsed": true,
"items": [
{
"text": "InputEmits",
"link": "/api/interfaces/Input.InputEmits"
},
{
"text": "InputExpose",
"link": "/api/interfaces/Input.InputExpose"
},
{
"text": "InputProps",
"link": "/api/interfaces/Input.InputProps"
}
]
}
]
}
]
预览效果如下:
至此,我们就借助 TypeDoc
实现了比较完善的组件库 API 文档自动生成的机制,只需要我们后续按照规定好的 props.ts
的方式编写每个组件的接口声明即可。除了 TypeScript
接口声明与注释之外,以后我们无需再为了 API 文档写任何多余的文本。
接入代码演练场
我们的文档还剩下一个最后一个核心功能——代码演练场。在线的演练场一直以来都不是一个容易实现的功能,无论是演示代码沙箱环境的隔离,还是实现一个体验良好的在线代码编辑器,都非常的复杂。感谢 Vue 生态,为我们提供了 @vue/repl 工具,让我们可以一键将 vue
的代码解释器集成到自己的项目中。目前主流的 Vue 组件库都集成了这个工具,实现了自己的代码演练场。
进一步研究 element-plus 的演练场后,我们可以将整个功能点拆分为三个阶段,依次实现它们:
- 接入 vue 代码解释器组件
@vue/repl
。 - 实现
vue
、TypeScript
、组件库的版本切换功能。 - 展示特定的用例代码,支持从用例演示直接跳转到对应的演练场。
接入 @vue/repl
这里先给大家推荐两篇讲解如何集成 @vue/repl
的文章,本文集成的思路也从中有所借鉴:
搭建 OpenTiny 组件库的 Playground 指导手册
首先依然是依赖安装:
pnpm --filter @openxui/docs i -S @vue/repl
之后,我们在 docs/.vitepress/components/Playground.vue
创建演练场组件,引入 @vue/repl
。
这里我们要特别注意,由于 VitePress
生成的是 SSG 网页,涉及服务端渲染。演练场组件使用到的 Monaco Editor 编辑器在初始化时涉及了浏览器专有的 API,因此我们需要注意组件的 SSR 兼容问题,让组件只在客户端环境下渲染。VitePress - SSR 适配 对此也有详细说明。
<!-- docs/.vitepress/components/Playground.vue -->
<script setup lang="ts">
import {
ref,
reactive,
onMounted,
} from 'vue';
import { Repl, ReplStore } from '@vue/repl';
import '@vue/repl/style.css';
let Monaco: any;
const isMounted = ref(false);
// 为了适配服务端渲染,组件本身,以及 Monaco 编辑器只在挂载完成后渲染
onMounted(() => {
import('@vue/repl/monaco-editor').then((res) => {
Monaco = res.default;
isMounted.value = true;
});
});
// repl组件需要store管理状态
const store = new ReplStore({});
const previewOptions = reactive({});
</script>
<template>
<div v-if="isMounted">
<Repl
:store="store"
:editor="Monaco"
:auto-resize="true"
:clear-console="false"
:preview-options="previewOptions" />
</div>
</template>
<style scoped lang="scss">
:deep(.vue-repl) {
height: calc(100vh - var(--vp-nav-height));
}
</style>
在 docs/.vitepress/components/index.ts
导出 Playground 组件,并在演练场页面 docs/playground.md
中使用:
// docs/.vitepress/components/index.ts
import Demo from './Demo.vue';
+import Playground from './Playground.vue';
export {
Demo,
+ Playground,
};
<!-- docs/playground.md -->
---
layout: page
---
<script setup>
import { Playground } from './.vitepress/components'
</script>
<ClientOnly>
<Playground />
</ClientOnly>
刷新预览页,我们已经获得了最基本的代码演练场功能。
为了防止后续执行 vitepress build
时构建失败,建议对 vite.config
文件也进行修改:
// docs/vite.config.mts
import { defineConfig } from 'vite';
// ...
export default defineConfig({
// ...
+ optimizeDeps: {
+ exclude: ['@vue/repl'],
+ },
+ ssr: {
+ noExternal: ['@vue/repl'],
+ },
});
vue 与 TypeScript 的版本切换
@vue/repl
的状态管理对象 ReplStore
提供了切换 Vue
以及 TypeScript
版本的能力:
// repl组件需要store管理状态
const store = new ReplStore({});
// 切换 vue 版本
store.setVueVersion('3.3.4');
// 切换 TS 版本
store.state.typescriptVersion = '5.0.0'
因此,新增 vue
与 TypeScript
的版本切换功能,我们只需要写好切换版本的 UI,将 UI 与状态管理对象的对应数据进行绑定即可。这里我们运用了 vue3 新增的 Teleport 功能,将 UI 添加到了 VitePress
主题的标题栏中:
<!-- docs/.vitepress/components/Playground.vue -->
<script setup lang="ts">
import {
ref,
reactive,
watch,
watchEffect,
onMounted,
} from 'vue';
import { Repl, ReplStore } from '@vue/repl';
import '@vue/repl/style.css';
// 省略之前的代码...
// 新增部分
// TS 版本切换部分
const tsVersions = ref<string[]>([]);
/** 获取所有 TypeScript 版本 */
async function fetchTsVersions() {
const res = await fetch('https://data.jsdelivr.com/v1/package/npm/typescript');
const { versions } = (await res.json()) as { versions: string[] };
tsVersions.value = versions.filter((v) => !v.includes('dev') && !v.includes('insiders'));
}
fetchTsVersions();
// vue 版本切换相关
const vueVersion = ref('latest');
const vueVersions = ref<string[]>([]);
/** 获取所有 Vue 版本 */
async function fetchVueVersions() {
const res = await fetch('https://data.jsdelivr.com/v1/package/npm/vue');
const { versions } = (await res.json()) as { versions: string[] };
// if the latest version is a pre-release, list all current pre-releases
// otherwise filter out pre-releases
let isInPreRelease = versions[0].includes('-');
const filteredVersions: string[] = [];
for (let i = 0; i < versions.length; i++) {
const v = versions[i];
if (v.includes('-')) {
if (isInPreRelease) {
filteredVersions.push(v);
}
} else {
filteredVersions.push(v);
isInPreRelease = false;
}
if (v === '3.0.10') {
break;
}
}
vueVersions.value = filteredVersions;
}
fetchVueVersions();
const isVueLoading = ref(false);
watch(vueVersion, (v) => {
setVueVersion(v);
});
function setVueVersion(v: string) {
if (isVueLoading.value) return;
isVueLoading.value = true;
store.setVueVersion(v).finally(() => {
isVueLoading.value = false;
});
}
</script>
<template>
<div v-if="isMounted">
<!-- 省略之前的代码... -->
<!-- 新增模板部分 -->
<Teleport to=".VPNavBarSearch">
<div class="flex items-center text-14px">
<label class="playground-label">Vue: </label>
<select v-model="vueVersion" class="playground-select" :disabled="isVueLoading">
<option value="latest">
latest
</option>
<option v-for="item in vueVersions" :key="item" :value="item">
{{ item }}
</option>
</select>
<label class="playground-label">TypeScript: </label>
<select v-model="store.state.typescriptVersion" class="playground-select">
<option value="latest">
latest
</option>
<option v-for="item in tsVersions" :key="item" :value="item">
{{ item }}
</option>
</select>
</div>
</Teleport>
</div>
</template>
<style scoped lang="scss">
/* 省略先前的样式... */
/* 新增样式 */
.playground-label {
margin-left: 24px;
font-weight: 700;
}
.playground-select {
width: 120px;
margin-left: 8px;
appearance: auto;
border: 1px solid rgb(var(--op-color-bd_base));
}
</style>
更新后的展示效果如下:
组件库的版本切换 - 前期准备
@vue/repl
可以预制更多的第三方模块,只需要在 importMap
中注入依赖对象即可。importMap
的 key 值是依赖名称,value 值是依赖文件,一般填入这个模块发布到 npm 之后的资源 cdn 地址。我们试着在 Vue 官方演练场中 引入 element-plus
来观察这个特性:
在 importMap
中完成注册的模块,就可以在其他文件中引入。因为引入语法是 import
,所以尽量要使用 esm
产物。
不过我们注意到,element-plus/es/index.mjs
是构建环境专用的 esm
产物,在构建过程中将各种第三方依赖外部化了(我们在之前 2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上 做过这种构建),因此 Playground
报了上面的 无法解析依赖模块(dayjs)
的错误。然而把所有依赖都写入 importMap
中是不现实的,这里我们需要把 js 文件换成全量构建产物 element-plus/dist/index.full.min.mjs
。
通过上面实践的启发,我们认识到如果要将组件库集成到演练场,并实现版本切换,还需要做好以下准备:
- 调整之前的构建流程,为主包生成全量
esm
产物。 - 至少发布一个版本到
npm
,获得公网的 cdn 地址。
我们先调整现有的构建流程,对 4. 定制组件库的打包体系 中产物格式、名称相关的代码进行调整,使得组件库主包 @openxui/ui
在全量构建时,能够额外生成一份 esm
产物:
// packages/build/src/generateConfig/index.ts
// ...
/** 获取 build.lib 产物相关配置 */
export function getLib(
packageJson: PackageJson = {},
options: GenerateConfigOptions = {},
): Pick<BuildOptions, 'lib' | 'minify' | 'sourcemap' | 'outDir' | 'emptyOutDir'> {
// ...
const libOptions: LibraryOptions = {
// ...
- // 全量构建只生产 umd 产物
- formats: mode === 'package' ? ['es', 'umd'] : ['umd'],
+ formats: ['es', 'umd'],
// ...
};
// ...
}
/** 获取产物文件名称 */
-export function getOutFileName(fileName: string, format: LibraryFormats, buildMode: GenerateConfigOptions['mode'] = 'package') {
- const formatName = format as ('es' | 'umd');
- const ext = formatName === 'es' ? '.mjs' : '.umd.js';
- let tail: string;
- // 全量构建时,文件名后缀的区别
- if (buildMode === 'full') {
- tail = '.full.js';
- } else if (buildMode === 'full-min') {
- tail = '.full.min.js';
- } else {
- tail = ext;
- }
- return `${fileName}${tail}`;
-}
+export function getOutFileName(fileName: string, format: LibraryFormats, buildMode: GenerateConfigOptions['mode'] = 'package') {
+ const formatName = format as ('es' | 'umd');
+ const ext = formatName === 'es' ? '.mjs' : '.umd.js';
+ let tail = '';
+ // 全量构建时,文件名后缀的区别
+ if (buildMode === 'full') {
+ tail += '.full';
+ } else if (buildMode === 'full-min') {
+ tail += '.full.min';
+ }
+ tail += ext;
+ return `${fileName}${tail}`;
+}
// ...
调整完毕后,执行全量构建命令 pnpm run build:ui
,组件库主包 @openxui/ui
生成了新的全量 esm
产物:
我们再给各个组件包的 package.json
增加发布配置,通过发布命令将组件上传到 npm。
// packages/ui/package.json
{
// ...
+ "publishConfig": {
+ "registry": "https://registry.npmjs.org",
+ "access": "public"
+ },
}
# 发布各个组件包
pnpm --filter "./packages/**" publish
发布完成后,我们就获得了组件库的 CDN 资源:
- 全量 js 文件:
https://fastly.jsdelivr.net/npm/@openxui/ui@latest/dist/openxui-ui.full.min.mjs
- 全量样式文件:
https://fastly.jsdelivr.net/npm/@openxui/ui@latest/dist/style/index.css
修改中间的版本号(将 latest
修改为其他),我们就可以切换成任意发布版本的组件库资源。
组件库的版本切换 - 正式实现
做好了准备,我们可以着手实现 UI 组件库的版本切换了。思路大体上与之前实现 vue
、TypeScript
的切换类似:
<!-- docs/.vitepress/components/Playground.vue -->
<script setup lang="ts">
import {
ref,
reactive,
watch,
watchEffect,
onMounted,
} from 'vue';
import { Repl, ReplStore } from '@vue/repl';
import '@vue/repl/style.css';
// 省略之前的代码...
// 新增部分
const uiVersion = ref('latest');
const uiVersions = ref<string[]>([]);
/** 获取所有的组件库版本 */
async function fetchUiVersions() {
const res = await fetch('https://data.jsdelivr.com/v1/package/npm/@openxui/ui');
const { versions } = (await res.json()) as { versions: string[] };
uiVersions.value = versions;
}
fetchUiVersions();
watch(uiVersion, (v) => {
setUiVersion(v);
}, { immediate: true });
/** 设置组件库的版本 */
function setUiVersion(version: string) {
// 加载组件库的全量 js 资源
store.setImportMap({
imports: {
'@openxui/ui': `https://fastly.jsdelivr.net/npm/@openxui/ui@${version}/dist/openxui-ui.full.min.mjs`,
},
});
// 加载组件库的全量样式
previewOptions.headHTML = `<link rel="stylesheet" href="https://fastly.jsdelivr.net/npm/@openxui/ui@${version}/dist/style/index.css">`;
}
<template>
<div v-if="isMounted">
<!-- 省略之前的代码... -->
<Teleport to=".VPNavBarSearch">
<div class="flex items-center text-14px">
<!-- 新增模板部分 -->
<label class="playground-label">OpenxUI: </label>
<select v-model="uiVersion" class="playground-select">
<option value="latest">
latest
</option>
<option v-for="item in uiVersions" :key="item" :value="item">
{{ item }}
</option>
</select>
<!-- 省略之前的代码... -->
</div>
</Teleport>
</div>
</template>
<style scoped lang="scss">
/* 省略先前的样式... */
</style>
修改并保存后,我们试着在演练场里写下这样的代码,检查组件是否被成功引入,版本能否正常切换:
<script setup>
import { ref } from 'vue'
import { Input, Button } from '@openxui/ui'
const msg = ref('Hello World!')
</script>
<template>
<h1>{{ msg }}</h1>
<p><Button>11111</Button></p>
<p><Input /></p>
<input v-model="msg">
</template>
值得注意的是,在演练场中我们居然获得了组件完整的类型声明。
打开控制台观察网络请求,我们发现 @vue/repl
也能够根据 importMap
引入的资源,自动寻找到了对应的 d.ts
文件,这正是因为我们在先前的构建(参考:2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下)过程中,输出了类型声明产物,并且正确提供了 package.json
的 types
字段。(参考:1. 基于 pnpm 搭建 monorepo 工程目录结构)
用例展示跳转到演练场
先前我们实现用例展示功能时(6. 建立带有 Demo 示例功能的文档网站 - 上),提供了一个跳转到演练场,并演示当前用例代码的按钮。但是当时演练场还没有集成,我们给跳转方法留了空。现在有了演练场,我们应该补齐这个能力。
@vue/repl
自身实现了对代码分享的支持,其原理如下:
- 用户将需要分享的代码按照
@vue/repl
的规则组合成对象,将这个对象转为 JSON 字符串后,序列化为 base64 编码,把它拼接在演练场 url 的 hash 中。 - 演练场加载时,发现 hash 参数有内容,将其传给
@vue/repl
的状态管理 store。 @vue/repl
的内部实现了对 base64 串的反序列化,得到了待分享的代码对象,将其同步到组件状态。
第一步,我们要修改演练场 Playground
组件:一方面读取 url 中的 hash 参数传给 @vue/repl
的 store,另一方面也要在演练场的内容发生变化时,同步更改当前 url 中的 hash 参数,使得用户任何时候都能通过复制 url 分享当前正在编辑的代码。
<!-- docs/.vitepress/components/Playground.vue -->
<script setup lang="ts">
import {
ref,
watch,
reactive,
onMounted,
} from 'vue';
import { Repl, ReplStore } from '@vue/repl';
import '@vue/repl/style.css';
// repl组件需要store管理状态
const store = new ReplStore({
+ serializedState: window.location.hash.slice(1),
});
// @vue/repl 的内容变化时,及时同步到 url 参数中
+watchEffect(() => window.history.replaceState({}, '', store.serialize()));
// 省略其他代码...
</script>
第二步,我们要拓展先前实现的 Demo
组件,将用例源码转换成 @vue/repl
需要的 Base64 串,在跳转到演练场时传入 url 的 hash 参数中。
<!-- docs/.vitepress/components/Demo.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = withDefaults(defineProps<{
/** 用例源码 */
source?: string;
}>(), {
source: '',
});
const MAIN_FILE_NAME = 'App.vue';
// 将用例源码按照 Playground 的规则转换为 Base64 编码
const sourceHash = computed(() => {
const originCode = {
[MAIN_FILE_NAME]: decodeURIComponent(props.source),
};
return btoa(unescape(encodeURIComponent(JSON.stringify(originCode))));
});
// 跳转到 Playground,将 Base64 编码作为 hash 参数,Playground 页面就能展示对应的用例源码
function toPlayground() {
window.open(
`${window.location.origin}/playground.html#${sourceHash.value}`,
'_blank',
);
}
const isCodeShow = ref(false);
</script>
<template>
<div class="demo">
<div class="demo-render">
<slot name="demo" />
</div>
<div class="demo-operators">
<i class="i-op-code-screen" title="在 Playground 中编辑" @click="toPlayground" />
<i class="i-op-code" title="查看源代码" @click="isCodeShow = !isCodeShow" />
</div>
<div v-if="isCodeShow" class="demo-code">
<slot name="code" />
<div class="pb-16px text-center" @click="isCodeShow = false">
<a href="javascript:;" class="cursor-pointer c-info! no-underline!">隐藏源代码</a>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.demo {
border: 1px solid rgb(var(--op-color-bd_light));
border-radius: 4px;
}
.demo-render {
padding: 20px;
}
.demo-operators {
display: flex;
justify-content: flex-end;
padding: 16px;
font-size: 18px;
color: rgb(var(--op-color-secondary));
border-top: 1px solid rgb(var(--op-color-bd_light));
i {
cursor: pointer;
+ i {
margin-left: 16px;
}
}
}
</style>
由于我们在前两步中给 <Demo>
组件拓展了一个参数:props.source
,用于接收用例源码内容,所以我们还要同步修改 markdown-it
的用例渲染插件:
// docs/.vitepress/plugins/mdDemoPlugin.ts
// ...
export function mdDemoPlugin(md: MarkdownIt) {
md.use(mdContainer, 'demo', <ContainerOpts>{
validate(params) {
return Boolean(params.trim().match(/^demo\s*(.*)$/));
},
render(tokens, idx, options, env, self) {
const token = tokens[idx];
// 不考虑 :::demo 的嵌套情况,碰到深层嵌套直接放弃渲染
if (token.level > 0) return '';
// :::demo 开启标签时触发
if (token.nesting === 1) {
// ...
// 拼接 <Demo> 组件的使用代码
- const txt = `<Demo>
+ const txt = `<Demo source="${encodeURIComponent(sourceCode)}">
<template #demo><${componentName} /></template>
<template #code>${sourceCodeHtml}</template>
`;
return txt;
}
// 读取到 :::demo 闭合的 Token 时,输出闭合 </Demo> 标签
return '</Demo>';
},
});
}
// ...
完成全部修改后,我们刷新开发环境,点击 Button 组件说明中的用例跳转按钮,查看效果:
这里触发了一个我们先前实现主题切换功能时的自定义错误(参考:5. 设计组件库的样式方案 - 下)。我们应该在使用切换主题能力之前,预加载 Theme
模块。
解决组件库的预加载问题
解决预加载问题的关键在于,要将 @vue/repl
的主文件切换为 AppWrapper.vue
,在 AppWrapper.vue
中获取 app 的实例,加载 Theme
模块。AppWrapper.vue
作为一个隐藏的文件不进行展示。
// docs/.vitepress/components/Playground.ts
export const APP_WRAPPER_CODE = `
<script setup lang="ts">
import { getCurrentInstance } from 'vue';
import { Theme } from '@openxui/ui';
import App from './App.vue';
const instance = getCurrentInstance();
instance?.appContext.app.use(Theme);
</script>
<template>
<App />
</template>
`;
<!-- docs/.vitepress/components/Playground.vue -->
<script setup lang="ts">
import {
ref,
reactive,
watch,
watchEffect,
onMounted,
} from 'vue';
import { Repl, ReplStore, File } from '@vue/repl';
import '@vue/repl/style.css';
import { APP_WRAPPER_CODE } from './Playground';
// 省略之前的代码...
// 新增部分
store.state.mainFile = 'src/AppWrapper.vue';
store.addFile(new File('src/AppWrapper.vue', APP_WRAPPER_CODE, true));
</script>
<!-- 省略 template 和 style,无改动... -->
另外,由于预处理器 sass
和 unocss
都是构建时依赖,无法集成到 @vue/repl
中。因此我们的用例需要排除相关的写法,并且在编写后续用例时,也要注意避免使用这些构建时的能力。
<!-- docs/demo/button/demo1.vue -->
// 省略
</script>
<template>
<div>
<!-- 省略 ... -->
- <div>
- <i class="i-op-alert inline-block text-100px c-primary" />
- <i class="i-op-alert-marked inline-block text-60px c-success" />
- </div>
<Input />
</div>
</template>
-<style lang="scss" scoped>
+<style scoped>
-.btns {
- :deep(.op-button) {
- margin-bottom: 10px;
-
- &:not(:first-child) {
- margin-left: 10px;
- }
- }
-}
+.deep(.op-button:not(:first-child)) {
+ margin-left: 10px;
+}
+:deep(.op-button) {
+ margin-bottom: 10px;
+}
</style>
一切就绪后,我们来看一下最终的效果:
结尾与资料汇总
本章涉及到的相关资料汇总如下:
官网与文档:
TypeDoc插件:typedoc-plugin-markdown
分享博文:
Typescript 的 interface 自动化生成 API 文档