文章 文章 posts

在 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 的本地与生产模式

非常适合在技术教程或组件文档中集成交互示例。