【从 0 到 1 搭建 Vue 组件库框架】6. 建立带有 Demo 示例功能的文档网站 - 下

2,111 阅读6分钟

导航

导航:0. 导论

上一章节: 6. 建立带有 Demo 示例功能的文档网站 - 上

(编写中)下一章节:7. 接入单元测试与集成测试

本章节示例代码仓:Github

本章节文档展示效果:gkn1234.github.io/openx-ui/

紧接着上篇,我们在下篇实现组件库文档的另外两个核心需求:API 说明代码演练场

组件 API 文档的自动生成

API 文档建设方案选型

经过调研,在实现 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 {}

了解规则后,我们来为组件 ButtonInput 来准备示例代码。注意,下面的示例代码只是为了满足演示需要,并不是组件实际应有的业务逻辑。

我们会充分用到 <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 产物建立起联系。

VitePressconfig.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.jsonscripts 字段,加入 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"
          }
        ]
      }
    ]
  }
]

预览效果如下:

vitepress-api-doc-test.gif

vitepress-api-doc-test2.gif

至此,我们就借助 TypeDoc 实现了比较完善的组件库 API 文档自动生成的机制,只需要我们后续按照规定好的 props.ts 的方式编写每个组件的接口声明即可。除了 TypeScript 接口声明与注释之外,以后我们无需再为了 API 文档写任何多余的文本。

接入代码演练场

我们的文档还剩下一个最后一个核心功能——代码演练场。在线的演练场一直以来都不是一个容易实现的功能,无论是演示代码沙箱环境的隔离,还是实现一个体验良好的在线代码编辑器,都非常的复杂。感谢 Vue 生态,为我们提供了 @vue/repl 工具,让我们可以一键将 vue 的代码解释器集成到自己的项目中。目前主流的 Vue 组件库都集成了这个工具,实现了自己的代码演练场。

进一步研究 element-plus 的演练场后,我们可以将整个功能点拆分为三个阶段,依次实现它们:

  • 接入 vue 代码解释器组件 @vue/repl
  • 实现 vueTypeScript、组件库的版本切换功能。
  • 展示特定的用例代码,支持从用例演示直接跳转到对应的演练场。

element-plus-playground3.png

接入 @vue/repl

这里先给大家推荐两篇讲解如何集成 @vue/repl 的文章,本文集成的思路也从中有所借鉴:

Vue组件库设计 | Vue3组件在线交互解释器

搭建 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-playground.png

为了防止后续执行 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'

因此,新增 vueTypeScript 的版本切换功能,我们只需要写好切换版本的 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>

更新后的展示效果如下:

vitepress-playground2.png

组件库的版本切换 - 前期准备

@vue/repl 可以预制更多的第三方模块,只需要在 importMap 中注入依赖对象即可。importMap 的 key 值是依赖名称,value 值是依赖文件,一般填入这个模块发布到 npm 之后的资源 cdn 地址。我们试着在 Vue 官方演练场中 引入 element-plus 来观察这个特性:

playground-import1.png

importMap 中完成注册的模块,就可以在其他文件中引入。因为引入语法是 import,所以尽量要使用 esm 产物。

playground-import2.png

不过我们注意到,element-plus/es/index.mjs 是构建环境专用的 esm 产物,在构建过程中将各种第三方依赖外部化了(我们在之前 2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上 做过这种构建),因此 Playground 报了上面的 无法解析依赖模块(dayjs) 的错误。然而把所有依赖都写入 importMap 中是不现实的,这里我们需要把 js 文件换成全量构建产物 element-plus/dist/index.full.min.mjs

playground-import3.png

playground-import4.png

通过上面实践的启发,我们认识到如果要将组件库集成到演练场,并实现版本切换,还需要做好以下准备:

  • 调整之前的构建流程,为主包生成全量 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 产物:

esm-full-production.png

我们再给各个组件包的 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 组件库的版本切换了。思路大体上与之前实现 vueTypeScript 的切换类似:

<!-- 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>

playground-with-ui.gif

playground-ui-switch.gif

值得注意的是,在演练场中我们居然获得了组件完整的类型声明。

playground-dts.png

打开控制台观察网络请求,我们发现 @vue/repl 也能够根据 importMap 引入的资源,自动寻找到了对应的 d.ts 文件,这正是因为我们在先前的构建(参考:2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下)过程中,输出了类型声明产物,并且正确提供了 package.jsontypes 字段。(参考:1. 基于 pnpm 搭建 monorepo 工程目录结构)

playground-dts2.png

playground-dts3.png

playground-dts4.png

用例展示跳转到演练场

先前我们实现用例展示功能时(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 组件说明中的用例跳转按钮,查看效果:

vitepress-to-playground.png

playground-inject-error.png

这里触发了一个我们先前实现主题切换功能时的自定义错误(参考: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,无改动... -->

另外,由于预处理器 sassunocss 都是构建时依赖,无法集成到 @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>

一切就绪后,我们来看一下最终的效果:

playground-button-full.gif

结尾与资料汇总

本章涉及到的相关资料汇总如下:

官网与文档:

Vue 官方文档

element-plus 组件库

VitePress

ts-morph

TypeDoc

TypeDoc插件:typedoc-plugin-markdown

TypeScript 注释编写规范:TSDoc

tsx

Vue 代码解释器:@vue/repl

代码编辑器:Monaco Editor

分享博文:

Typescript 的 interface 自动化生成 API 文档

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

Vue组件库设计 | Vue3组件在线交互解释器

搭建 OpenTiny 组件库的 Playground 指导手册