SELECTED PROJECTS

CaiYa_Blog:Vue 3 个人博客原型

记录早期纯前端博客的路由、Markdown 渲染、主题状态与部署经验。

阅读设置

用 Vue 3 + Vite 从零搭建个人博客 —— CaiYa_Blog 开发全记录

菜牙爱吃菜 · 前端 / Vue 3 / 个人项目


前言:为什么要自己写博客?

说实话,用 Hexo、Hugo 这些现成的静态博客框架,十分钟就能搭出一个漂亮的站点。但我偏不——我想自己写一个。

原因有三:

  1. 学习目的:我在学 AI Agent 开发,天天和 Go、Wails、Vue 打交道,想通过一个完整的前端项目把 Vue 3 的组合式 API、路由、状态管理系统地练一遍。
  2. 可控性:自己写的博客,想加什么功能加什么功能,不用等框架作者排期。
  3. 折腾的快乐:从零到上线,踩坑、填坑、再踩坑——这种体验用现成框架是得不到的。

于是就有了 CaiYa_Blog——一个基于 Vue 3 + Vite 的个人博客网站。


一、项目介绍

CaiYa_Blog 是一个纯前端的个人博客系统,核心功能:

  • 文章列表与详情页
  • 文章分类与标签
  • 搜索功能
  • 深色模式切换
  • 响应式布局(移动端适配)

项目地址:CaiYa_Blog(公开仓库)

CaiYa_Blog/
├── caiya_bg/              # 主项目(Vue 3 + Vite)
│   ├── src/
│   │   ├── components/    # 通用组件
│   │   ├── views/         # 页面
│   │   ├── router/        # 路由
│   │   ├── stores/        # Pinia 状态管理
│   │   ├── assets/        # 静态资源
│   │   └── utils/         # 工具函数
│   ├── public/
│   ├── vite.config.ts
│   ├── package.json
│   └── tsconfig.json
├── BUILD_AND_DEPLOY.md    # 构建与部署文档
├── FAVICON_SETUP.md       # Favicon 配置指南
└── UPLOAD_GUIDE.md        # 上传指南

二、技术栈

层面 选型 理由
框架 Vue 3(Composition API) 组合式逻辑复用比 mixin 强太多
构建 Vite 5 冷启动毫秒级,HMR 秒级,开发体验拉满
语言 TypeScript 配合 Volar 插件,类型提示非常舒服
路由 Vue Router 4 官方亲儿子,支持路由懒加载
状态管理 Pinia 比 Vuex 简洁,TS 支持一流
样式 SCSS + CSS 变量 深色模式靠 CSS 变量无缝切换
Markdown marked + highlight.js 文章用 Markdown 写,前端渲染
部署 GitHub Pages / Vercel 免费,push 即部署

三、核心实现

1. Vite 配置:多页面与路径别名

博客项目需要多个入口(文章页、关于页等),Vite 的 build.rollupOptions.input 可以配置多页面:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
  build: {
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'index.html'),
        about: resolve(__dirname, 'about.html'),
      },
    },
  },
})

2. 路由设计:文章详情页

用动态路由匹配文章 slug,配合懒加载减少首屏体积:

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
  },
  {
    path: '/post/:slug',
    name: 'Post',
    component: () => import('@/views/Post.vue'),
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue'),
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/404.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, _from, savedPosition) {
    if (savedPosition) return savedPosition
    return { top: 0 }
  },
})

export default router

3. Markdown 渲染

用 marked 解析 Markdown,highlight.js 做代码高亮:

// utils/markdown.ts
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'

marked.setOptions({
  highlight(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang }).value
    }
    return code
  },
})

export function renderMarkdown(content: string): string {
  return DOMPurify.sanitize(marked.parse(content) as string)
}
<!-- views/Post.vue -->
<template>
  <article class="post-content" v-html="renderedContent" />
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { renderMarkdown } from '@/utils/markdown'

const route = useRoute()
const renderedContent = ref('')

onMounted(async () => {
  const slug = route.params.slug as string
  const response = await fetch(`${import.meta.env.BASE_URL}posts/${encodeURIComponent(slug)}.md`)
  const markdown = await response.text()
  renderedContent.value = renderMarkdown(markdown)
})
</script>

4. 深色模式:CSS 变量切换

不用第三方库,纯 CSS 变量 + 一个 class 切换就搞定了:

// assets/styles/variables.scss
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f5f5f5;
  --text-primary: #333333;
  --text-secondary: #666666;
  --accent: #42b883;
}

html.dark {
  --bg-primary: #1a1a1a;
  --bg-secondary: #2d2d2d;
  --text-primary: #e0e0e0;
  --text-secondary: #aaaaaa;
  --accent: #42b883;
}
// stores/theme.ts
import { defineStore } from 'pinia'

export const useThemeStore = defineStore('theme', {
  state: () => ({
    isDark: localStorage.getItem('theme') === 'dark',
  }),
  actions: {
    toggle() {
      this.isDark = !this.isDark
      document.documentElement.classList.toggle('dark', this.isDark)
      localStorage.setItem('theme', this.isDark ? 'dark' : 'light')
    },
  },
})

四、部署经验

本地构建

## build-all.ps1
cd caiya_bg
npm install
npm run build

build-all.ps1 是个简单的 PowerShell 脚本,一键安装依赖 + 构建。可在 PowerShell 中运行,能否直接双击取决于文件关联和执行策略。

部署到 GitHub Pages

  1. 构建产物在 caiya_bg/dist/ 目录下
  2. 在 vite.config.ts 里设置 base: '/CaiYa_Blog/'(仓库名)
  3. 用 gh-pages 分支推送:
npm install -D gh-pages
npx gh-pages -d caiya_bg/dist
  1. 在 GitHub 仓库 Settings → Pages 里选 gh-pages 分支

踩坑:GitHub Pages 的 404 问题

部署后发现子路由(如 /post/hello-world)直接 404 了。原因是 GitHub Pages 的静态服务器没有 SPA 回退。

解决方案:在 public/ 目录下加一个 404.html,内容与 index.html 相同,这样所有路径都会返回首页,由前端路由接管。


五、经验教训

1. Vue 3 的 ref 和 reactive 别滥用

一开始我图省事,所有状态都用 reactive。结果在模板里解构后失去响应式,排查了半小时。

// 错误写法 ❌
const state = reactive({ count: 0 })
const { count } = state  // 失去响应式!

// 正确写法 ✅
const count = ref(0)
// 或者用 toRefs
const state = reactive({ count: 0 })
const { count } = toRefs(state)

2. TypeScript 严格模式值得开

tsconfig.json 里 "strict": true 一定要开。虽然前期写起来痛苦,但能避免大量运行时错误。我项目里因为开了 strict,至少提前发现了 3-4 个 undefined 访问的 bug。

3. Vite 的 base 路径在开发和生产环境不同

开发时 base: '/',部署到 GitHub Pages 时 base: '/CaiYa_Blog/'。如果忘了改,部署后所有静态资源都会 404。

建议:用环境变量区分:

// vite.config.ts
export default defineConfig({
  base: process.env.VITE_BASE_URL || '/',
})

4. Pinia 的 store 设计要克制

一开始我把所有逻辑都塞进 store,结果 store 越写越大。后来学乖了——store 只存状态和方法,业务逻辑写在组件或 composable 里。

5. 别过度设计

博客项目不需要 SSR、不需要 PWA、不需要微前端。先把核心功能跑通,再考虑优化。我一开始想加 SSR,研究了半天 Nuxt 3 发现完全没必要——个人博客的 SEO 用静态生成就够了。

6. 写文档是给自己看的

项目写了 BUILD_AND_DEPLOY.md、FAVICON_SETUP.md、UPLOAD_GUIDE.md 三份文档。半年后回头看,如果没有这些文档,我可能连构建命令都忘了。写文档的成本远低于重新踩坑的成本。


六、后续计划

  • [ ] 接入 Gitalk 或 Giscus 评论系统
  • [ ] 添加文章 RSS 订阅
  • [ ] 接入 Algolia 全文搜索
  • [ ] 写一个简单的静态生成器(类似 VitePress),构建时把 Markdown 编译成 HTML
  • [ ] 支持文章草稿与定时发布

写在最后

CaiYa_Blog 从构思到上线大概花了两周的业余时间。代码量不大(Vue 306KB + JS 33KB),但五脏俱全——路由、状态管理、Markdown 渲染、深色模式、响应式布局,该有的都有。

这个项目最大的收获不是"我会用 Vue 3 了",而是我完整地走了一遍从开发到部署的全流程。从写代码、构建、部署、到排查线上问题——每一步都有新的教训。

如果你也想自己写一个博客,别犹豫,动手就完了。踩坑不可怕,可怕的是从未开始。


我是菜牙爱吃菜,一个在 Go/Vue/Python 之间反复横跳的开发者。保持学习,保持分享。

版本与部署补充

marked 的 highlight 选项在部分新版本已移除,需按版本使用扩展或独立高亮流程。v-html 前必须清洗不可信 HTML;原型加入清洗示意后仍需安装依赖并测试。GitHub Pages 的 404.html 回退仍可能返回 HTTP 404,不能当作真正的服务端路由。只有实现预渲染/静态生成后才具备相应 SEO 内容,当前纯 SPA 不自动获得这些能力。

Vue 3ViteTypeScriptPinia