主题开发实战:博客主题
本教程将带你从零开始开发一个功能完整的 VitePress 博客主题,包括文章列表、分类标签、归档页面等功能。通过这个实战项目,你将掌握主题开发的完整流程。
版本说明
- 本教程基于 VitePress v1.0.0+ 和 Vue 3.3+ 编写
- 需要 Node.js 18.0.0 及以上版本
- 建议先学习 Vue 组件开发指南
- 预计完成时间:90 分钟
项目规划
功能特性
我们要开发的博客主题包含以下功能:
| 功能模块 | 说明 | 难度 |
|---|---|---|
| 文章列表 | 首页展示文章列表 | ⭐⭐ |
| 文章详情 | 文章阅读页面 | ⭐ |
| 分类标签 | 文章分类和标签系统 | ⭐⭐⭐ |
| 归档页面 | 按时间归档文章 | ⭐⭐ |
| 关于页面 | 个人简介页面 | ⭐ |
| 评论系统 | 文章评论功能 | ⭐⭐⭐ |
项目结构
my-blog/
├── .vitepress/
│ ├── theme/
│ │ ├── index.ts # 主题入口
│ │ ├── Layout.vue # 博客布局
│ │ ├── components/
│ │ │ ├── PostList.vue # 文章列表
│ │ │ ├── PostCard.vue # 文章卡片
│ │ │ ├── TagList.vue # 标签列表
│ │ │ ├── CategoryList.vue # 分类列表
│ │ │ ├── ArchiveList.vue # 归档列表
│ │ │ ├── Comment.vue # 评论组件
│ │ │ └── Sidebar.vue # 侧边栏
│ │ ├── composables/
│ │ │ ├── usePosts.ts # 文章数据
│ │ │ ├── useTags.ts # 标签数据
│ │ │ └── useCategories.ts # 分类数据
│ │ └── styles/
│ │ ├── index.css # 全局样式
│ │ └── variables.css # CSS 变量
│ ├── config.ts # VitePress 配置
│ └── posts.data.ts # 文章数据加载器
├── posts/ # 文章目录
│ ├── 2024-01-15-my-first-post.md
│ └── 2024-02-20-vue-components.md
├── public/
│ └── avatar.jpg
├── index.md # 首页
├── about.md # 关于页面
├── archives.md # 归档页面
├── tags.md # 标签页面
└── package.json步骤一:项目初始化
1.1 创建项目
bash
# 创建项目目录
mkdir my-vitepress-blog
cd my-vitepress-blog
# 初始化项目
npm init -y
# 安装依赖
npm install -D vitepress vue1.2 配置 package.json
json
{
"name": "my-vitepress-blog",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.0.0",
"vue": "^3.3.0"
}
}1.3 创建基础配置
typescript
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
export default defineConfig({
title: '我的博客',
description: '一个使用 VitePress 搭建的个人博客',
themeConfig: {
nav: [
{ text: '首页', link: '/' },
{ text: '归档', link: '/archives' },
{ text: '标签', link: '/tags' },
{ text: '关于', link: '/about' }
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/yourusername' }
],
footer: {
message: '基于 VitePress 构建',
copyright: 'Copyright © 2024-present'
}
},
// 标记文章目录
srcDir: 'posts',
srcExclude: ['README.md'],
// 美化 URL
cleanUrls: true,
// 最后更新时间
lastUpdated: true
})步骤二:创建主题布局
2.1 主题入口文件
typescript
// .vitepress/theme/index.ts
import { h } from 'vue'
import type { Theme } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
// 导入自定义组件
import Layout from './Layout.vue'
import PostList from './components/PostList.vue'
import PostCard from './components/PostCard.vue'
import TagList from './components/TagList.vue'
import CategoryList from './components/CategoryList.vue'
import ArchiveList from './components/ArchiveList.vue'
import Comment from './components/Comment.vue'
import Sidebar from './components/Sidebar.vue'
// 导入样式
import './styles/index.css'
export default {
extends: DefaultTheme,
Layout: Layout,
enhanceApp({ app }) {
// 注册全局组件
app.component('PostList', PostList)
app.component('PostCard', PostCard)
app.component('TagList', TagList)
app.component('CategoryList', CategoryList)
app.component('ArchiveList', ArchiveList)
app.component('Comment', Comment)
app.component('Sidebar', Sidebar)
}
} satisfies Theme2.2 自定义布局组件
vue
<!-- .vitepress/theme/Layout.vue -->
<template>
<Layout>
<!-- 在顶部插入自定义内容 -->
<template #layout-top>
<div v-if="isHome" class="hero-banner">
<div class="hero-content">
<img src="/avatar.jpg" alt="Avatar" class="avatar" />
<h1>{{ site.title }}</h1>
<p>{{ site.description }}</p>
</div>
</div>
</template>
<!-- 在侧边栏前插入内容 -->
<template #sidebar-nav-before>
<div class="sidebar-profile">
<img src="/avatar.jpg" alt="Avatar" class="sidebar-avatar" />
<h3>博主</h3>
<p>前端开发工程师</p>
<div class="social-links">
<a href="https://github.com/yourusername" target="_blank">
GitHub
</a>
</div>
</div>
</template>
<!-- 在文章后插入评论 -->
<template #doc-after>
<Comment v-if="showComment" />
</template>
</Layout>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useData, useRoute } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
const { site } = useData()
const route = useRoute()
const Layout = DefaultTheme.Layout
// 判断是否首页
const isHome = computed(() => route.path === '/')
// 判断是否显示评论
const showComment = computed(() => {
return route.path.startsWith('/posts/')
})
</script>
<style scoped>
.hero-banner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 4rem 2rem;
text-align: center;
color: white;
}
.hero-content {
max-width: 800px;
margin: 0 auto;
}
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid white;
margin-bottom: 1rem;
}
.hero-content h1 {
font-size: 2.5rem;
margin: 0.5rem 0;
}
.hero-content p {
font-size: 1.2rem;
opacity: 0.9;
}
.sidebar-profile {
padding: 1.5rem;
text-align: center;
border-bottom: 1px solid var(--vp-c-divider);
}
.sidebar-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin-bottom: 0.5rem;
}
.sidebar-profile h3 {
margin: 0.5rem 0;
font-size: 1.1rem;
}
.sidebar-profile p {
margin: 0.25rem 0;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.social-links {
margin-top: 1rem;
}
.social-links a {
display: inline-block;
padding: 0.4rem 1rem;
background: var(--vp-c-brand-1);
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 0.85rem;
transition: all 0.2s;
}
.social-links a:hover {
background: var(--vp-c-brand-2);
}
</style>步骤三:创建数据加载器
3.1 文章数据加载器
typescript
// .vitepress/posts.data.ts
import { createContentLoader } from 'vitepress'
interface Post {
title: string
url: string
date: string
description: string
tags: string[]
category: string
author: string
readingTime: number
}
declare const data: Post[]
export { data }
export default createContentLoader('posts/*.md', {
// 提取 frontmatter
transform(raw): Post[] {
return raw
.map((page) => {
const { url, frontmatter, content } = page
// 计算阅读时间(假设每分钟阅读 300 个单词)
const readingTime = Math.ceil(
content.split(/\s+/).length / 300
)
return {
title: frontmatter.title,
url,
date: frontmatter.date,
description: frontmatter.description || '',
tags: frontmatter.tags || [],
category: frontmatter.category || '未分类',
author: frontmatter.author || '博主',
readingTime
}
})
.sort((a, b) => {
// 按日期降序排序
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
}
})3.2 文章 Frontmatter 规范
yaml
---
title: 我的第一篇博客文章
date: 2024-01-15
description: 这是我使用 VitePress 搭建博客后的第一篇文章
tags:
- VitePress
- 博客
category: 技术分享
author: 博主
---
文章内容...步骤四:开发核心组件
4.1 文章列表组件
vue
<!-- .vitepress/theme/components/PostList.vue -->
<template>
<div class="post-list">
<PostCard
v-for="post in filteredPosts"
:key="post.url"
:post="post"
/>
<div v-if="filteredPosts.length === 0" class="empty-state">
<p>暂无文章</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { data as posts } from '../../posts.data'
import PostCard from './PostCard.vue'
interface Props {
limit?: number
tag?: string
category?: string
}
const props = withDefaults(defineProps<Props>(), {
limit: 10,
tag: '',
category: ''
})
const filteredPosts = computed(() => {
let result = posts
// 按标签过滤
if (props.tag) {
result = result.filter(post =>
post.tags.includes(props.tag)
)
}
// 按分类过滤
if (props.category) {
result = result.filter(post =>
post.category === props.category
)
}
// 限制数量
return result.slice(0, props.limit)
})
</script>
<style scoped>
.post-list {
display: grid;
gap: 1.5rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--vp-c-text-2);
}
</style>4.2 文章卡片组件
vue
<!-- .vitepress/theme/components/PostCard.vue -->
<template>
<article class="post-card">
<a :href="post.url" class="post-link">
<div class="post-meta">
<time :datetime="post.date">{{ formatDate(post.date) }}</time>
<span class="reading-time">{{ post.readingTime }} 分钟阅读</span>
</div>
<h2 class="post-title">{{ post.title }}</h2>
<p v-if="post.description" class="post-description">
{{ post.description }}
</p>
<div class="post-tags">
<span
v-for="tag in post.tags"
:key="tag"
class="tag"
>
#{{ tag }}
</span>
</div>
</a>
</article>
</template>
<script setup lang="ts">
interface Post {
title: string
url: string
date: string
description: string
tags: string[]
category: string
author: string
readingTime: number
}
interface Props {
post: Post
}
defineProps<Props>()
function formatDate(date: string) {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
</script>
<style scoped>
.post-card {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
transition: all 0.3s ease;
}
.post-card:hover {
border-color: var(--vp-c-brand-1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.post-link {
text-decoration: none;
color: inherit;
}
.post-meta {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 0.75rem;
}
.post-title {
font-size: 1.4rem;
margin: 0 0 0.75rem;
color: var(--vp-c-text-1);
transition: color 0.2s;
}
.post-card:hover .post-title {
color: var(--vp-c-brand-1);
}
.post-description {
color: var(--vp-c-text-2);
line-height: 1.6;
margin: 0 0 1rem;
}
.post-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tag {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-1);
border-radius: 12px;
font-size: 0.8rem;
transition: all 0.2s;
}
.tag:hover {
background: var(--vp-c-brand-1);
color: white;
}
</style>4.3 标签列表组件
vue
<!-- .vitepress/theme/components/TagList.vue -->
<template>
<div class="tag-list">
<h2>标签</h2>
<div class="tags">
<a
v-for="tag in tags"
:key="tag.name"
:href="`/tags?tag=${encodeURIComponent(tag.name)}`"
class="tag-item"
:class="{ active: currentTag === tag.name }"
>
{{ tag.name }}
<span class="count">{{ tag.count }}</span>
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vitepress'
import { data as posts } from '../../posts.data'
interface Tag {
name: string
count: number
}
const route = useRoute()
const tags = computed<Tag[]>(() => {
const tagMap = new Map<string, number>()
posts.forEach(post => {
post.tags.forEach(tag => {
tagMap.set(tag, (tagMap.get(tag) || 0) + 1)
})
})
return Array.from(tagMap.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
})
const currentTag = computed(() => {
const params = new URLSearchParams(route.search)
return params.get('tag') || ''
})
</script>
<style scoped>
.tag-list {
padding: 2rem;
}
.tag-list h2 {
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--vp-c-bg-soft);
border-radius: 20px;
text-decoration: none;
color: var(--vp-c-text-1);
font-size: 0.9rem;
transition: all 0.2s;
}
.tag-item:hover,
.tag-item.active {
background: var(--vp-c-brand-1);
color: white;
}
.count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.4rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 10px;
font-size: 0.75rem;
}
.tag-item:hover .count,
.tag-item.active .count {
background: rgba(255, 255, 255, 0.2);
}
</style>4.4 归档列表组件
vue
<!-- .vitepress/theme/components/ArchiveList.vue -->
<template>
<div class="archive-list">
<div
v-for="(group, year) in groupedPosts"
:key="year"
class="archive-year"
>
<h2 class="year-title">{{ year }}</h2>
<div class="post-items">
<a
v-for="post in group"
:key="post.url"
:href="post.url"
class="post-item"
>
<time class="post-date">{{ formatDate(post.date) }}</time>
<h3 class="post-title">{{ post.title }}</h3>
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { data as posts } from '../../posts.data'
const groupedPosts = computed(() => {
const groups: Record<string, typeof posts> = {}
posts.forEach(post => {
const year = new Date(post.date).getFullYear().toString()
if (!groups[year]) {
groups[year] = []
}
groups[year].push(post)
})
// 按年份降序排序
const sortedYears = Object.keys(groups).sort((a, b) =>
parseInt(b) - parseInt(a)
)
const result: Record<string, typeof posts> = {}
sortedYears.forEach(year => {
result[year] = groups[year]
})
return result
})
function formatDate(date: string) {
return new Date(date).toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric'
})
}
</script>
<style scoped>
.archive-list {
padding: 2rem;
}
.archive-year {
margin-bottom: 3rem;
}
.year-title {
font-size: 2rem;
color: var(--vp-c-brand-1);
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--vp-c-divider);
}
.post-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.post-item {
display: flex;
gap: 1.5rem;
padding: 1rem;
text-decoration: none;
color: inherit;
border-radius: 8px;
transition: background 0.2s;
}
.post-item:hover {
background: var(--vp-c-bg-soft);
}
.post-date {
flex-shrink: 0;
width: 100px;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.post-title {
margin: 0;
font-size: 1.1rem;
color: var(--vp-c-text-1);
transition: color 0.2s;
}
.post-item:hover .post-title {
color: var(--vp-c-brand-1);
}
</style>4.5 评论组件
vue
<!-- .vitepress/theme/components/Comment.vue -->
<template>
<div class="comment-container">
<h3>评论</h3>
<!-- 使用 Giscus -->
<div class="giscus-container">
<component
:is="'script'"
src="https://giscus.app/client.js"
data-repo="yourusername/yourrepo"
data-repo-id="your-repo-id"
data-category="Announcements"
data-category-id="your-category-id"
data-mapping="pathname"
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="top"
data-theme="preferred_color_scheme"
data-lang="zh-CN"
crossorigin="anonymous"
async
/>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
const loaded = ref(false)
onMounted(() => {
loaded.value = true
})
</script>
<style scoped>
.comment-container {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--vp-c-divider);
}
.comment-container h3 {
margin-bottom: 1.5rem;
font-size: 1.3rem;
}
.giscus-container {
min-height: 200px;
}
</style>步骤五:创建页面
5.1 首页
markdown
---
layout: home
hero:
name: 我的博客
text: 记录学习与生活
tagline: 前端开发、技术分享、个人成长
image:
src: /avatar.jpg
alt: Avatar
---
<PostList :limit="5" />5.2 标签页
markdown
---
title: 标签
---
<TagList />
<PostList :tag="$route.query.tag" />5.3 归档页
markdown
---
title: 归档
---
# 文章归档
<ArchiveList />5.4 关于页
markdown
---
title: 关于
---
# 关于我
<img src="/avatar.jpg" alt="Avatar" style="width: 150px; border-radius: 50%;" />
## 简介
你好,我是一名前端开发工程师。
## 联系方式
- GitHub: [@yourusername](https://github.com/yourusername)
- Email: your.email@example.com
## 技能
- 前端开发:Vue、React、TypeScript
- 后端开发:Node.js、Express
- 工具:Git、Docker、CI/CD步骤六:样式定制
6.1 全局样式
css
/* .vitepress/theme/styles/index.css */
@import './variables.css';
/* 平滑滚动 */
html {
scroll-behavior: smooth;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--vp-c-bg);
}
::-webkit-scrollbar-thumb {
background: var(--vp-c-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--vp-c-text-3);
}
/* 代码块优化 */
div[class*='language-'] {
border-radius: 8px;
margin: 1rem 0;
}
/* 图片优化 */
img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
/* 链接样式 */
a {
text-decoration: none;
transition: color 0.2s;
}
/* 文章内容优化 */
.vp-doc {
line-height: 1.8;
}
.vp-doc h1,
.vp-doc h2,
.vp-doc h3,
.vp-doc h4,
.vp-doc h5,
.vp-doc h6 {
margin-top: 2rem;
}6.2 CSS 变量
css
/* .vitepress/theme/styles/variables.css */
:root {
/* 主色调 */
--vp-c-brand-1: #667eea;
--vp-c-brand-2: #764ba2;
--vp-c-brand-3: #f093fb;
--vp-c-brand-soft: rgba(102, 126, 234, 0.14);
/* 按钮颜色 */
--vp-button-brand-border: var(--vp-c-brand-1);
--vp-button-brand-bg: var(--vp-c-brand-1);
--vp-button-brand-hover-border: var(--vp-c-brand-2);
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
/* 链接颜色 */
--vp-link-color: var(--vp-c-brand-1);
--vp-link-hover-color: var(--vp-c-brand-2);
/* 导航高度 */
--vp-nav-height: 64px;
/* 侧边栏宽度 */
--vp-sidebar-width: 272px;
}
/* 深色模式 */
.dark {
--vp-c-brand-1: #a5b4fc;
--vp-c-brand-2: #818cf8;
--vp-c-brand-3: #c4b5fd;
--vp-c-brand-soft: rgba(165, 180, 252, 0.14);
}步骤七:优化与扩展
7.1 SEO 优化
typescript
// .vitepress/config.ts
export default defineConfig({
// ...其他配置
head: [
['meta', { name: 'author', content: '博主' }],
['meta', { name: 'keywords', content: '博客,前端,Vue,VitePress' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: '我的博客' }],
['meta', { name: 'twitter:card', content: 'summary_large_image' }]
],
sitemap: {
hostname: 'https://yourdomain.com'
},
robots: {
allow: '/'
}
})7.2 性能优化
typescript
// .vitepress/config.ts
export default defineConfig({
// ...其他配置
vite: {
build: {
// 代码分割
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue'],
'theme': ['vitepress']
}
}
}
}
}
})7.3 部署配置
创建部署脚本:
bash
#!/bin/bash
# deploy.sh
set -e
# 构建
npm run build
# 部署到 GitHub Pages
cd .vitepress/dist
git init
git add -A
git commit -m 'deploy'
git push -f git@github.com:yourusername/yourusername.github.io.git main
cd -完成检查清单
使用以下清单确保你的博客主题已完成:
基础功能
- [x] 项目初始化完成
- [x] 主题布局创建
- [x] 文章数据加载器
- [x] 文章列表显示
- [x] 文章卡片组件
高级功能
- [x] 标签系统
- [x] 分类系统
- [x] 归档页面
- [x] 评论集成
- [x] SEO 优化
样式优化
- [x] CSS 变量定制
- [x] 响应式设计
- [x] 深色模式支持
- [x] 平滑过渡动画
性能优化
- [x] 代码分割
- [x] 图片优化
- [x] 缓存策略
- [x] 构建优化