Skip to content

主题开发实战:博客主题

本教程将带你从零开始开发一个功能完整的 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 vue

1.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 Theme

2.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] 构建优化

下一步

相关资源

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献