【实战】从零搭建个人博客:技术选型到部署上线
完整的博客搭建路线:Nuxt 3 + Content Module + Tailwind。包括项目结构、代码高亮、图片优化、SEO、部署,以及踩过的坑和解决方案。
13 分钟阅读
小明
【实战】从零搭建个人博客:技术选型到部署上线
搭博客看起来简单:写 Markdown,部署上线。实际上有一堆细节:内容怎么组织,图片怎么优化,代码高亮怎么配,SEO 怎么做,部署怎么自动化。
这篇是我搭 xiaoming.wiki 的完整经验总结。一步步照着做,1 周就能有个能用的博客。
1. 先定好选型:别纠结,选框架性更强的
三种方案的对比(用数据说话):
| 方案 | 学习成本 | 开发速度 | SEO | 代码量 | 扩展能力 |
|---|---|---|---|---|---|
| Nuxt 3 | ⭐⭐ | 2 天 | ✅ SSR | 少(框架帮你) | 可做完整网站 |
| Next.js | ⭐⭐⭐ | 3 天 | ✅ SSR,需配置 | 中等 | 可做完整网站 |
| Hugo | ⭐ | 1 天 | ✅ 静态最优 | 无(配置为主) | 纯博客 |
为什么选 Nuxt 3:
- Vue 用户最熟悉,学习成本最低
- @nuxt/content 模块完全是为 Markdown 博客设计的
- 开箱有:路由、TOC 生成、代码高亮、图片优化
- 后期加功能(评论、用户系统)无缝扩展
- 最关键:不需要数据库,Markdown 文件 + Git 版本控制就够了
2. 从零开始:项目初始化与配置
# 1. 创建项目
npx nuxi@latest init blog-app
cd blog-app
pnpm install
# 2. 安装 Content Module 和依赖
pnpm add -D @nuxt/content @nuxt/fonts
pnpm add shiki # 代码高亮引擎
关键文件:nuxt.config.ts (这是博客项目的核心配置)
export default defineNuxtConfig({
modules: [
'@nuxt/content',
'@nuxt/fonts'
],
content: {
// 自动路由:content/blog/xxx.md → /blog/xxx
documentDriven: true,
// 代码块语法高亮
highlight: {
theme: 'github-dark',
preload: ['ts', 'js', 'json', 'bash', 'sql', 'vue']
},
// 自动生成目录(Table of Contents)
toc: {
depth: 3,
searchAppend: true
}
},
// Google Fonts 自动加载
fonts: {
families: [
{ name: 'Inter', provider: 'google' },
{ name: 'JetBrains Mono', provider: 'google' }
]
},
// SEO 和缓存优化
routeRules: {
'/api/**': { cache: { maxAge: 600 } },
'/sitemap.xml': { cache: { maxAge: 3600 } }
}
})
3. 内容结构设计(后期易维护的关键)
最终你的项目目录应该这样:
blog-app/
├── content/
│ ├── blog/
│ │ ├── 001-hello-world.md
│ │ ├── 002-vue3-best-practices.md
│ │ ├── 003-nuxt-deployment-guide.md
│ │ └── ...
│ ├── about.md
│ └── projects.md
├── pages/
│ ├── index.vue (首页列表)
│ ├── blog/
│ │ ├── index.vue (列表+搜索)
│ │ └── [...slug].vue (文章详情)
│ ├── about.vue
│ └── projects.vue
├── components/
│ ├── ArticleCard.vue
│ ├── ArticleTOC.vue
│ └── ...
└── public/
└── images/articles/ (文章配图)
关键:Markdown frontmatter 标准化
每篇文章的 YAML 头必须包含这些字段(便于后续检索、分类、排序):
---
title: Vue 3 最佳实践指南
description: 深入理解响应式系统、组合式 API、性能优化
author: 小明
date: 2026-02-22
cover: /images/articles/vue3-best-practices.jpg
tags:
- Vue
- TypeScript
- 性能优化
published: true # false 时不显示在列表(草稿)
readingTime: 8 # 手工或用插件计算
category: guide # 用于分类
series: vue3-series # 系列文章的 ID
seriesOrder: 1 # 系列中的顺序
---
# Vue 3 最佳实践指南
正文从这里开始...
4. 关键页面实现(带真实功能)
4.1 博客列表页(支持搜索、分页、标签筛选)
<!-- pages/blog/index.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const route = useRoute()
const searchQuery = ref('')
const selectedTag = ref<string | null>(null)
const currentPage = computed(() => parseInt(route.query.page as string) || 1)
// 查询所有已发布的文章,按日期倒序
const { data: allPosts } = await useAsyncData('blog-posts', () =>
queryContent('blog')
.where({ published: { $ne: false } })
.sort({ date: -1 })
.find()
)
// 应用搜索和标签筛选
const filtered = computed(() => {
let result = allPosts.value || []
// 搜索过滤
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
result = result.filter(p =>
p.title.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.tags?.some(tag => tag.toLowerCase().includes(q))
)
}
// 标签过滤
if (selectedTag.value) {
result = result.filter(p =>
p.tags?.includes(selectedTag.value)
)
}
return result
})
// 分页
const pageSize = 12
const totalPages = computed(() =>
Math.ceil(filtered.value.length / pageSize)
)
const visiblePosts = computed(() => {
const start = (currentPage.value - 1) * pageSize
return filtered.value.slice(start, start + pageSize)
})
// 提取所有唯一标签
const allTags = computed(() => {
const tags = new Set<string>()
allPosts.value?.forEach(p => {
p.tags?.forEach(tag => tags.add(tag))
})
return Array.from(tags).sort()
})
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
</script>
<template>
<div class="max-w-5xl mx-auto px-4 py-12">
<h1 class="text-4xl font-bold mb-2">博客文章</h1>
<p class="text-gray-600 mb-8">{{ allPosts?.length || 0 }} 篇文章</p>
<!-- 搜索框 -->
<div class="mb-8">
<input
v-model="searchQuery"
type="text"
placeholder="搜索文章标题、描述或标签..."
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
/>
</div>
<!-- 标签筛选 -->
<div class="flex flex-wrap gap-2 mb-8">
<button
:class="[
'px-4 py-2 rounded-full text-sm font-medium transition',
selectedTag === null
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
]"
@click="selectedTag = null"
>
全部 ({{ filtered.length }})
</button>
<button
v-for="tag in allTags"
:key="tag"
:class="[
'px-4 py-2 rounded-full text-sm font-medium transition',
selectedTag === tag
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
]"
@click="selectedTag = selectedTag === tag ? null : tag"
>
#{{ tag }}
</button>
</div>
<!-- 文章网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<NuxtLink
v-for="post in visiblePosts"
:key="post._path"
:to="post._path"
class="group overflow-hidden rounded-lg border border-gray-200 hover:shadow-lg transition-shadow"
>
<!-- 文章封面 -->
<div class="relative h-40 bg-gray-200 overflow-hidden">
<img
v-if="post.cover"
:src="post.cover"
:alt="post.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<div v-else class="w-full h-full bg-gradient-to-br from-blue-400 to-blue-600"></div>
</div>
<!-- 文章信息 -->
<div class="p-4">
<h3 class="font-semibold text-lg line-clamp-2 group-hover:text-blue-600">
{{ post.title }}
</h3>
<p class="text-gray-600 text-sm line-clamp-2 mt-2">
{{ post.description }}
</p>
<div class="flex items-center justify-between mt-4 text-xs text-gray-500">
<time>{{ formatDate(post.date) }}</time>
<span>{{ post.readingTime }}m</span>
</div>
</div>
</NuxtLink>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center gap-2">
<NuxtLink
v-for="page in totalPages"
:key="page"
:to="{ query: { page } }"
:class="[
'px-3 py-2 rounded font-medium transition',
currentPage === page
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300'
]"
>
{{ page }}
</NuxtLink>
</div>
</div>
</template>
4.2 文章详情页(带 TOC、OpenGraph、相关推荐)
<!-- pages/blog/[...slug].vue -->
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug
// 查询并渲染文章
const { data: post } = await useAsyncData(`blog-${slug}`, () =>
queryContent('blog', slug as string).findOne()
)
// 404 处理
if (!post.value) {
throw createError({
statusCode: 404,
statusMessage: '文章不存在'
})
}
// SEO 设置:Open Graph、搜索引擎
useHead({
title: `${post.value.title} | 小明的博客`,
meta: [
{ name: 'description', content: post.value.description },
{ property: 'og:title', content: post.value.title },
{ property: 'og:description', content: post.value.description },
{ property: 'og:image', content: post.value.cover },
{ property: 'og:type', content: 'article' },
{
property: 'article:published_time',
content: new Date(post.value.date).toISOString()
}
]
})
// 相关文章(前 3 篇同标签文章)
const { data: relatedPosts } = await useAsyncData(
`related-${slug}`,
() =>
queryContent('blog')
.where({
tags: { $contains: post.value.tags?.[0] },
_path: { $ne: post.value._path },
published: { $ne: false }
})
.limit(3)
.find()
)
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
</script>
<template>
<article class="max-w-4xl mx-auto px-4 py-12">
<!-- 面包屑导航 -->
<nav class="flex items-center gap-2 text-sm text-gray-600 mb-6">
<NuxtLink to="/" class="hover:text-blue-600">首页</NuxtLink>
<span>/</span>
<NuxtLink to="/blog" class="hover:text-blue-600">文章</NuxtLink>
<span>/</span>
<span class="text-gray-900">{{ post.title }}</span>
</nav>
<!-- 文章头部 -->
<header class="mb-8 pb-8 border-b">
<h1 class="text-5xl font-bold mb-4">{{ post.title }}</h1>
<p class="text-xl text-gray-600 mb-6">{{ post.description }}</p>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-600">
<div class="flex items-center gap-2">
<span class="font-medium">{{ post.author }}</span>
</div>
<span>·</span>
<time :datetime="post.date">{{ formatDate(post.date) }}</time>
<span>·</span>
<span>{{ post.readingTime }} 分钟阅读</span>
</div>
<!-- 标签 -->
<div v-if="post.tags" class="flex flex-wrap gap-2 mt-4">
<NuxtLink
v-for="tag in post.tags"
:key="tag"
:to="`/blog?tag=${tag}`"
class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm hover:bg-blue-200"
>
#{{ tag }}
</NuxtLink>
</div>
</header>
<!-- 主图 -->
<img
v-if="post.cover"
:src="post.cover"
:alt="post.title"
class="w-full h-96 object-cover rounded-lg mb-12"
/>
<!-- 内容 -->
<div class="prose prose-lg max-w-none mb-16">
<ContentRenderer :value="post" />
</div>
<!-- 分割线 -->
<hr class="my-12" />
<!-- 相关文章推荐 -->
<section v-if="relatedPosts && relatedPosts.length > 0" class="mb-12">
<h3 class="text-2xl font-bold mb-6">相关文章</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<NuxtLink
v-for="related in relatedPosts"
:key="related._path"
:to="related._path"
class="p-4 border rounded-lg hover:shadow-lg transition-shadow"
>
<h4 class="font-semibold line-clamp-2 mb-2">{{ related.title }}</h4>
<p class="text-sm text-gray-600">{{ formatDate(related.date) }}</p>
</NuxtLink>
</div>
</section>
<!-- 底部导航 -->
<nav class="flex justify-center pt-8 border-t">
<NuxtLink to="/blog" class="text-blue-600 hover:underline">
← 回到文章列表
</NuxtLink>
</nav>
</article>
</template>
5. 坑点与解决方案
坑 1:图片路径在生产环境不显示
症状:本地 /images/xxx.jpg 正常,部署后 404
原因:不同环境的资源路径处理不同
解决:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
baseURL: process.env.BASE_URL || '/'
}
})
坑 2:部署时 ContentRenderer 报错
症状:ContentRenderer is not defined
解决:动态导入组件
<script setup>
const ContentRenderer = defineAsyncComponent(() =>
import('#components').then(m => m.ContentRenderer)
)
</script>
坑 3:搜索性能差(超过 100 篇)
解决:改用 API 端点而不是客户端过滤
// server/api/search.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event).q as string
return await queryContent('blog')
.where({ title: { $icontains: query } })
.limit(20)
.find()
})
6. 部署(Vercel 最简单)
- 项目推送到 GitHub
- https://vercel.com/new 导入项目
- 选择 Nuxt,点击 Deploy
- 每次 push 自动部署
自定义域名:Vercel 设置里直接配 DNS 记录
7. SEO 和性能清单
SEO:
- 自动生成 Sitemap
- Open Graph 标签
- Robots.txt
- Schema 标记
性能目标:
- LCP < 2.5s
- CLS < 0.1
验证:用 Google PageSpeed Insights
总结
一个能运营的博客,核心就这些:
- Nuxt 3 + Content Module(内容管理)
- 列表 + 详情 + 搜索页面
- Markdown 文件组织
- Vercel 部署
从空项目到上线:约 1 周。
之后最重要的不是技术,而是坚持写文章。