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

587 阅读8分钟

🚩 背景

在 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 highlight.js

2. Vite 插件配置和 highlight 高亮设置

vite.config.ts 如下

import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
// Markdown 插件与外部高亮配置
import Markdown from "unplugin-vue-markdown/vite";
import { markdownHighlight } from "./highlight";

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

  return {
    plugins: [
      vueJsx(),
      vue({
        include: [/\.vue$/, /\.md$/], // 让 .md 也走 Vue 编译
      }),
      Markdown({
        // 最简单就是什么都不配置,也可根据文档按需扩展 markdown-it 插件
        markdownItOptions: {
          // 可以添加代码高亮
          highlight: markdownHighlight, // 需安装 highlight.js
        }
      }),
    ]
  };
});

highlight.ts 如下

// 使用 highlight.js 为 Markdown 代码块提供高亮功能(配合 unplugin-vue-markdown)
// 中文注释:考虑可维护性与扩展性,按需注册常用语言,并提供语言别名映射

import hljs from "highlight.js/lib/core";

// 按需注册,减少打包体积
import xml from "highlight.js/lib/languages/xml"; // html、vue 模板
import javascript from "highlight.js/lib/languages/javascript";
import typescript from "highlight.js/lib/languages/typescript";
import json from "highlight.js/lib/languages/json";
import css from "highlight.js/lib/languages/css";
import scss from "highlight.js/lib/languages/scss";

hljs.registerLanguage("xml", xml);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("json", json);
hljs.registerLanguage("css", css);
hljs.registerLanguage("scss", scss);

// 语言别名映射:用于将常见简写映射到已注册语言
export const languageAliasMap: Record<string, string> = {
  vue: "xml",
  html: "xml",
  ts: "typescript",
  js: "javascript",
};

/**
 * markdown-it 的 highlight 钩子函数
 * 说明:unplugin-vue-markdown 支持通过 markdownItOptions.highlight 接入
 */
export function markdownHighlight(code: string, lang?: string): string {
  const mapped = lang && languageAliasMap[lang] ? languageAliasMap[lang] : lang;

  try {
    // 显式语言优先(需要已注册)
    if (mapped && hljs.getLanguage(mapped)) {
      const result = hljs.highlight(code, {
        language: mapped,
        ignoreIllegals: true,
      }).value;
      const className = `hljs language-${lang}`;
      return `<pre><code class="${className}">${result}</code></pre>`;
    }
  } catch (_err) {
    console.log(_err);
  }

  // 自动识别作为兜底方案(未知语言或未注册语言)
  const auto = hljs.highlightAuto(code);
  const className = `hljs${auto.language ? ` language-${auto.language}` : ""}`;
  return `<pre><code class="${className}">${auto.value}</code></pre>`;
}

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(/* @vite-ignore */`@/components/${"PlayDoc.vue"}`),
          component: () => import("@/components/PlayDoc.vue"),
          meta: { hidden: true, title: "组件文档" },
        },
      ]
    : [];

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

忽略下面 3 点,经过排查文档文件 playDoc.vue 并没有出现在 dist 打包产物中,之前搞错了。

为了保留记录,仅做删除线处理。

1. 在调试构建时,考虑到文档的部分不打包到 dist 中,所以最初写的是模板字符串形式,因为

静态字符串 → 确定依赖 → 构建时打包成异步的 chunk

动态字符串 → 不确定依赖 → 构建时跳过,不会打包(不出现在dist包中)

2. 构建没问题,但是 vite 开发环境不支持这样写,报错 hook.js:608 TypeError: Failed to resolve module specifier '@/components/PlayDoc.vue'

3. /* @vite-ignore * 也只针对开发环境

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";
// 代码高亮主题样式(highlight.js),可按需切换主题
import "highlight.js/styles/intellij-light.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 代码折叠的功能可以让 ai 实现,只需要在展示组件 /playDoc.vue 中通过 ref 拿到文档组件,获取所有 pre 标签,针对超出一定高度的自动加样式和插入折叠按钮。

📂 目录结构

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

命名规范:

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

注意事项和拓展:

说明
生产环境剔除文档文件不会出现在最终构建产物中
风格隔离PlayDoc.vue 设置样式时,不要影响到引入的子组件
Markdown 能力集成其他插件,增强代码高亮、预览等

🐞 其他

可能和node版本相关,有同事使用插件后,有可能会有如下报错:

failed to load config from C:\Users\Lenovo\Desktop\xxxxx\vite.config.ts
error when starting dev server:
Error [ERR_REQUIRE_ESM]: require() of ES Module C:\Users\Lenovo\Desktop\xxxxx\node_modules\.pnpm\package-manager-detector@1.3.0\node_modules\package-manager-detector\dist\detect.mjs not supported.
Instead change the require of C:\Users\Lenovo\Desktop\xxxxx\node_modules\.pnpm\package-manager-detector@1.3.0\node_modules\package-manager-detector\dist\detect.mjs to a dynamic import() which is available in all CommonJS modules

经过排查,是因为 unplugin-vue-markdown 插件内部使用了 package-manager-detector这个包,而后面这个包只支持 mjs 的导出形式,所以就报错了。当然能通过 "type":"module" 解决,但是这个影响太大,可能有未知问题。所以可以将配置文件改成 vite.config.mts,同时需要调整下 path 的使用:

import path from "path";
import { fileURLToPath } from "url";

// 转换成 __filename 和 __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

补充

本地开发时总通过手动切换路由,很麻烦。所以写了个油猴脚本,插入了一个文档跳转📃

// ==UserScript==
// @name         本地开发文档
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  在本机环境右下角插入跳转开发文档的按钮
// @author       You
// @match        http://localhost*/*
// @match        http://127.0.0.1*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=localhost
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // 仅允许在本机环境(localhost / 127.0.0.1)执行主逻辑
    const isLocalHost =
        location.hostname === 'localhost' ||
        location.hostname === '127.0.0.1';

    if (!isLocalHost) {
        return;
    }

    // 文档页本身不展示按钮:
    if (location.hash.includes('/newdz/playDoc')) {
        return;
    }

    // 防止重复插入
    if (document.getElementById('dev-doc-float-btn')) {
        return;
    }

    // 创建右下角文档按钮(外层容器)
    const btn = document.createElement('div');
    btn.id = 'dev-doc-float-btn';

    Object.assign(btn.style, {
        position: 'fixed',
        right: '20px',
        bottom: '20px',
        width: '30px',
        height: '30px',
        lineHeight: '30px',
        textAlign: 'center',
        backgroundColor: '#ff7700',
        color: '#fff',
        borderRadius: '50%',
        boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
        cursor: 'pointer',
        zIndex: 9999,
        fontSize: '18px',
        userSelect: 'none',
        boxSizing: 'border-box',
        // 让里面的小 × 能够绝对定位
        position: 'fixed'
    });

    btn.title = '跳转到开发环境文档';

    // 文档图标
    const icon = document.createElement('span');
    icon.textContent = '📄';
    Object.assign(icon.style, {
        display: 'inline-block',
        width: '100%',
        height: '100%',
        lineHeight: '30px',
        textAlign: 'center'
    });

    // 关闭按钮(默认隐藏,仅 hover 时显示)
    const close = document.createElement('span');
    close.textContent = '×';
    Object.assign(close.style, {
        position: 'absolute',
        top: '-6px',
        right: '-6px',
        width: '16px',
        height: '16px',
        lineHeight: '16px',
        textAlign: 'center',
        borderRadius: '50%',
        backgroundColor: '#ff4444',
        color: '#fff',
        fontSize: '12px',
        cursor: 'pointer',
        display: 'none',
        boxShadow: '0 1px 4px rgba(0,0,0,0.3)'
    });

    // hover 时显示关闭按钮
    btn.addEventListener('mouseenter', () => {
        close.style.display = 'block';
    });
    btn.addEventListener('mouseleave', () => {
        close.style.display = 'none';
    });

    // 点击主按钮:跳转到文档页
    btn.addEventListener('click', (e) => {
        // 如果点击的是关闭按钮,不执行跳转逻辑
        if (e.target === close) return;

        const docUrl = `${location.protocol}//${location.host}/实际路径/playDoc`;
        window.open(docUrl);
    });

    // 点击 ×:移除按钮
    close.addEventListener('click', (e) => {
        e.stopPropagation(); // 阻止冒泡到 btn,避免触发跳转
        btn.remove();
    });

    btn.appendChild(icon);
    btn.appendChild(close);
    document.body.appendChild(btn);
})();

image.png

为了便于追踪 bug,快速定位问题引入时间点和相关改动,团队成员同步组件最新变化,新增 CHANGELOG 文件。

## 更新日志

本文件记录所有组件的变更历史,用于:
- ✅追踪 Bug:快速定位问题引入时间点和相关改动
- ✅协作沟通:团队成员同步组件最新变化

<!--
📝 格式示例:
**[2025-12-15]**
- SearchForm - 【修复】修复表单重置后数据未清空的问题
- BaseTable - 【新增】新增分页配置项 `showTotal` 属性
- BaseTable - 【优化】优化大数据渲染性能,提升30%
-->
<!-- 📌 最新变更记录在最上方 -->

**[2025-12-15]**
- PlayDoc - 【新增】新增组件库全局 CHANGELOG.md 文件,统一记录所有组件变更

✅ 总结

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

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

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

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

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