【实战】从零搭建个人博客:技术选型到部署上线

完整的博客搭建路线:Nuxt 3 + Content Module + Tailwind。包括项目结构、代码高亮、图片优化、SEO、部署,以及踩过的坑和解决方案。

13 分钟阅读
小明

【实战】从零搭建个人博客:技术选型到部署上线

搭博客看起来简单:写 Markdown,部署上线。实际上有一堆细节:内容怎么组织,图片怎么优化,代码高亮怎么配,SEO 怎么做,部署怎么自动化。

这篇是我搭 xiaoming.wiki 的完整经验总结。一步步照着做,1 周就能有个能用的博客。


1. 先定好选型:别纠结,选框架性更强的

三种方案的对比(用数据说话):

方案学习成本开发速度SEO代码量扩展能力
Nuxt 3⭐⭐2 天✅ SSR少(框架帮你)可做完整网站
Next.js⭐⭐⭐3 天✅ SSR,需配置中等可做完整网站
Hugo1 天✅ 静态最优无(配置为主)纯博客

为什么选 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 最简单)

  1. 项目推送到 GitHub
  2. https://vercel.com/new 导入项目
  3. 选择 Nuxt,点击 Deploy
  4. 每次 push 自动部署

自定义域名:Vercel 设置里直接配 DNS 记录


7. SEO 和性能清单

SEO

  • 自动生成 Sitemap
  • Open Graph 标签
  • Robots.txt
  • Schema 标记

性能目标

  • LCP < 2.5s
  • CLS < 0.1

验证:用 Google PageSpeed Insights


总结

一个能运营的博客,核心就这些:

  1. Nuxt 3 + Content Module(内容管理)
  2. 列表 + 详情 + 搜索页面
  3. Markdown 文件组织
  4. Vercel 部署

从空项目到上线:约 1 周。

之后最重要的不是技术,而是坚持写文章。