主题
在 VitePress 中创建支持演示与源码查看的 Demo 组件
在编写技术文档(例如教程或组件库文档)时,我们常常希望能 在页面中直接演示 HTML 示例,并同时 支持查看源码与复制功能。
本文将介绍如何在 VitePress 项目中创建一个功能完善的 <Demo>
组件。
1️⃣ 功能概述
本文实现的 Demo 组件具有以下特性:
✅ 支持以 iframe 加载外部 HTML 文件(如 /demos/3.html
)
✅ 自动检测路径错误(防止加载到 index.html
)
✅ 源码高亮显示(基于 Ace Editor)
✅ 一键复制源码
✅ 切换演示 / 查看源码
✅ 自动调整 iframe 高度
✅ 兼容 VitePress 的 SSR 和静态构建模式
2️⃣ 文件结构示例
在 VitePress 项目中,我们推荐将组件放置在:
txt
.vitepress/
└── theme/
└── components/
└── Demo.vue
同时,你的示例 HTML 文件可以放在 /public/demos/
下,例如:
txt
public/
└── demos/
├── 1.html
├── 2.html
└── 3.html
3️⃣ 安装依赖
本组件使用 vue3-ace-editor 作为源码查看器,因此需要安装:
bash
pnpm add vue3-ace-editor ace-builds
或使用 npm:
bash
npm install vue3-ace-editor ace-builds
4️⃣ 组件源码
创建文件: ./.vitepress/theme/components/Demo.vue
vue
<!-- Demo.vue -->
<script setup>
import { ref, onMounted, watch } from 'vue'
import { VAceEditor } from 'vue3-ace-editor'
// Ace 编辑器配置
import 'ace-builds/src-noconflict/mode-html'
import 'ace-builds/src-noconflict/theme-github'
import 'ace-builds/src-noconflict/ext-language_tools'
const props = defineProps({
html: { type: String, required: true }, // 支持 "/demos/3.html" 或 "../demos/3.html"
height: { type: String, default: '420px' }
})
const iframeSrc = ref('')
const sourceCode = ref('')
const showSource = ref(false)
const errorMsg = ref('')
const iframeRef = ref(null)
// 检查是否加载了 index.html(Vite fallback)
function looksLikeIndexHtml(text) {
if (!text) return false
return text.includes('/@vite/client') || text.includes('vitepress')
}
// 解析相对路径为网站路径
function resolveToWebPath(input) {
if (typeof window === 'undefined') return input
if (/^(https?:)?\/\//.test(input)) {
try {
const u = new URL(input, window.location.href)
return u.pathname + u.search + u.hash
} catch {
return input
}
}
if (input.startsWith('/')) return input
let base = window.location.pathname
if (!base.endsWith('/')) base = base.substring(0, base.lastIndexOf('/') + 1) || '/'
try {
const u = new URL(input, window.location.origin + base)
return u.pathname + u.search + u.hash
} catch {
return '/' + input.replace(/^\.\//, '')
}
}
// 加载示例
async function loadDemo() {
errorMsg.value = ''
iframeSrc.value = ''
sourceCode.value = ''
let webPath = props.html
if (!webPath.startsWith('/') && !/^(https?:)?\/\//.test(webPath)) {
webPath = resolveToWebPath(webPath)
}
let fullUrl = /^(https?:)?\/\//.test(webPath)
? webPath
: window.location.origin + webPath
try {
const res = await fetch(fullUrl, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const txt = await res.text()
sourceCode.value = txt.trim()
if (looksLikeIndexHtml(txt)) {
errorMsg.value = `请求 ${webPath} 返回了站点主页面 (index.html),请确认 demo 文件已放在 public/ 下并使用以 "/" 开头的路径。`
return
}
iframeSrc.value = fullUrl
} catch (e) {
errorMsg.value = `无法加载示例:${e.message}`
}
}
onMounted(loadDemo)
watch(() => props.html, loadDemo)
// 复制源码
const copyState = ref('')
async function copySource() {
try {
await navigator.clipboard.writeText(sourceCode.value)
copyState.value = '已复制'
setTimeout(() => (copyState.value = ''), 1600)
} catch {
copyState.value = '复制失败'
setTimeout(() => (copyState.value = ''), 1600)
}
}
// iframe 高度自适应
function adjustHeight() {
const iframe = iframeRef.value
if (!iframe) return
try {
const doc = iframe.contentDocument || iframe.contentWindow.document
iframe.style.height = doc.body.scrollHeight + 16 + 'px'
} catch {
iframe.style.height = props.height
}
}
</script>
<template>
<div class="demo-block">
<div class="demo-header">
<div class="demo-actions">
<button class="btn" @click="showSource = !showSource">
{{ showSource ? '显示演示' : '查看源码' }}
</button>
<button class="btn" @click="copySource">
{{ copyState || '复制源码' }}
</button>
</div>
</div>
<div v-if="errorMsg" class="demo-error">{{ errorMsg }}</div>
<div v-if="!showSource" class="demo-preview">
<div v-if="iframeSrc" class="iframe-wrap">
<iframe ref="iframeRef" :src="iframeSrc" @load="adjustHeight" frameborder="0" style="width:100%"></iframe>
</div>
<div v-else class="demo-fallback">
<p>示例无法以 iframe 加载(请检查路径或将 demo 放到 public/)。</p>
</div>
</div>
<div v-else class="demo-editor">
<v-ace-editor v-model:value="sourceCode" lang="html" theme="github" :readonly="true"
style="width:100%; height: 420px; font-size:13px;" />
</div>
</div>
</template>
<style scoped>
.demo-block {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
margin: 16px 0;
overflow: hidden;
background: var(--vp-c-bg);
}
.demo-header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 8px 10px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.demo-actions {
display: flex;
gap: 8px;
}
.btn {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
cursor: pointer;
font-size: 13px;
}
.demo-fallback, .demo-error {
padding: 12px;
color: #a00;
font-size: 14px;
}
</style>
5️⃣ 使用方式
在 Markdown 文档中直接引入:
md
<Demo html="/demos/3.html" />
或者使用相对路径(VitePress 会自动解析):
md
<Demo html="../demos/3.html" />
💡 注意:请确保示例文件放在
public/demos/
目录下,否则可能被 Vite fallback 到index.html
。
6️⃣ 示例效果
你将看到如下效果:
- 默认显示 HTML 示例(通过 iframe 渲染)
- 点击「查看源码」切换至源码查看界面
- 点击「复制源码」即可复制 HTML 内容到剪贴板
7️⃣ 总结
通过以上实现,我们得到了一个高复用的 <Demo>
组件,完美支持:
- ✅ 示例与源码双模式展示
- ✅ 独立 iframe 隔离运行环境
- ✅ 一键复制源码
- ✅ 兼容 VitePress 的本地与生产模式
非常适合在技术教程或组件文档中集成交互示例。