likes
comments
collection
share

使用vitepress开发组件文档

作者站长头像
站长
· 阅读数 30

开发好一个组件库后,我们还需要写对应的文档,组件库通常不是一个人使用的,就算是一个人使用的,长时间不接触后,使用方法也会淡忘,此时再重新通过代码查看使用逻辑也是一件很繁琐的事,所以,针对组件库写一个对应的文档是很有必要的一件事。

此处我们使用vitepress来开发Vue3.x的组件库文档。

简单的使用

  1. 新建文件夹docs
  2. 在docs下初始化项目 pnpm init
// docs/package.json
{
  ...
  "scripts": {
    "docs:dev": "vitepress dev docs",
    "docs:build": "vitepress build docs",
    "docs:serve": "vitepress serve docs"
  },
  ...
}
  1. 下载vitepress pnpm add vitepress -D
  2. 新建 docs/index.md
  3. 使用vitepress启动项目 pnpm run docs:dev
  4. 打开服务地址 http://localhost:3000 会加载 docs/index.md
  5. 我们可以在docs文件夹下新建更多的md文件,然后通过链接http://localhost:3000/文件名去访问对应的md文件![image] 使用vitepress开发组件文档

我们发现,这种最简单的方式,需要我们手动更新url去切换md文件,有没有通过按钮或者文本去映射url的方式,实现类似路由的效果呢?

config配置

我们可以通过更改.vitepress/config.js去做一些配置

先来看个简单的配置,我们可以配置一个sidebar:

sidebar
import { sidebars, } from './config/sidebars'

export default {
    title: 'SeanwangUI',
    description: 'A Vue.js UI library.',
    themeConfig: {
        sidebar: sidebars
    }
}

themeConfig.sidebar这个选项有两种配置方式,一种是单个sidebar,另一种是多sidebar。单个sidebar属性对应的值是一个数组对象,多个sidebar属性对应的值是一个对象。具体的配置可以通过上面的链接查看。可能有人不理解什么是单个和多个sidebar,我们可以去element-plus官方文档看看:

使用vitepress开发组件文档

使用vitepress开发组件文档

可以看到,上面两张图片的sidebar是不一样的,这里通过切换navbar上的标签,改变了sidebar,这就是多sidebar的情况。

为了后续方便,我们尽管只有一个sidebar,也用多sidebar的约定去配置。

export default {
  themeConfig: {
    sidebar: {
      // This sidebar gets displayed when user is
      // under `guide` directory.
      '/guide/': [
        {
          text: 'Guide',
          items: [
            // This shows `/guide/index.md` page.
            { text: 'Index', link: '/guide/' }, // /guide/index.md
            { text: 'One', link: '/guide/one' }, // /guide/one.md
            { text: 'Two', link: '/guide/two' } // /guide/two.md
          ]
        }
      ],
      '/component/': [
        {
          text: 'Component',
          items: [
            { text: 'Index', link: '/component/' },
            { text: 'Button', link: '/component/button' }, 
          ]
        }
      ],
    }
  }
}

这个配置有两个约定的地方,一个是我们的文件结构中必须包含guide和component这两个文件夹(sidebar的key),且这两个文件夹管理每个sidebar下映射的页面,也就是必须存在下面的目录结构:

.
├─ guide/
│  ├─ index.md
│  ├─ one.md
│  └─ two.md
└─ component/
   ├─ index.md
   ├─ three.md
   └─ four.md

这个目录结构的深度没有规定,但是需要注意的是link对应的路径是从根目录开始的。例如上面的配置中link是/component/button,那么component文件夹的位置就应该在根目录,如果link是en-US/component/button,那么component文件夹的位置就应该再en-US/下面。

再优化一下代码,我们发现整个sidebar的配置中,只有link是经常变化的(比如上i18n之后,可能需要在link中区分en-US、zh-CN等),于是我们把配置的最基础的部分分离成一个单独的json文件,再用函数来拼接对应的link,于是就有了:

// docs/.vitepress/json/page/component.json
{
  "basic": {
    "text": "Basic",
    "items": [
      {
        "link": "/button",
        "text": "Button"
      }
    ]
  }
}

// docs/.vitepress/config/sidebars.ts
import componentLocale from '../json/page/component.json'

const getSidebars = () => {
    return {
        '/component/': getComponentsSideBar(),
    }
}
function getComponentsSideBar() {
    return Object.values(componentLocale).map((item) => mapPrefix(item, '/component'))
}
type Item = {
    text: string
    items?: Item[]
    link?: string
}
function mapPrefix(item: Item, prefix = '') {
    if (item.items && item.items.length > 0) {
        return {
            ...item,
            items: item.items.map((child) => mapPrefix(child, prefix)),
        }
    }
    return {
        ...item,
        link: `${prefix}${item.link}`
    }
}
export const sidebars = getSidebars()

// docs/.vitepress/config.js
import { sidebars, } from './config/sidebars'
export default {
    title: 'SeanwangUI',
    description: 'A Vue.js UI library.',
    themeConfig: {
        sidebar: sidebars
    }
}
docs
└─ component/
   ├─ index.md
   ├─ button.md

按照上面的代码和文件目录结构配置后,我们得到了下面的页面::

使用vitepress开发组件文档

title和sidebar是我们通过配置config文件得到的,右边的部分是对应的link所映射的md文件内容。

自定义theme

单纯通过config配置文件,我们用的是默认的theme style,如果默认的布局样式足够使用,那么我们无需做接下来的步骤,如果我们需要添加一些自定义的布局结构,我们也可以自定义theme。

添加.vitepress/theme/index.ts

// .vitepress/theme/index.js
import ElementPlus from 'element-plus'
import '@element-plus/theme-chalk'
import VPApp, { globals } from '../vitepress'

export default {
  // root component to wrap each page
  Layout: VPApp,
  // this is a Vue 3 functional component
  NotFound: () => 'custom 404',
  enhanceApp({ app }) {
    app.use(ElementPlus)
    globals.forEach(([name, Comp]) => {
      app.component(name, Comp)
    })
  }
}

添加了这个文件后,就不会生成默认的主题了,而是会加载.vitepress/theme/index.js里的Layout页面。

需要注意的是,因为我们的文档需要使用组件去写案例,所以需要引用组件,原文中的elementplus是通过npm下载的线上的版本,如果我们没有将自己的包发布到npm,也可以使用monoprepo去引用本地的项目。

Layout页面就跟我们写个Vue页面是一样的。

// vp-app.vue
<template>
    <div class="App">
        <VPNav />
        <VPSide />
        <VPContent></VPContent>
    </div>
</template>
<script lang="ts" setup>
import VPNav from './vp-nav.vue'
import VPSide from './vp-sidebar.vue'
import VPContent from './vp-content.vue'
</script>

我们可以通过vitepress内置的方法(如useData等)去访问我们的config配置的内容,或者一些有用的数据。例如渲染sidebar时,我们需要获取配置中的sidebar选项内容:

// vp-sidebar.vue
<template>
    <aside class="sidebar open">
        <div class="sidebar-groups">
            <section v-for="(item, key) of sidebars" :key="key" class="sidebar-group">
                <p class="sidebar-group__title">
                {{ item.text }}
                </p>
                <VPSidebarLink
                v-for="(child, childKey) in item.items"
                :key="childKey"
                :item="child"
                />
            </section>
        </div>
    </aside>
</template>
<script lang="ts" setup>
import { useSidebar, } from '../composables/useSidebar'
import VPSidebarLink from './sidebar/vp-sidebar-link.vue'
import {computed} from 'vue'
import { useData, useRoute } from 'vitepress'

const route = useRoute()
const { site, page } = useData()

const sidebars = computed(() => {
    console.log("relativePath", route.data.relativePath)
    const sidebars = getSidebarConfig(
        site.value.themeConfig.sidebar,
        route.data.relativePath,
        'en-US'
    )
    return sidebars
})
function getSidebarConfig(sidebar: Sidebar, path: string, lang: string) {
    if (sidebar === false || Array.isArray(sidebar) || sidebar === 'auto') {
        return []
    }
    for (const dir in sidebar) {
        if (path.startsWith(`${lang}${dir}`)) {
            return sidebar[dir]
        }
    }
    return []
}
</script>

site的类型如下:

interface SiteData<ThemeConfig = any> {
  base: string

  /**
   * Language of the site as it should be set on the `html` element.
   *
   * @example `en-US`, `zh-CN`
   */
  lang: string

  title: string
  titleTemplate?: string | boolean
  description: string
  head: HeadConfig[]
  appearance: boolean
  themeConfig: ThemeConfig
  scrollOffset: number | string
  locales: Record<string, LocaleConfig>

  /**
   * Available locales for the site when it has defined `locales` in its
   * `themeConfig`. This object is otherwise empty. Keys are paths like `/` or
   * `/zh/`.
   */
  langs: Record<
    string,
    {
      /**
       * Lang attribute as set on the `<html>` element.
       * @example `en-US`, `zh-CN`
       */
      lang: string
      /**
       * Label to display in the language menu.
       * @example `English`, `简体中文`
       */
      label: string
    }
  >
}

还需要注意的是vitepress内置的组件,这个组件渲染的内容是当前url映射的md文件内容。

// vp-content.vue
<template>
    <main :class="{ 'page-content': true, 'has-sidebar': true }">
        <div class="doc-content-wrapper">
            <div class="doc-content-container">
                <Content ref="content" class="doc-content"/>
            </div>
        </div>
    </main>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useData } from 'vitepress'

const { page } = useData()
const content = ref<{ $el: HTMLElement }>()
</script>

通过以上自定义的theme之后,我们达到的效果:

使用vitepress开发组件文档

接下来我们只需要改改navbar样式,改改sidebar对应的json,然后在合理的位置新建md文件就可以达到一个简单的文档了。但是接下来你可能要问:内置组件会将一个完整的md渲染到页面上,那我如何将Vue代码穿插在md文件之中呢?就像element-plus一样,我们需要渲染组件达到的效果。

正如上面说的,一个url对应的就是一个目录结构下的md文件,并且可以通过组件去渲染,我们无法在一个url上通过Content渲染两个不同的md文件,所以也就不存在穿插,vitepress可以使我们在md文件里使用Vue组件。

在Markdown中使用Vue

如何在md中使用Vue组件?

vitepress会将md内容解析成HTML内容,然后将该HTML内容当作Vue单页组件去执行,简单的说就是生成的HTML就是我们平时写的Vue页面的 Template内容。

在md中我们可以:

  1. 使用模版
  2. 使用指令
  3. 内置支持.scss,.sass,.less,.styl,styuls预处理器(处理器本身需要被下载)
  4. 使用script和style标签,这两个标签会在解析成Vue单页的时候被提升到顶部,相当于在Vue页面中
// demo.md
<script setup>
import CustomComponent from '../components/CustomComponent.vue'
</script>
<style lang="scss">
.title {
    font-size: 24px;
}
</style>
# text 
<Tag/> 
<div class="title">demo</div>

也就是说上面的md和下面的.vue文件是一样的

// demo.vue
<template>
    <h1>text</h1>
    <CustomComponent />
    <div class="title">demo</div>
</template>
<script setup>
import CustomComponent from '../components/CustomComponent.vue'
</script>
<style lang="scss">
.title {
    font-size: 24px;
}
</style>
  1. 使用组件(像上面一样,通过script标签和import单独引入或者全局注册)
// 注册全局组件
import DefaultTheme from 'vitepress/theme'
export default {
  ...DefaultTheme,
  enhanceApp({ app }) {
    app.component('VueClickAwayExample', VueClickAwayExample)
  }
}
  1. 使用内置的组件等

下面是一些简单的使用:

// element-plus/docs/en-US/component/border.md
// script标签 + 引用组件
<script setup>
import { WxButton } from 'seanwang-ui/es'
</script>
// style标签 + scss预处理
<style lang="scss">
    .title {
        font-size: 24px;
    }
</style>
### Border
// 使用组件
<WxButton type="primary">Border</WxButton>
// 使用指令 + 模版
<div v-for="i in 3" class="title">{{i}}</div>

我们看看element-plus的官方文档

使用vitepress开发组件文档

其中红色区域都是md完成的,而蓝色区域则是通过在md中使用组件完成的,我们只需要将蓝色区域用.vue写好,然后在md中引入就可以了。

例如上图中的内容我们可以这样写:

// element-plus-dev/docs/en-US/component/button.md
## 基础用法

使用 `type`, `plain`, `round``circle` 来定义按钮的样式
<script setup>
    import BasicButton '../../examples/button/basic.vue'
</script>
<BasicButton />
// element-plus-dev/docs/examples/button/basic.vue
<template>
  <el-row class="mb-4">
    <el-button>Default</el-button>
    <el-button type="primary">Primary</el-button>
    <el-button type="success">Success</el-button>
    <el-button type="info">Info</el-button>
    <el-button type="warning">Warning</el-button>
    <el-button type="danger">Danger</el-button>
    <el-button>中文</el-button>
  </el-row>
</template>

<script lang="ts" setup>
import {
  Check,
  Delete,
  Edit,
  Message,
  Search,
  Star,
} from '@element-plus/icons-vue'
</script>

自定义Markdown解析

但是每个模块都需要单独的引入,这样显得很繁琐,所以可以写一个通用组件,通过解析md文件内容去引用和渲染不同的组件,这就是下面的vp-demo组件和vp-example组件。

// element-plus-dev/docs/.vitepress/vitepress/components/vp-demo.vue
<template>
  <ClientOnly>
    <Example :file="path" :demo="formatPathDemos[path]" />
  </ClientOnly>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, toRef } from 'vue'

import Example from './vp-example.vue'

const props = defineProps<{
  source: string
  path: string
  css?: string
  cssPreProcessor?: string
  js?: string
  html?: string
  demos: object
  rawSource: string
  description?: string
}>()

const formatPathDemos = computed(() => {
  const demos = {}

  Object.keys(props.demos).forEach((key) => {
    demos[key.replace('../../examples/', '').replace('.vue', '')] =
      props.demos[key].default
  })

  return demos
})
</script>


// element-plus-dev/docs/.vitepress/vitepress/components/demo/vp-example.vue
<script setup lang="ts">
defineProps({
  file: {
    type: String,
    required: true,
  },
  demo: {
    type: Object,
    required: true,
  },
})
</script>

<template>
  <div class="example-showcase">
    <ClientOnly>
      <component :is="demo" v-if="demo" v-bind="$attrs" />
    </ClientOnly>
  </div>
</template>

抛开其他代码的实现,核心部分就是<component :is="demo" v-if="demo" v-bind="$attrs" />

我们可以看到,md中渲染vue组件的核心就是使用vue内置组件component,我们再顺着往上查找动态属性is的值demo是什么?

<Example :file="path" :demo="formatPathDemos[path]" />
const formatPathDemos = computed(() => {
  const demos = {}

  Object.keys(props.demos).forEach((key) => {
    demos[key.replace('../../examples/', '').replace('.vue', '')] =
      props.demos[key].default
  })

  return demos
})

从上面可以看到Exanple组件的demo是formatPathDemos[path]的返回值,这个计算属性是根据props.demos去计算的,我们再来看看props.demos是什么?要看这个属性,首先我们得找找vp-demo这个组件是什么时候使用的。

我们得往回看.vitepress的config.ts配置文件。我们看到markdown这个属性:

markdown: {
    config: (md) => mdPlugin(md),
},

这个属性就是用来解析markdown文件的,需要用到markdown-itmarkdown-it-container

我们再来看看mdPlugin的使用:

import path from 'path'
import fs from 'fs'
import MarkdownIt from 'markdown-it'
import mdContainer from 'markdown-it-container'
import { docRoot } from '@element-plus/build'
import { highlight } from '../utils/highlight'
import type Token from 'markdown-it/lib/token'
import type Renderer from 'markdown-it/lib/renderer'

const localMd = MarkdownIt()
const scriptSetupRE = /<\s*script[^>]*\bsetup\b[^>]*/

interface ContainerOpts {
  marker?: string | undefined
  validate?(params: string): boolean
  render?(
    tokens: Token[],
    index: number,
    options: any,
    env: any,
    self: Renderer
  ): string
}

export const mdPlugin = (md: MarkdownIt) => {
  md.use(mdContainer, 'demo', {
    validate(params) {
      return !!params.trim().match(/^demo\s*(.*)$/)
    },

    render(tokens, idx) {
      const data = (md as any).__data
      const hoistedTags: string[] = data.hoistedTags || (data.hoistedTags = [])

      const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/)
      if (tokens[idx].nesting === 1 /* means the tag is opening */) {
        const description = m && m.length > 1 ? m[1] : ''
        const sourceFileToken = tokens[idx + 2]
        let source = ''
        const sourceFile = sourceFileToken.children?.[0].content ?? ''

        if (sourceFileToken.type === 'inline') {
          source = fs.readFileSync(
            path.resolve(docRoot, 'examples', `${sourceFile}.vue`),
            'utf-8'
          )
          const existingScriptIndex = hoistedTags.findIndex((tag) =>
            scriptSetupRE.test(tag)
          )
          if (existingScriptIndex === -1) {
            hoistedTags.push(`
    <script setup>
    const demos = import.meta.globEager('../../examples/${
      sourceFile.split('/')[0]
    }/*.vue')
    </script>`)
          }
        }
        if (!source) throw new Error(`Incorrect source file: ${sourceFile}`)

        return `<Demo :demos="demos" source="${encodeURIComponent(
          highlight(source, 'vue')
        )}" path="${sourceFile}" raw-source="${encodeURIComponent(
          source
        )}" description="${encodeURIComponent(localMd.render(description))}">`
      } else {
        return '</Demo>'
      }
    },
  } as ContainerOpts)
}

解释一下这个函数:

validate选项是为了验证开始标记(:::)的文本内容,满足validate的tokens会调用render函数。从validate的函数实现可以看到,只筛选出以demo起始的开始标记,也就是:::demo

render选项有两个参数,tokens和idx,tokens是md文件的所有节点组成的数组对象,这个节点类似与Vue的抽象节点,只不过最后形成的是一个数组,而idx则是满足validate的开始结束标记所在tokens的下标。也就是说md文件中有一个:::demo :::,那么这里render函数就会调用两次(开始结束各一次)。两次render函数的tokens的值是一样的,因为都是整个md的所有tokens。

我们来看render函数的具体实现:

整个函数主要是有if else块组成的,if中判断当前标记是开始标记,那么返回<Demo >开始标记以及传给Demo组件的属性,否则是结束标记,那么返回</Demo>

if中的主要组成部分有4个:

  1. description:解析的是紧接着:::demo的文本内容
  2. sourceFile:解析的是当前开始标记的后面第二个tokens内容(具体可以由自己的md格式决定)
  3. source:通过fs.readFileSync读取sourceFile的文件内容
  4. hoistedTags:
hoistedTags.push(`
    <script setup>
    const demos = import.meta.globEager('../../examples/${sourceFile.split('/')[0]
              }/*.vue')
    </script>`)

最后返回的内容是:

return `<Demo :demos="demos" source="${encodeURIComponent(
      highlight(source, 'vue')
    )}" path="${sourceFile}" raw-source="${encodeURIComponent(
      source
    )}" description="${encodeURIComponent(localMd.render(description))}">`

其中有个问题,Demo组件的demos属性的值是从哪里来的?我们可以看到,代码中只是将<script setup>标签内容添加到hoistedTags数组中,并没有添加到md文件的渲染结果中,如果将<script setup>一起放到return中,那就没有任何问题了。这时候我们需要看看hoistedTags是什么?

const data = (md as any).__data
const hoistedTags: string[] = data.hoistedTags || (data.hoistedTags = [])

md.__data.hoistedTags我们可以到vitepress的具体实现中找到,它会将这个数组中的内容渲染到结果中:

const vueSrc = genPageDataCode(data.hoistedTags || [], pageData).join("\n") + `
<template><div>${html}</div></template>`;

function genPageDataCode(tags, data) {
  const code = `
export const __pageData = JSON.parse(${JSON.stringify(JSON.stringify(data))})`;
  const existingScriptIndex = tags.findIndex((tag) => {
    return scriptRE.test(tag) && !scriptSetupRE.test(tag) && !scriptClientRE$1.test(tag);
  });
  const isUsingTS = tags.findIndex((tag) => scriptLangTsRE.test(tag)) > -1;
  if (existingScriptIndex > -1) {
    const tagSrc = tags[existingScriptIndex];
    const hasDefaultExport = defaultExportRE.test(tagSrc) || namedDefaultExportRE.test(tagSrc);
    tags[existingScriptIndex] = tagSrc.replace(scriptRE, code + (hasDefaultExport ? `` : `
export default {name:'${data.relativePath}'}`) + `<\/script>`);
  } else {
    tags.unshift(`<script ${isUsingTS ? 'lang="ts"' : ""}>${code}
export default {name:'${data.relativePath}'}<\/script>`);
  }
  return tags;
}

所以<Demo :demos="demos">中的demos就是通过import.meta.globEager导入的examples文件夹下的Vue组件内容。

我们来看一下完整的md文件示例:

### Button

:::demo Use `type`, `plain`, `round` and `circle` to define Button's style.

button/basic

:::

例如上面的md文件内容,mdPlugin解析后:

  1. description:Use type, plain, roundandcircle to define Button's style.
  2. sourceFile:button/basic
  3. source:docRoot/examples/button/basic.vue
  4. hoistedTags: push(` `)

至此,我们约定了目录结构,约定了md文件中关于Vue组件调用的书写格式,现在可以仅用少量的配置和规范的方式去书写我们的文档。了解以上的内容,我们已经可以通过vitepress写一个和大型库一样的组件文档了。

这是系列从Element-plus了解如何开发一个组件的第三部分,前两章分别是:

  1. 从ElementPlus了解如何从头开发一个组件库(一)
  2. 从ElementPlus了解如何开发一个组件库(二)