个人博客网站
使用 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 上查看。
在线演示
- 博客网站: blog.example.com
- 源代码: GitHub Repository
通过这个案例,你可以学习如何使用 VitePress 构建功能完整的个人博客网站,包括文章管理、标签分类、评论系统等核心功能。