Vue 组件开发指南
VitePress 基于 Vue 3 构建,允许你在主题中使用 Vue 组件来扩展功能。本文档将详细介绍如何在主题中开发和使用 Vue 组件。
版本说明
- 本文档基于 VitePress v1.0.0+ 和 Vue 3.3+ 编写
- 组合式 API(Composition API)是推荐的开发方式
- 需要具备 Vue 3 基础知识
- 支持 Node.js 18.0.0 及以上版本
组件开发基础
VitePress 中的 Vue 环境
VitePress 使用 Vue 3 的单文件组件(SFC)系统,提供了以下特性:
| 特性 | 支持情况 | 说明 |
|---|---|---|
<script setup> | ✅ 完全支持 | 推荐使用组合式 API |
| TypeScript | ✅ 完全支持 | 类型推导和类型检查 |
| CSS Scoped | ✅ 完全支持 | 样式隔离 |
| CSS Modules | ✅ 完全支持 | CSS 模块化 |
| Pre-processors | ✅ 支持 | Sass、Less、Stylus |
组件文件结构
docs/
├── .vitepress/
│ ├── theme/
│ │ ├── index.ts # 主题入口
│ │ └── components/ # 自定义组件
│ │ ├── MyComponent.vue
│ │ └── AnotherComponent.vue
│ └── ...
└── ...组件注册方式
方式一:全局注册
在 .vitepress/theme/index.ts 中全局注册组件:
typescript
// .vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import MyComponent from './components/MyComponent.vue'
import AnotherComponent from './components/AnotherComponent.vue'
export default {
extends: DefaultTheme,
enhanceApp({ app }) {
// 全局注册组件
app.component('MyComponent', MyComponent)
app.component('AnotherComponent', AnotherComponent)
}
}优点:
- 在任何地方直接使用组件
- 适合频繁使用的组件
- 简化使用流程
缺点:
- 可能影响打包体积
- 全局命名空间污染
方式二:按需导入
在 Markdown 文件或 Vue 组件中按需导入:
vue
<script setup>
import MyComponent from './.vitepress/theme/components/MyComponent.vue'
</script>
<template>
<MyComponent />
</template>优点:
- 更好的代码分割
- 按需加载,优化性能
- 适合大型组件
缺点:
- 每次都需要导入
- 路径可能较长
方式三:自动导入(推荐)
使用 unplugin-vue-components 自动导入组件:
bash
npm install -D unplugin-vue-componentstypescript
// .vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import { AntDesignContainer } from 'vitepress-theme-demoblock'
export default {
extends: DefaultTheme,
enhanceApp({ app }) {
// 注册 Demo 容器
app.use(AntDesignContainer)
}
}typescript
// vite.config.ts
import { defineConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
export default defineConfig({
plugins: [
Components({
dirs: ['.vitepress/theme/components'],
extensions: ['vue'],
dts: '.vitepress/components.d.ts'
})
]
})组件开发示例
基础组件
创建一个简单的卡片组件:
vue
<!-- .vitepress/theme/components/InfoCard.vue -->
<template>
<div class="info-card" :class="`info-card--${type}`">
<div v-if="icon" class="info-card__icon">{{ icon }}</div>
<div class="info-card__content">
<h3 v-if="title" class="info-card__title">{{ title }}</h3>
<p class="info-card__description"><slot /></p>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
title?: string
type?: 'info' | 'warning' | 'error' | 'success'
icon?: string
}
withDefaults(defineProps<Props>(), {
type: 'info',
title: '',
icon: ''
})
</script>
<style scoped>
.info-card {
padding: 1rem;
border-radius: 8px;
border-left: 4px solid;
margin: 1rem 0;
}
.info-card--info {
background-color: var(--vp-c-brand-soft);
border-color: var(--vp-c-brand-1);
}
.info-card--warning {
background-color: #fff3cd;
border-color: #ffc107;
}
.info-card--error {
background-color: #f8d7da;
border-color: #dc3545;
}
.info-card--success {
background-color: #d4edda;
border-color: #28a745;
}
.info-card__icon {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.info-card__title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 600;
}
.info-card__description {
margin: 0;
line-height: 1.6;
}
</style>使用方式:
vue
<InfoCard title="提示" type="info" icon="💡">
这是一个信息提示卡片。
</InfoCard>交互式组件
创建一个标签切换组件:
vue
<!-- .vitepress/theme/components/TabSwitch.vue -->
<template>
<div class="tab-switch">
<div class="tab-switch__tabs">
<button
v-for="(tab, index) in tabs"
:key="index"
class="tab-switch__tab"
:class="{ 'tab-switch__tab--active': activeIndex === index }"
@click="activeIndex = index"
>
{{ tab.label }}
</button>
</div>
<div class="tab-switch__content">
<div
v-for="(tab, index) in tabs"
:key="index"
v-show="activeIndex === index"
class="tab-switch__panel"
>
<component :is="tab.component" v-if="tab.component" />
<div v-else v-html="tab.content"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Tab {
label: string
content?: string
component?: any
}
interface Props {
tabs: Tab[]
}
const props = defineProps<Props>()
const activeIndex = ref(0)
</script>
<style scoped>
.tab-switch {
margin: 1rem 0;
}
.tab-switch__tabs {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid var(--vp-c-divider);
margin-bottom: 1rem;
}
.tab-switch__tab {
padding: 0.5rem 1rem;
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
color: var(--vp-c-text-2);
transition: all 0.2s;
}
.tab-switch__tab:hover {
color: var(--vp-c-text-1);
}
.tab-switch__tab--active {
color: var(--vp-c-brand-1);
border-bottom: 2px solid var(--vp-c-brand-1);
margin-bottom: -2px;
}
.tab-switch__panel {
padding: 1rem 0;
}
</style>数据驱动组件
创建一个文章列表组件:
vue
<!-- .vitepress/theme/components/ArticleList.vue -->
<template>
<div class="article-list">
<div
v-for="article in articles"
:key="article.path"
class="article-item"
>
<a :href="article.path" class="article-item__link">
<h3 class="article-item__title">{{ article.title }}</h3>
<p v-if="article.description" class="article-item__desc">
{{ article.description }}
</p>
<div class="article-item__meta">
<span v-if="article.date">{{ formatDate(article.date) }}</span>
<span v-if="article.tags" class="article-item__tags">
<span
v-for="tag in article.tags"
:key="tag"
class="article-item__tag"
>
{{ tag }}
</span>
</span>
</div>
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { data as posts } from './posts.data'
interface Article {
title: string
path: string
description?: string
date?: string
tags?: string[]
}
interface Props {
limit?: number
tags?: string[]
}
const props = withDefaults(defineProps<Props>(), {
limit: 10,
tags: () => []
})
const articles = computed(() => {
let result = posts as Article[]
// 按标签过滤
if (props.tags.length > 0) {
result = result.filter(article =>
article.tags?.some(tag => props.tags.includes(tag))
)
}
// 限制数量
return result.slice(0, props.limit)
})
function formatDate(date: string) {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
</script>
<style scoped>
.article-list {
display: grid;
gap: 1rem;
}
.article-item {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
transition: all 0.2s;
}
.article-item:hover {
border-color: var(--vp-c-brand-1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.article-item__link {
display: block;
padding: 1.5rem;
text-decoration: none;
color: inherit;
}
.article-item__title {
margin: 0 0 0.5rem;
font-size: 1.2rem;
color: var(--vp-c-text-1);
}
.article-item__desc {
margin: 0 0 1rem;
color: var(--vp-c-text-2);
line-height: 1.6;
}
.article-item__meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: var(--vp-c-text-3);
}
.article-item__tags {
display: flex;
gap: 0.5rem;
}
.article-item__tag {
padding: 0.2rem 0.5rem;
background: var(--vp-c-brand-soft);
border-radius: 4px;
font-size: 0.8rem;
}
</style>组件间通信
Props 和 Emits
vue
<!-- 父组件 Parent.vue -->
<template>
<ChildComponent
:message="parentMessage"
@update="handleUpdate"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const parentMessage = ref('来自父组件的消息')
function handleUpdate(newValue: string) {
parentMessage.value = newValue
}
</script>
<!-- 子组件 ChildComponent.vue -->
<template>
<div>
<p>{{ message }}</p>
<button @click="updateMessage">更新消息</button>
</div>
</template>
<script setup lang="ts">
interface Props {
message: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [value: string]
}>()
function updateMessage() {
emit('update', '来自子组件的新消息')
}
</script>Provide/Inject
适合深层嵌套组件通信:
typescript
// 在父组件提供数据
import { provide, ref } from 'vue'
const theme = ref('light')
provide('theme', theme)typescript
// 在子组件注入数据
import { inject } from 'vue'
const theme = inject('theme')使用 Composables
创建可复用的逻辑:
typescript
// .vitepress/theme/composables/useTheme.ts
import { ref, watch } from 'vue'
export function useTheme() {
const isDark = ref(false)
// 监听主题变化
watch(isDark, (val) => {
if (val) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
localStorage.setItem('theme', val ? 'dark' : 'light')
})
// 初始化主题
const savedTheme = localStorage.getItem('theme')
if (savedTheme === 'dark') {
isDark.value = true
}
const toggleTheme = () => {
isDark.value = !isDark.value
}
return {
isDark,
toggleTheme
}
}在组件中使用:
vue
<template>
<button @click="toggleTheme">
{{ isDark ? '🌙' : '☀️' }}
</button>
</template>
<script setup>
import { useTheme } from '../composables/useTheme'
const { isDark, toggleTheme } = useTheme()
</script>访问 VitePress 数据
使用 useData 获取页面数据
vue
<template>
<div>
<h1>{{ page.title }}</h1>
<p>最后更新: {{ lastUpdated }}</p>
</div>
</template>
<script setup>
import { useData } from 'vitepress'
const { page, lastUpdated } = useData()
</script>使用 useRoute 获取路由信息
vue
<template>
<div>
<p>当前路径: {{ route.path }}</p>
</div>
</template>
<script setup>
import { useRoute } from 'vitepress'
const route = useRoute()
</script>使用 useSiteConfig 获取站点配置
vue
<template>
<div>
<p>站点标题: {{ site.title }}</p>
<p>站点描述: {{ site.description }}</p>
</div>
</template>
<script setup>
import { useSiteConfig } from 'vitepress'
const site = useSiteConfig()
</script>组件最佳实践
1. 使用 TypeScript
vue
<script setup lang="ts">
interface Props {
title: string
count?: number
items: Array<{ id: number; name: string }>
}
const props = withDefaults(defineProps<Props>(), {
count: 0
})
</script>2. 样式隔离
使用 scoped 或 CSS Modules:
vue
<!-- 方式一:Scoped -->
<style scoped>
.my-component {
/* 样式只作用于当前组件 */
}
</style>
<!-- 方式二:CSS Modules -->
<template>
<div :class="$style.container">
<p :class="$style.text">Hello</p>
</div>
</template>
<style module>
.container {
/* 样式会被模块化 */
}
.text {
color: red;
}
</style>3. 性能优化
vue
<template>
<div>
<!-- 使用 v-once 渲染静态内容 -->
<header v-once>
<h1>{{ siteTitle }}</h1>
</header>
<!-- 使用 v-memo 缓存复杂渲染 -->
<div v-memo="[item.id]">
<ExpensiveComponent :item="item" />
</div>
<!-- 使用 Suspense 处理异步组件 -->
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<Loading />
</template>
</Suspense>
</div>
</template>4. 响应式设计
vue
<template>
<div class="responsive-container">
<div class="sidebar">...</div>
<div class="main-content">...</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const isMobile = ref(false)
function checkScreenSize() {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
checkScreenSize()
window.addEventListener('resize', checkScreenSize)
})
onUnmounted(() => {
window.removeEventListener('resize', checkScreenSize)
})
</script>
<style scoped>
.responsive-container {
display: flex;
}
.sidebar {
width: 250px;
}
.main-content {
flex: 1;
}
@media (max-width: 768px) {
.responsive-container {
flex-direction: column;
}
.sidebar {
width: 100%;
}
}
</style>组件调试技巧
使用 Vue DevTools
- 安装 Vue DevTools
- 在开发模式下自动启用
- 查看组件树、状态、事件等
控制台调试
vue
<script setup>
import { onMounted, ref } from 'vue'
const data = ref([])
onMounted(() => {
console.log('组件已挂载')
console.log('数据:', data.value)
})
</script>错误处理
vue
<script setup>
import { onErrorCaptured, ref } from 'vue'
const error = ref(null)
onErrorCaptured((err) => {
error.value = err
console.error('组件错误:', err)
return false // 阻止错误继续向上传播
})
</script>
<template>
<div v-if="error">
发生错误: {{ error.message }}
</div>
<slot v-else />
</template>