阅读视图

发现新文章,点击刷新页面。

Vue3 开发态轻量组件文档方案:基于动态路由 + Markdown

🚩 背景

在 Vue3 业务项目中,常见做法是将复用组件集中放到 src/components 目录。但随着多人并行开发,逐渐出现以下痛点:

  • 🤷‍♂️ 不知道已有封装(重复造轮子)
  • 🧪 组件封装质量参差不齐,缺乏复用指引
  • 📄 大量组件无使用文档 / 无交互示例
  • 🔍 逐个打开文件效率低
  • 🗣️ 口头沟通成本高,给人添麻烦

引入独立组件库(例如 storybook / docs site)成本过高,不符合仅为“项目内业务组件”做快速可见化的诉求,因此需要一个“足够轻”且“低侵入”的内部文档解决方案。

🎯 目标(Design Goals)

目标 说明
低侵入 不新增独立入口,不污染生产包体
零上手成本 开发者只需新增/维护 .md 文件
自动化收集 自动扫描 components 下 Markdown 文档
支持热更新 开发态修改文档立即生效
支持组件示例 Markdown 内可内联 Vue 组件预览
平滑演进 未来可拓展“示例 + 源码复制 + 搜索”等功能

🧩 方案概述

核心思想:仅在开发环境动态注入一个内部路由 /playDoc,该页面会:

  1. 使用 import.meta.glob 递归扫描 src/components/**/*.md
  2. 借助 unplugin-vue-markdown.md 编译为 Vue 组件
  3. 将 Markdown 渲染为动态组件并支持切换
  4. 后续扩展:内联示例、源码折叠、预览/复制等

✅ 优势:无需建立二次入口、无需新开端口、无需发布,生产环境自动剔除。

最初的想法是做成多入口文件,单独启动预览,实践中发现有点复杂,除了要加一套入口文件和项目配置外,有的依赖包必须要在 vite.config.dev.ts 中导入,否则影响构建,改动较多所以放弃了。


🏗️ 实现步骤

1. 安装依赖

pnpm add -D unplugin-vue-markdown

2. Vite 插件配置

import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import Markdown from "unplugin-vue-markdown/vite";

export default defineConfig(({ mode }) => {
  const isDev = mode === "development";

  return {
    plugins: [
      vueJsx(),
      vue({
        include: [/\.vue$/, /\.md$/], // 让 .md 也走 Vue 编译
      }),
      isDev &&
        Markdown({
          // 最简单就是什么都不配置,也可根据文档按需扩展 markdown-it 插件
          // headEnabled: false,
          // wrapperClasses: "md-doc-body",
          // markdownItSetup(md) {
            // 示例:支持 :::tip 容器、目录、task list 等
            // md.use(require("markdown-it-anchor")).use(require("markdown-it-task-lists"));
          },
        }),
    ]
  };
});

3. 类型声明

src/types/shims.d.ts

declare module "*.vue" {
  import type { Component } from "vue";
  const component: Component;
  export default component;
}

declare module "*.md" {
  import type { Component } from "vue";
  const component: Component;
  export default component;
}

4. 动态开发路由注入

import type { RouteRecordRaw } from "vue-router";

const baseRoutes: RouteRecordRaw[] = [
  // ...你的真实业务路由
];

const devDocRoute: RouteRecordRaw[] =
  import.meta.env.DEV
    ? [
        {
          path: "/playDoc",
          name: "PlayDoc",
          component: () => import("@/components/PlayDoc.vue"),
          meta: { hidden: true, title: "组件文档" },
        },
      ]
    : [];

export default [...baseRoutes, ...devDocRoute];

5. 文档页面组件(核心实现)

创建 src/components/PlayDoc.vue,组件内容借助 AI 实现。(简单示例)

<template>
  <div class="play-doc">
    <div class="sidebar">
      <h3>组件文档</h3>
      <ul class="doc-list">
        <li
          v-for="doc in docFiles"
          :key="doc.path"
          :class="{ active: currentDoc === doc.path }"
          @click="loadDoc(doc)"
        >
          {{ doc.name }}
        </li>
      </ul>
    </div>
    <div class="content">
      <div v-if="currentDocComponent" class="doc-content">
        <component :is="currentDocComponent" />
      </div>
      <div v-else class="empty">选择一个文档查看</div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import "element-plus/dist/index.css";

interface DocFile {
  name: string;
  path: string;
  module: () => Promise<any>;
}

const docFiles = ref<DocFile[]>([]);
const currentDoc = ref<string>("");
const currentDocComponent = ref<any>(null);

// 动态获取 components 目录下的 md 文件
const getDocFiles = () => {
  const modules = import.meta.glob("/src/components/**/*.md");
  console.log(modules, "modules");

  const files: DocFile[] = [];
  Object.entries(modules).forEach(([path, moduleLoader]) => {
    const name = path.split("/").pop()?.replace(".md", "") || "";
    files.push({
      name,
      path,
      module: moduleLoader as () => Promise<any>,
    });
  });

  docFiles.value = files;
  if (files.length > 0) {
    loadDoc(files[0]); // 默认加载第一个文档
  }
};

const loadDoc = async (doc: DocFile) => {
  try {
    currentDoc.value = doc.path;
    const module = await doc.module();
    currentDocComponent.value = module.default;
  } catch (error) {
    console.error("加载文档失败:", error);
  }
};

onMounted(() => {
  getDocFiles();
});
</script>

6. 示例组件文档(开发者需要编写的 .md)

注意 unplugin-vue-markdown 插件的作用,一个是将 md 文件转成 vue 组件使用;另一个是能够在 md 文件中使用 vue 组件。

# SearchForm 搜索表单组件

<SearchForm
v-model="searchForm"
:form-config="formConfig"
@search="handleSearch"
>
<el-button @click="handleReset">重置</el-button>
<el-button type="success" @click="handleExport">导出</el-button>
</SearchForm>

<script setup>
import { ref } from 'vue'
import SearchForm from './index.vue'
import { ElButton } from 'element-plus'

const searchForm = ref({})

const formConfig = [
  {
    type: 'input',
    label: '用户名',
    prop: 'username',
    placeholder: '请输入用户名'
  },
  {
    type: 'select',
    label: '状态',
    prop: 'status',
    placeholder: '请选择状态',
    options: [
      { label: '启用', value: 1 },
      { label: '禁用', value: 0 }
    ]
  },
  {
    type: 'daterange',
    label: '创建时间',
    prop: 'createTime'
  }
]

function handleSearch(formData) {
  console.log('搜索参数:', formData)
}

function handleReset() {
  searchForm.value = {}
}

function handleExport() {
  console.log('导出逻辑')
}
</script>

image.png

📂 目录结构

src/
  components/
    PlayDoc.vue          # 文档入口(仅开发态路由引用)
    FancyButton/
      index.vue
      FancyButton.md     # 组件文档
    UserAvatar/
      index.vue
      UserAvatar.md
    charts/
      BarChart.vue
      BarChart.md

命名规范:

  • 每个“可复用业务组件”目录下放置同名 .md
  • 无文档的组件会在后续统计中提示(可扩展自动检测)

注意事项和拓展:

说明
生产环境剔除 路由通过 import.meta.env.DEV 条件控制
风格隔离 PlayDoc.vue 设置样式时,不要影响到引入的子组件
Markdown 能力 集成其他插件,增强代码高亮、预览等

✅ 总结

该方案通过“开发态路由 + Markdown 编译为 Vue 组件”实现了一个:

  • 不额外开启端口
  • 不改变生产构建
  • 几乎零上手成本
  • 可持续迭代增强

的内部组件文档系统。适合业务项目在“尚未抽象到组件库层级”的组件复用与提效。

🚀 先让文档“存在且可见”,再逐步“结构化 + 自动化”。

后续继续补充......

❌