编写那么多vue3组件你会写乐意文档吗?(基于vitepress编写的vue3组件文档)

1,915 阅读2分钟

目的

  • 公共组件文档统一管理避免重复造轮子
  • 拓展vitepress支持类似element-plus那样可以查看源码和例子参考如下图:

image.png

点击查看演示例子

如何编写文档?

  • docs/.vitepress/config.ts 配置Nav和Sidebar
  • 引入例子组建 如 import basic from './basic.vue'
  • :::demo [路径]上编写组件路径如:::demo button/basic
  • :::demo [实例] ::: 包裹实例 如:<basic></basic>

完整例子如下

<script setup>
# 引入例子组件
import basic from './basic.vue';
</script>

# 按钮
## 基础用法
# button/basic是docs/component相对路径必填,否则查看不了源码
:::demo button/basic
# 组件例子显示,内部会以slot插入
<basic></basic>
::: 

友情链接

实现原理

  • 编写markdown-plugin拦截:::demo button/basic ::: 中的button/basic路径利fs.readFileSync读取源文件
  • vitepress支持vue组件引入importing-components-in-markdown,利用这个只要传入slot就可显示例子

注意:这个实现方式和element-plus的实现方式是有区别的,element-plus只传需要传路径,我的是传路径和组件,而且写法也有些不一样!!!当然本质原理一样都是编写markdown-plugin

具体实现

项目目录结构

image.png

1.准备工作

  • node 16 +
  • 要有vitepress基础,及简单配置,不懂去vitepress官网

2.关键库

  • vitepress 当前选用1.0.0-alpha.10
  • markdown-it-container 更改markdown必要插件
  • escape-html 格式化代码
  • prismjs 格式化代码

3.拓展markdown-plugin

  • 参考element-plus源码
 // ref https://github.com/vuejs/vitepress/blob/main/src/node/markdown/plugins/highlight.ts
import escapeHtml from 'escape-html'
import prism from 'prismjs'
import path from 'path';
import fs from 'fs';
import mdContainer from 'markdown-it-container';

function wrap(code: string, lang: string): string {
  if (lang === 'text') {
    code = escapeHtml(code)
  }
  return `<pre v-pre style="margin: 0;"><code>${code}</code></pre>`
}
// 语法高亮
const highlight = (str: string, lang: string) => {
  if (!lang) {
    return wrap(str, 'text')
  }
  lang = lang.toLowerCase()
  const rawLang = lang
  if (lang === 'vue' || lang === 'html') {
    lang = 'markup'
  }
  if (lang === 'md') {
    lang = 'markdown'
  }
  if (lang === 'ts') {
    lang = 'typescript'
  }
  if (lang === 'py') {
    lang = 'python'
  }
  if (prism.languages[lang]) {
    const code = prism.highlight(str, prism.languages[lang], lang)
    return wrap(code, rawLang)
  }
  return wrap(str, 'text')
}
// 配置
export const markdownConfig = (md) => {
    md.use(mdContainer, 'demo', {
        validate(params) {
          return !!params.trim().match(/^demo\s*(.*)$/);
        },

        render(tokens, idx) {
          const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/);
          if (tokens[idx].nesting === 1 /* means the tag is opening */) {
            // 取出:::demo 后面的配置,即源码路径
            const sourceFile = m && m.length > 1 ? m[1] : '';
            const sourceFileToken = tokens[idx + 2];
            // 源码文件路径
            const filePath = path.resolve(
              process.cwd(),
              'docs/component',
              `${sourceFile}.vue`
            );
            let source = '';
            if (sourceFileToken.type === 'inline') {
              source = fs.readFileSync(filePath, 'utf-8');
            }
            if (!source)
              throw new Error(`Incorrect source file: ${sourceFile}`);
            return `<Demo source="${encodeURIComponent(
              highlight(source, 'vue')
            )}" raw-source="${encodeURIComponent(
                source
              )}" >`;
          } else {
            return '</Demo>';
          }
        },
      });
  }

4.vitepress 配置config

# 关键代码
import { markdownConfig } from './plugins/markdown-plugin';
export default {
  markdown: {
    config: markdownConfig,
  },
}

5.vitepress 配置theme,添加Demo组件

import DefaultTheme from "vitepress/theme";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import "prismjs/themes/prism-funky.min.css";
import VpDemo  from '../src/vp-demo.vue';

export default {
  ...DefaultTheme,
  enhanceApp: ({ app }) => {
    app.use(ElementPlus);
    app.component('Demo',VpDemo)
  },
};

6.vp-demo 组件代码

<script setup lang="ts">
import { computed } from "vue";

const props = defineProps({
  source: {
    type: String,
    required: true,
  },
});

const decoded = computed(() => {
  return decodeURIComponent(props.source);
});
</script>

<template>
  <div class="example-source-wrapper">
    <div class="example-source" v-html="decoded" />
  </div>
</template>

<style scoped lang="scss">

.example-source{
    overflow-x: scroll;
    position: relative;
    color: #fff;
    background-color: var(--vp-code-block-bg) !important;
    padding: 1em;
}
</style>

7.vp-source-code组件代码

<script setup lang="ts">
import { useClipboard, useToggle } from "@vueuse/core";
import { CaretTop, DocumentCopy } from "@element-plus/icons-vue";
import { ElMessage } from 'element-plus';
import IconCopy from "./icon-copy.vue";
import IconCode from "./icon-code.vue";
import IconTop from "./icon-top.vue";
import Example from "./vp-example.vue";
import SourceCode from "./vp-source-code.vue";

const [sourceVisible, toggleSourceVisible] = useToggle();

const props = defineProps<{
  source: string;
  rawSource: string;
}>();

const { copy } = useClipboard({
  source: decodeURIComponent(props.rawSource),
  read: false,
});

const copyCode = async () => {
  try {
    await copy();
    ElMessage({
    message: '复制成功!',
    type: 'success',
  })
  } catch (e: any) {}
};
</script>

<template>
  <ClientOnly>
    <div class="example">
      <div class="example-showcase">
        <slot></slot>
      </div>
      <div class="op-btns">
        <ElTooltip content="复制代码" :show-arrow="false">
          <ElIcon :size="16" class="op-btn" @click="copyCode">
            <icon-copy></icon-copy>
          </ElIcon>
        </ElTooltip>
        <ElTooltip content="查看源代码" :show-arrow="false">
          <ElIcon :size="16" class="op-btn" @click="toggleSourceVisible()">
            <icon-code></icon-code>
          </ElIcon>
        </ElTooltip>
      </div>

      <ElCollapseTransition>
        <SourceCode v-show="sourceVisible" :source="source" />
      </ElCollapseTransition>

      <Transition name="el-fade-in-linear">
        <div
          v-show="sourceVisible"
          class="example-float-control"
          @click="toggleSourceVisible(false)"
        >
          <ElIcon :size="16">
            <icon-top></icon-top>
          </ElIcon>
          <span>隐藏源码</span>
        </div>
      </Transition>
    </div>
  </ClientOnly>
</template>

<style scoped lang="scss">
.m-0 {
  margin: 0;
}
.example-showcase {
  padding: 1.5rem;
  margin: 0.5px;
}
.example {
  border: 1px solid var(--vp-c-divider-light);
  border-radius: 4px;

  .op-btns {
    border-top: 1px solid var(--vp-c-divider-light);
    padding: 0.5rem;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    height: 2.5rem;

    .el-icon {
      &:hover {
        color: var(--vp-c-brand);
      }
    }

    .op-btn {
      margin: 0 0.5rem;
      cursor: pointer;
      color: var(--vp-c-text-2);
      transition: 0.2s;

    }
  }

  &-float-control {
    display: flex;
    align-items: center;
    justify-content: center;
    border-top: 1px solid var(--vp-c-divider-light);
    height: 44px;
    box-sizing: border-box;
    background-color: var(--vp-c-bg, #fff);
    border-bottom-left-radius: 4px;
    border-bottom-right-radius: 4px;
    margin-top: -1px;
    color: var(--vp-c-brand);
    cursor: pointer;
    position: sticky;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 10;
    span {
      font-size: 14px;
      margin-left: 10px;
    }

    &:hover {
      color: var(--vp-c-brand-light);
    }
  }
}
</style>

如果再看不懂请看例子源码,如果能解决你的问题的就给个赞和小星星呗