🚩 背景
在 Vue3 业务项目中,常见做法是将复用组件集中放到 src/components 目录。但随着多人并行开发,逐渐出现以下痛点:
- 🤷♂️ 不知道已有封装(重复造轮子)
- 🧪 组件封装质量参差不齐,缺乏复用指引
- 📄 大量组件无使用文档 / 无交互示例
- 🔍 逐个打开文件效率低
- 🗣️ 口头沟通成本高,给人添麻烦
引入独立组件库(例如 storybook / docs site)成本过高,不符合仅为“项目内业务组件”做快速可见化的诉求,因此需要一个“足够轻”且“低侵入”的内部文档解决方案。
🎯 目标(Design Goals)
| 目标 | 说明 |
|---|---|
| 低侵入 | 不新增独立入口,不增加生产包体积 |
| 零上手成本 | 开发者只需新增/维护 .md 文件 |
| 自动化收集 | 自动扫描 components 下 Markdown 文档 |
| 支持热更新 | 开发态修改文档立即生效 |
| 支持组件示例 | Markdown 内可内联 Vue 组件预览 |
| 平滑演进 | 未来可拓展“示例 + 源码复制 + 搜索”等功能 |
🧩 方案概述
核心思想:仅在开发环境动态注入一个内部路由 /playDoc,该页面会:
- 使用
import.meta.glob递归扫描src/components/**/*.md - 借助
unplugin-vue-markdown将.md编译为 Vue 组件 - 将 Markdown 渲染为动态组件并支持切换
- 后续扩展:内联示例、源码折叠、预览/复制等
✅ 优势:无需建立二次入口、无需新开端口、无需发布,生产环境自动剔除。
最初的想法是做成多入口文件,单独启动预览,实践中发现有点复杂,除了要加一套入口文件和项目配置外,有的依赖包必须要在 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>
代码折叠的功能可以让 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);
})();
为了便于追踪 bug,快速定位问题引入时间点和相关改动,团队成员同步组件最新变化,新增 CHANGELOG 文件。
## 更新日志
本文件记录所有组件的变更历史,用于:
- ✅追踪 Bug:快速定位问题引入时间点和相关改动
- ✅协作沟通:团队成员同步组件最新变化
<!--
📝 格式示例:
**[2025-12-15]**
- SearchForm - 【修复】修复表单重置后数据未清空的问题
- BaseTable - 【新增】新增分页配置项 `showTotal` 属性
- BaseTable - 【优化】优化大数据渲染性能,提升30%
-->
<!-- 📌 最新变更记录在最上方 -->
**[2025-12-15]**
- PlayDoc - 【新增】新增组件库全局 CHANGELOG.md 文件,统一记录所有组件变更
✅ 总结
该方案通过“开发态路由 + Markdown 编译为 Vue 组件”实现了一个:
- 不额外开启端口
- 不改变生产构建
- 几乎零上手成本
- 可持续迭代增强
的内部组件文档系统。适合业务项目在“尚未抽象到组件库层级”的组件复用与提效。
🚀 先让文档“存在且可见”,再逐步“结构化 + 自动化”。
后续继续补充......