使用vitepress开发组件文档
开发好一个组件库后,我们还需要写对应的文档,组件库通常不是一个人使用的,就算是一个人使用的,长时间不接触后,使用方法也会淡忘,此时再重新通过代码查看使用逻辑也是一件很繁琐的事,所以,针对组件库写一个对应的文档是很有必要的一件事。
此处我们使用vitepress来开发Vue3.x的组件库文档。
简单的使用
- 新建文件夹docs
- 在docs下初始化项目
pnpm init
// docs/package.json
{
...
"scripts": {
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:serve": "vitepress serve docs"
},
...
}
- 下载vitepress
pnpm add vitepress -D
- 新建 docs/index.md
- 使用vitepress启动项目
pnpm run docs:dev
- 打开服务地址 http://localhost:3000 会加载 docs/index.md
- 我们可以在docs文件夹下新建更多的md文件,然后通过链接http://localhost:3000/文件名去访问对应的md文件![image]
我们发现,这种最简单的方式,需要我们手动更新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官方文档看看:
可以看到,上面两张图片的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
按照上面的代码和文件目录结构配置后,我们得到了下面的页面::
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之后,我们达到的效果:
接下来我们只需要改改navbar样式,改改sidebar对应的json,然后在合理的位置新建md文件就可以达到一个简单的文档了。但是接下来你可能要问:内置组件会将一个完整的md渲染到页面上,那我如何将Vue代码穿插在md文件之中呢?就像element-plus一样,我们需要渲染组件达到的效果。
正如上面说的,一个url对应的就是一个目录结构下的md文件,并且可以通过组件去渲染,我们无法在一个url上通过Content渲染两个不同的md文件,所以也就不存在穿插,vitepress可以使我们在md文件里使用Vue组件。
在Markdown中使用Vue
vitepress会将md内容解析成HTML内容,然后将该HTML内容当作Vue单页组件去执行,简单的说就是生成的HTML就是我们平时写的Vue页面的 Template内容。
在md中我们可以:
- 使用模版
- 使用指令
- 内置支持.scss,.sass,.less,.styl,styuls预处理器(处理器本身需要被下载)
- 使用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>
- 使用组件(像上面一样,通过script标签和import单独引入或者全局注册)
// 注册全局组件
import DefaultTheme from 'vitepress/theme'
export default {
...DefaultTheme,
enhanceApp({ app }) {
app.component('VueClickAwayExample', VueClickAwayExample)
}
}
- 使用内置的组件等
下面是一些简单的使用:
// 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的官方文档
其中红色区域都是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-it
和markdown-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个:
- description:解析的是紧接着:::demo的文本内容
- sourceFile:解析的是当前开始标记的后面第二个tokens内容(具体可以由自己的md格式决定)
- source:通过
fs.readFileSync
读取sourceFile的文件内容 - 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解析后:
- description:
Use
type,
plain,
roundand
circleto define Button's style.
- sourceFile:button/basic
- source:docRoot/examples/button/basic.vue
- hoistedTags: push(` `)
至此,我们约定了目录结构,约定了md文件中关于Vue组件调用的书写格式,现在可以仅用少量的配置和规范的方式去书写我们的文档。了解以上的内容,我们已经可以通过vitepress写一个和大型库一样的组件文档了。
这是系列从Element-plus了解如何开发一个组件的第三部分,前两章分别是:
转载自:https://juejin.cn/post/7128769888948060191