Skip to content

个人博客网站

使用 VitePress 构建现代化的个人博客网站,支持 Markdown 写作、文章分类、标签系统、评论功能和 RSS 订阅。

项目概述

个人博客是展示技术能力和分享知识的重要平台。VitePress 凭借其出色的 Markdown 支持和 Vue 组件集成能力,是构建个人博客的理想选择。

核心特性

  • ✍️ Markdown 写作 - 专注内容创作的写作体验
  • 🏷️ 标签分类 - 灵活的文章组织方式
  • 💬 评论系统 - 与读者互动交流
  • 📡 RSS 订阅 - 支持内容订阅
  • 🔍 全文搜索 - 快速查找文章内容
  • 📱 响应式设计 - 完美适配移动设备
  • 🌙 深色模式 - 护眼的阅读体验

技术架构

核心技术栈

json
{
  "framework": "VitePress",
  "language": "TypeScript",
  "styling": "CSS3 + Sass",
  "components": "Vue 3",
  "tools": [
    "Feed Generator",
    "Giscus Comments",
    "Algolia Search",
    "Reading Time"
  ]
}

项目结构

personal-blog/
├── docs/
│   ├── .vitepress/
│   │   ├── config.ts
│   │   ├── theme/
│   │   │   ├── index.ts
│   │   │   ├── Layout.vue
│   │   │   └── components/
│   │   └── utils/
│   ├── posts/
│   │   ├── 2024/
│   │   │   ├── vue3-composition-api.md
│   │   │   └── vitepress-blog-setup.md
│   │   └── 2023/
│   ├── tags/
│   ├── about/
│   └── index.md
├── scripts/
│   └── generate-feed.js
└── package.json

实现步骤

1. 项目初始化

bash
# 创建项目
npm create vitepress@latest personal-blog
cd personal-blog

# 安装依赖
npm install
npm install -D feed gray-matter reading-time

2. 配置 VitePress

typescript
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import { generateSidebar, generateFeed } from './utils'

export default defineConfig({
  title: '我的技术博客',
  description: '分享前端开发经验和技术思考',
  
  head: [
    ['link', { rel: 'icon', href: '/favicon.ico' }],
    ['meta', { name: 'author', content: '张三' }],
    ['meta', { property: 'og:type', content: 'website' }],
    ['link', { rel: 'alternate', type: 'application/rss+xml', href: '/feed.xml' }]
  ],
  
  themeConfig: {
    nav: [
      { text: '首页', link: '/' },
      { text: '文章', link: '/posts/' },
      { text: '标签', link: '/tags/' },
      { text: '关于', link: '/about/' }
    ],
    
    sidebar: generateSidebar(),
    
    socialLinks: [
      { icon: 'github', link: 'https://github.com/username' },
      { icon: 'twitter', link: 'https://twitter.com/username' }
    ],
    
    footer: {
      message: '基于 VitePress 构建',
      copyright: 'Copyright © 2024 张三'
    }
  },
  
  buildEnd: generateFeed
})

3. 自定义主题

vue
<!-- .vitepress/theme/Layout.vue -->
<template>
  <Layout>
    <template #home-hero-before>
      <div class="hero-background">
        <div class="hero-particles"></div>
      </div>
    </template>
    
    <template #doc-before>
      <ArticleMeta v-if="isArticle" :frontmatter="frontmatter" />
    </template>
    
    <template #doc-after>
      <ArticleFooter v-if="isArticle" />
      <Comments v-if="isArticle" />
    </template>
    
    <template #sidebar-nav-after>
      <TagCloud />
    </template>
  </Layout>
</template>

<script setup>
import { computed } from 'vue'
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import ArticleMeta from './components/ArticleMeta.vue'
import ArticleFooter from './components/ArticleFooter.vue'
import Comments from './components/Comments.vue'
import TagCloud from './components/TagCloud.vue'

const { Layout } = DefaultTheme
const { page, frontmatter } = useData()

const isArticle = computed(() => {
  return page.value.relativePath.startsWith('posts/')
})
</script>

<style>
.hero-background {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  opacity: 0.1;
}

.hero-particles {
  position: absolute;
  width: 100%;
  height: 100%;
  background-image: 
    radial-gradient(circle at 20% 50%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
    radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
    radial-gradient(circle at 40% 80%, rgba(120, 200, 255, 0.3) 0%, transparent 50%);
  animation: float 6s ease-in-out infinite;
}

@keyframes float {
  0%, 100% { transform: translateY(0px); }
  50% { transform: translateY(-20px); }
}
</style>

4. 文章元信息组件

vue
<!-- .vitepress/theme/components/ArticleMeta.vue -->
<template>
  <div class="article-meta">
    <div class="meta-info">
      <span class="date">
        <Icon name="calendar" />
        {{ formatDate(frontmatter.date) }}
      </span>
      
      <span v-if="readingTime" class="reading-time">
        <Icon name="clock" />
        {{ readingTime }}
      </span>
      
      <span v-if="frontmatter.author" class="author">
        <Icon name="user" />
        {{ frontmatter.author }}
      </span>
    </div>
    
    <div v-if="frontmatter.tags" class="tags">
      <Tag
        v-for="tag in frontmatter.tags"
        :key="tag"
        :name="tag"
        :link="`/tags/${tag}`"
      />
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useData } from 'vitepress'
import readingTime from 'reading-time'
import Icon from './Icon.vue'
import Tag from './Tag.vue'

const props = defineProps({
  frontmatter: Object
})

const { page } = useData()

const readingTime = computed(() => {
  const stats = readingTime(page.value.content)
  return `${stats.minutes} 分钟阅读`
})

function formatDate(date) {
  return new Date(date).toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}
</script>

<style scoped>
.article-meta {
  padding: 20px 0;
  border-bottom: 1px solid var(--vp-c-border);
  margin-bottom: 30px;
}

.meta-info {
  display: flex;
  gap: 20px;
  margin-bottom: 15px;
  font-size: 14px;
  color: var(--vp-c-text-2);
}

.meta-info span {
  display: flex;
  align-items: center;
  gap: 5px;
}

.tags {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}
</style>

5. 标签组件

vue
<!-- .vitepress/theme/components/Tag.vue -->
<template>
  <a
    :href="link"
    class="tag"
    :class="{ 'tag-large': size === 'large' }"
  >
    {{ name }}
    <span v-if="count" class="count">{{ count }}</span>
  </a>
</template>

<script setup>
defineProps({
  name: String,
  link: String,
  count: Number,
  size: {
    type: String,
    default: 'normal'
  }
})
</script>

<style scoped>
.tag {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px 8px;
  background: var(--vp-c-bg-mute);
  border: 1px solid var(--vp-c-border);
  border-radius: 12px;
  font-size: 12px;
  color: var(--vp-c-text-1);
  text-decoration: none;
  transition: all 0.2s;
}

.tag:hover {
  background: var(--vp-c-brand);
  color: white;
  border-color: var(--vp-c-brand);
}

.tag-large {
  padding: 6px 12px;
  font-size: 14px;
}

.count {
  background: var(--vp-c-text-3);
  color: white;
  padding: 1px 5px;
  border-radius: 8px;
  font-size: 10px;
}
</style>

6. 评论系统

vue
<!-- .vitepress/theme/components/Comments.vue -->
<template>
  <div class="comments-section">
    <h3>评论</h3>
    <div ref="commentsContainer" class="comments-container"></div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue'
import { useData, useRoute } from 'vitepress'

const commentsContainer = ref()
const { isDark } = useData()
const route = useRoute()

onMounted(() => {
  loadGiscus()
})

watch(() => route.path, () => {
  // 路由变化时重新加载评论
  if (commentsContainer.value) {
    commentsContainer.value.innerHTML = ''
    loadGiscus()
  }
})

watch(isDark, () => {
  // 主题变化时更新评论主题
  updateGiscusTheme()
})

function loadGiscus() {
  const script = document.createElement('script')
  script.src = 'https://giscus.app/client.js'
  script.setAttribute('data-repo', 'username/blog-comments')
  script.setAttribute('data-repo-id', 'your-repo-id')
  script.setAttribute('data-category', 'General')
  script.setAttribute('data-category-id', 'your-category-id')
  script.setAttribute('data-mapping', 'pathname')
  script.setAttribute('data-strict', '0')
  script.setAttribute('data-reactions-enabled', '1')
  script.setAttribute('data-emit-metadata', '0')
  script.setAttribute('data-input-position', 'bottom')
  script.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
  script.setAttribute('data-lang', 'zh-CN')
  script.crossOrigin = 'anonymous'
  script.async = true
  
  commentsContainer.value?.appendChild(script)
}

function updateGiscusTheme() {
  const iframe = document.querySelector('iframe.giscus-frame')
  if (iframe) {
    iframe.contentWindow?.postMessage({
      giscus: {
        setConfig: {
          theme: isDark.value ? 'dark' : 'light'
        }
      }
    }, 'https://giscus.app')
  }
}
</script>

<style scoped>
.comments-section {
  margin-top: 50px;
  padding-top: 30px;
  border-top: 1px solid var(--vp-c-border);
}

.comments-section h3 {
  margin-bottom: 20px;
  color: var(--vp-c-text-1);
}

.comments-container {
  min-height: 200px;
}
</style>

7. 文章列表页面

markdown
<!-- docs/posts/index.md -->
---
title: "所有文章"
layout: page
---

<script setup>
import { data as posts } from '../.vitepress/utils/posts.data.js'
import PostList from '../.vitepress/theme/components/PostList.vue'
</script>

# 所有文章

<PostList :posts="posts" />
vue
<!-- .vitepress/theme/components/PostList.vue -->
<template>
  <div class="post-list">
    <div v-for="post in sortedPosts" :key="post.url" class="post-item">
      <div class="post-date">
        {{ formatDate(post.frontmatter.date) }}
      </div>
      
      <div class="post-content">
        <h3 class="post-title">
          <a :href="post.url">{{ post.frontmatter.title }}</a>
        </h3>
        
        <p v-if="post.frontmatter.description" class="post-description">
          {{ post.frontmatter.description }}
        </p>
        
        <div class="post-meta">
          <div v-if="post.frontmatter.tags" class="post-tags">
            <Tag
              v-for="tag in post.frontmatter.tags"
              :key="tag"
              :name="tag"
              :link="`/tags/${tag}`"
            />
          </div>
          
          <div class="post-reading-time">
            {{ getReadingTime(post.content) }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import readingTime from 'reading-time'
import Tag from './Tag.vue'

const props = defineProps({
  posts: Array
})

const sortedPosts = computed(() => {
  return props.posts
    .filter(post => !post.frontmatter.draft)
    .sort((a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date))
})

function formatDate(date) {
  return new Date(date).toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit'
  })
}

function getReadingTime(content) {
  const stats = readingTime(content)
  return `${stats.minutes} 分钟阅读`
}
</script>

<style scoped>
.post-list {
  max-width: 800px;
}

.post-item {
  display: flex;
  gap: 20px;
  padding: 20px 0;
  border-bottom: 1px solid var(--vp-c-border);
}

.post-item:last-child {
  border-bottom: none;
}

.post-date {
  flex-shrink: 0;
  width: 100px;
  font-size: 14px;
  color: var(--vp-c-text-2);
  font-family: var(--vp-font-family-mono);
}

.post-content {
  flex: 1;
}

.post-title {
  margin: 0 0 10px 0;
  font-size: 18px;
  font-weight: 600;
}

.post-title a {
  color: var(--vp-c-text-1);
  text-decoration: none;
}

.post-title a:hover {
  color: var(--vp-c-brand);
}

.post-description {
  margin: 0 0 15px 0;
  color: var(--vp-c-text-2);
  line-height: 1.6;
}

.post-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.post-tags {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

.post-reading-time {
  font-size: 12px;
  color: var(--vp-c-text-3);
}

@media (max-width: 768px) {
  .post-item {
    flex-direction: column;
    gap: 10px;
  }
  
  .post-date {
    width: auto;
  }
  
  .post-meta {
    flex-direction: column;
    align-items: flex-start;
    gap: 10px;
  }
}
</style>

8. RSS 订阅生成

javascript
// .vitepress/utils/generateFeed.js
import { Feed } from 'feed'
import { writeFileSync } from 'fs'
import { resolve } from 'path'
import { SiteConfig } from 'vitepress'

export async function generateFeed(config: SiteConfig) {
  const feed = new Feed({
    title: config.site.title,
    description: config.site.description,
    id: config.site.base,
    link: config.site.base,
    language: 'zh-CN',
    favicon: `${config.site.base}/favicon.ico`,
    copyright: 'Copyright © 2024 张三',
    author: {
      name: '张三',
      email: 'zhangsan@example.com',
      link: config.site.base
    }
  })

  // 获取所有文章
  const posts = await getPosts()
  
  posts.forEach(post => {
    feed.addItem({
      title: post.frontmatter.title,
      id: `${config.site.base}${post.url}`,
      link: `${config.site.base}${post.url}`,
      description: post.frontmatter.description,
      content: post.html,
      author: [{
        name: post.frontmatter.author || '张三',
        email: 'zhangsan@example.com'
      }],
      date: new Date(post.frontmatter.date)
    })
  })

  writeFileSync(resolve(config.outDir, 'feed.xml'), feed.rss2())
  writeFileSync(resolve(config.outDir, 'feed.json'), feed.json1())
}

高级功能

1. 文章搜索

vue
<!-- .vitepress/theme/components/SearchBox.vue -->
<template>
  <div class="search-box">
    <input
      v-model="searchQuery"
      type="text"
      placeholder="搜索文章..."
      class="search-input"
      @input="handleSearch"
    />
    
    <div v-if="searchResults.length" class="search-results">
      <div
        v-for="result in searchResults"
        :key="result.url"
        class="search-result"
      >
        <a :href="result.url" class="result-title">
          {{ result.title }}
        </a>
        <p class="result-excerpt">{{ result.excerpt }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { data as posts } from '../../utils/posts.data.js'

const searchQuery = ref('')
const searchResults = computed(() => {
  if (!searchQuery.value) return []
  
  const query = searchQuery.value.toLowerCase()
  return posts
    .filter(post => 
      post.frontmatter.title.toLowerCase().includes(query) ||
      post.content.toLowerCase().includes(query) ||
      post.frontmatter.tags?.some(tag => tag.toLowerCase().includes(query))
    )
    .slice(0, 5)
    .map(post => ({
      url: post.url,
      title: post.frontmatter.title,
      excerpt: getExcerpt(post.content, query)
    }))
})

function getExcerpt(content, query) {
  const index = content.toLowerCase().indexOf(query)
  if (index === -1) return content.slice(0, 100) + '...'
  
  const start = Math.max(0, index - 50)
  const end = Math.min(content.length, index + 50)
  return '...' + content.slice(start, end) + '...'
}

function handleSearch() {
  // 可以添加防抖逻辑
}
</script>

2. 文章推荐

vue
<!-- .vitepress/theme/components/RelatedPosts.vue -->
<template>
  <div v-if="relatedPosts.length" class="related-posts">
    <h3>相关文章</h3>
    <div class="posts-grid">
      <article
        v-for="post in relatedPosts"
        :key="post.url"
        class="related-post"
      >
        <a :href="post.url" class="post-link">
          <h4>{{ post.frontmatter.title }}</h4>
          <p>{{ post.frontmatter.description }}</p>
          <div class="post-date">
            {{ formatDate(post.frontmatter.date) }}
          </div>
        </a>
      </article>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useData } from 'vitepress'
import { data as posts } from '../../utils/posts.data.js'

const { page } = useData()

const relatedPosts = computed(() => {
  const currentPost = posts.find(p => p.url === page.value.relativePath.replace('.md', ''))
  if (!currentPost) return []
  
  const currentTags = currentPost.frontmatter.tags || []
  
  return posts
    .filter(post => post.url !== currentPost.url)
    .map(post => ({
      ...post,
      score: calculateSimilarity(currentTags, post.frontmatter.tags || [])
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 3)
})

function calculateSimilarity(tags1, tags2) {
  const intersection = tags1.filter(tag => tags2.includes(tag))
  const union = [...new Set([...tags1, ...tags2])]
  return intersection.length / union.length
}

function formatDate(date) {
  return new Date(date).toLocaleDateString('zh-CN')
}
</script>

部署配置

Vercel 部署

json
{
  "name": "personal-blog",
  "version": "2",
  "builds": [
    {
      "src": "package.json",
      "use": "@vercel/static-build",
      "config": {
        "distDir": "docs/.vitepress/dist"
      }
    }
  ],
  "routes": [
    {
      "handle": "filesystem"
    },
    {
      "src": "/(.*)",
      "status": 404,
      "dest": "/404.html"
    }
  ]
}

GitHub Actions

yaml
name: Deploy Blog

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
      with:
        fetch-depth: 0
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Build
      run: npm run build
    
    - name: Deploy to GitHub Pages
      uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_dir: docs/.vitepress/dist

写作工作流

1. 文章模板

bash
# scripts/new-post.js
const fs = require('fs')
const path = require('path')

const title = process.argv[2]
if (!title) {
  console.error('请提供文章标题')
  process.exit(1)
}

const slug = title.toLowerCase().replace(/\s+/g, '-')
const date = new Date().toISOString().split('T')[0]
const year = new Date().getFullYear()

const template = `---
title: "${title}"
description: ""
date: ${date}
tags: []
author: "张三"
---

# ${title}

文章内容...
`

const dir = path.join(__dirname, `../docs/posts/${year}`)
if (!fs.existsSync(dir)) {
  fs.mkdirSync(dir, { recursive: true })
}

const filePath = path.join(dir, `${slug}.md`)
fs.writeFileSync(filePath, template)

console.log(`文章已创建: ${filePath}`)

2. 图片优化

javascript
// scripts/optimize-images.js
const sharp = require('sharp')
const fs = require('fs')
const path = require('path')

async function optimizeImages() {
  const imagesDir = path.join(__dirname, '../docs/public/images')
  const files = fs.readdirSync(imagesDir)
  
  for (const file of files) {
    if (!/\.(jpg|jpeg|png)$/i.test(file)) continue
    
    const inputPath = path.join(imagesDir, file)
    const outputPath = path.join(imagesDir, 'optimized', file)
    
    await sharp(inputPath)
      .resize(1200, null, { withoutEnlargement: true })
      .jpeg({ quality: 80 })
      .toFile(outputPath)
    
    console.log(`优化完成: ${file}`)
  }
}

optimizeImages()

最佳实践

1. SEO 优化

  • 结构化数据 - 添加 JSON-LD 标记
  • Open Graph - 社交媒体分享优化
  • 站点地图 - 自动生成 sitemap.xml

2. 性能优化

  • 图片懒加载 - 提升页面加载速度
  • 代码分割 - 按需加载组件
  • 缓存策略 - 合理设置缓存头

3. 用户体验

  • 阅读进度 - 显示文章阅读进度
  • 目录导航 - 长文章的快速导航
  • 返回顶部 - 便捷的页面导航

示例项目

完整的示例项目可以在 GitHub 上查看。

在线演示


通过这个案例,你可以学习如何使用 VitePress 构建功能完整的个人博客网站,包括文章管理、标签分类、评论系统等核心功能。

vitepress开发指南