一、引言:为什么选择 Vue 开发博客?

在静态博客生成器(如 Hexo、Jekyll)和动态博客框架(如 WordPress)占据主流的当下,选择 Vue 手动搭建博客似乎是 “自讨苦吃”。但作为前端开发者,用 Vue 构建博客有三个不可替代的优势:

  1. 完全自定义:摆脱模板限制,从 UI 到功能全由自己掌控,打造独一无二的博客风格;
  1. 技术练兵场:将 Vue 生态(Vue Router、Pinia、Vite 等)融会贯通,实战中提升工程化能力;
  1. 前后端分离优势:前端负责渲染,后端仅提供接口,部署灵活(可静态托管 + 云函数),访问速度更快。

本文将以 Vue 3 + Vite + Pinia + Vue Router 为核心技术栈,从项目初始化到部署上线,完整拆解博客开发的每一个环节,总字数超 8000 字,适合有 Vue 基础、想动手搭建个人博客的开发者。

二、项目初始化:搭建 Vue 博客基础架构

2.1 技术栈选型与环境准备

核心技术栈
  • 框架:Vue 3(Composition API 为主)
  • 构建工具:Vite(比 Webpack 更快的热更新与打包)
  • 状态管理:Pinia(Vuex 替代方案,更简洁的 API)
  • 路由:Vue Router 4(适配 Vue 3 的路由管理)
  • UI 组件库:Element Plus(轻量、可定制,适合博客场景)
  • 样式方案:Scss(CSS 预处理器,提高样式复用性)
  • 接口请求:Axios(封装请求拦截、响应处理)
  • 部署平台:Vercel(静态资源托管)+ 云开发(接口服务)
环境准备

确保本地安装 Node.js(v14+)和 npm/yarn,执行以下命令初始化项目:


# 创建 Vite + Vue 项目

npm create vite@latest my-blog -- --template vue

cd my-blog

npm install

# 安装核心依赖

npm install vue-router@4 pinia axios element-plus @element-plus/icons-vue sass

2.2 项目目录结构设计

合理的目录结构是工程化开发的基础,博客项目推荐如下结构:


my-blog/

├── public/ # 静态资源(图片、favicon 等)

├── src/

│ ├── api/ # 接口请求封装

│ ├── assets/ # 静态资源(样式、图片等)

│ │ ├── css/ # 全局样式

│ │ └── img/ # 本地图片

│ ├── components/ # 公共组件(Header、Footer、Card 等)

│ ├── layouts/ # 布局组件(主布局、文章详情布局等)

│ ├── pages/ # 页面组件(首页、文章列表、详情页等)

│ ├── router/ # 路由配置

│ ├── store/ # Pinia 状态管理

│ ├── utils/ # 工具函数(时间格式化、MD 解析等)

│ ├── App.vue # 根组件

│ └── main.js # 入口文件

├── .env.development # 开发环境变量

├── .env.production # 生产环境变量

└── vite.config.js # Vite 配置

2.3 基础配置:Vite + 路由 + Pinia

2.3.1 Vite 配置(vite.config.js)

配置跨域、别名、端口等,优化开发体验:


import { defineConfig } from 'vite'

import vue from '@vitejs/plugin-vue'

import path from 'path'

export default defineConfig({

plugins: [vue()],

resolve: {

// 路径别名

alias: {

'@': path.resolve(__dirname, './src')

}

},

server: {

port: 3000, // 开发端口

open: true, // 启动后自动打开浏览器

proxy: {

// 跨域代理(对接后端接口)

'/api': {

target: 'https://xxx.cloudfunctions.net', // 后端接口地址

changeOrigin: true,

rewrite: (path) => path.replace(/^\/api/, '')

}

}

},

css: {

preprocessorOptions: {

scss: {

additionalData: '@import "@/assets/css/global.scss";' // 全局引入 Scss 变量

}

}

}

})

2.3.2 路由配置(src/router/index.js)

博客核心路由包括首页、文章列表、详情页、分类页、标签页、关于页等:


import { createRouter, createWebHistory } from 'vue-router'

import MainLayout from '@/layouts/MainLayout.vue' // 主布局(包含 Header、Footer)

// 懒加载页面组件

const Home = () => import('@/pages/Home.vue')

const ArticleList = () => import('@/pages/ArticleList.vue')

const ArticleDetail = () => import('@/pages/ArticleDetail.vue')

const Category = () => import('@/pages/Category.vue')

const Tag = () => import('@/pages/Tag.vue')

const About = () => import('@/pages/About.vue')

const NotFound = () => import('@/pages/NotFound.vue')

const routes = [

{

path: '/',

component: MainLayout,

children: [

{ path: '', name: 'Home', component: Home }, // 首页

{ path: 'articles', name: 'ArticleList', component: ArticleList }, // 文章列表

{ path: 'article/:id', name: 'ArticleDetail', component: ArticleDetail }, // 文章详情

{ path: 'category/:id', name: 'Category', component: Category }, // 分类页

{ path: 'tag/:id', name: 'Tag', component: Tag }, // 标签页

{ path: 'about', name: 'About', component: About } // 关于页

]

},

{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound } // 404 页面

]

const router = createRouter({

history: createWebHistory(import.meta.env.BASE_URL),

routes,

scrollBehavior: () => ({ top: 0 }) // 路由切换时滚动到顶部

})

export default router

2.3.3 Pinia 状态管理(src/store/index.js)

博客需要管理的全局状态:文章列表、分类、标签、用户信息等,用 Pinia 简化状态管理:


import { createPinia } from 'pinia'

export default createPinia()

// src/store/modules/article.js(文章模块)

import { defineStore } from 'pinia'

import { getArticleList, getArticleDetail } from '@/api/article'

export const useArticleStore = defineStore('article', {

state: () => ({

articleList: [], // 文章列表

currentArticle: null, // 当前文章详情

total: 0, // 文章总数

pageSize: 10, // 每页条数

currentPage: 1 // 当前页码

}),

actions: {

// 获取文章列表

async fetchArticleList(params) {

try {

const res = await getArticleList(params)

this.articleList = res.data.list

this.total = res.data.total

this.currentPage = params.page

} catch (err) {

console.error('获取文章列表失败:', err)

}

},

// 获取文章详情

async fetchArticleDetail(id) {

try {

const res = await getArticleDetail(id)

this.currentArticle = res.data

} catch (err) {

console.error('获取文章详情失败:', err)

}

}

}

})

// 同理可创建 category.js、tag.js 等模块

2.3.4 入口文件配置(src/main.js)

引入路由、Pinia、Element Plus 等核心依赖:


import { createApp } from 'vue'

import App from './App.vue'

import router from './router'

import pinia from './store'

import ElementPlus from 'element-plus'

import 'element-plus/dist/index.css'

import '@/assets/css/global.scss' // 全局样式

const app = createApp(App)

app.use(pinia)

app.use(router)

app.use(ElementPlus)

app.mount('#app')

三、博客核心功能实现:从 UI 到逻辑

3.1 布局组件开发:复用性与一致性

博客布局通常分为 “主布局”(包含 Header、Footer、侧边栏)和 “独立布局”(如关于页、404 页),以 MainLayout 为例:


<!-- src/layouts/MainLayout.vue -->

<template>

<div class="main-layout">

<!-- 顶部导航 -->

<Header />

<div class="layout-content">

<div class="container">

<!-- 侧边栏 + 主内容区(响应式适配) -->

<div class="content-wrap">

<aside class="sidebar" :class="{ 'hidden': isMobile }">

<Sidebar />

</aside>

<main class="main-content">

<router-view /> <!-- 路由出口 -->

</main>

</div>

</div>

</div>

<!-- 页脚 -->

<Footer />

<!-- 移动端侧边栏开关 -->

<el-fab

icon="Menu"

class="mobile-menu-btn"

@click="toggleMobileMenu"

v-show="isMobile"

/>

</div>

</template>

<script setup>

import { ref, watchEffect } from 'vue'

import { useRoute } from 'vue-router'

import Header from '@/components/Header.vue'

import Footer from '@/components/Footer.vue'

import Sidebar from '@/components/Sidebar.vue'

// 响应式判断是否为移动端

const isMobile = ref(window.innerWidth < 768)

const isMobileMenuOpen = ref(false)

// 监听窗口大小变化

window.addEventListener('resize', () => {

isMobile.value = window.innerWidth < 768

if (window.innerWidth >= 768) isMobileMenuOpen.value = false

})

// 切换移动端侧边栏

const toggleMobileMenu = () => {

isMobileMenuOpen.value = !isMobileMenuOpen.value

}

</script>

<style scoped lang="scss">

.main-layout {

display: flex;

flex-direction: column;

min-height: 100vh;

.layout-content {

flex: 1;

padding: 20px 0;

.container {

width: 1200px;

margin: 0 auto;

padding: 0 20px;

.content-wrap {

display: flex;

gap: 30px;

.sidebar {

width: 280px;

flex-shrink: 0;

&.hidden {

display: none;

}

}

.main-content {

flex: 1;

}

}

}

}

.mobile-menu-btn {

position: fixed;

bottom: 30px;

right: 30px;

z-index: 999;

}

}

// 响应式适配

@media (max-width: 1200px) {

.container {

width: 900px !important;

}

}

@media (max-width: 900px) {

.container {

width: 100% !important;

}

}

3.2 公共组件开发:提高开发效率

3.2.1 Header 组件(导航栏)

包含博客标题、导航菜单、搜索框等核心元素:


<!-- src/components/Header.vue -->

<template>

<header class="header">

<div class="container">

<div class="header-wrap">

<!-- 博客标题 -->

<router-link to="/" class="logo">

<h1>{{ blogTitle }}</h1>

</router-link>

<!-- 导航菜单(桌面端) -->

<nav class="nav-desktop">

<ul class="nav-list">

<li class="nav-item">

<router-link

to="/"

:class="{ active: $route.path === '/' }"

>

首页

</router-link>

</li>

<li class="nav-item">

<router-link

to="/articles"

:class="{ active: $route.path === '/articles' }"

>

文章

</router-link>

</li>

<li class="nav-item">

<router-link

to="/about"

:class="{ active: $route.path === '/about' }"

>

关于我

</router-link>

</li>

</ul>

</nav>

<!-- 搜索框 -->

<div class="search-box">

<el-input

v-model="searchKey"

placeholder="搜索文章..."

icon="Search"

@keyup.enter="handleSearch"

size="small"

/>

</div>

</div>

</div>

</header>

</template>

<script setup>

import { ref } from 'vue'

import { useRouter } from 'vue-router'

const router = useRouter()

const blogTitle = ref('Vue 技术博客') // 博客标题

const searchKey = ref('') // 搜索关键词

// 搜索功能

const handleSearch = () => {

if (searchKey.value.trim()) {

router.push({

path: '/articles',

query: { keyword: searchKey.value.trim() }

})

}

}

</script>

<style scoped lang="scss">

.header {

height: 80px;

line-height: 80px;

border-bottom: 1px solid #eee;

.header-wrap {

display: flex;

justify-content: space-between;

align-items: center;

}

.logo {

h1 {

font-size: 24px;

color: #2c3e50;

font-weight: 600;

}

}

.nav-desktop {

.nav-list {

display: flex;

gap: 30px;

.nav-item {

a {

color: #333;

font-size: 16px;

transition: color 0.3s;

&:hover, &.active {

color: #42b983; // Vue 主题色

}

}

}

}

}

.search-box {

width: 200px;

}

}

</style>

3.2.2 文章卡片组件(ArticleCard)

首页、文章列表页复用的核心组件:


<!-- src/components/ArticleCard.vue -->

<template>

<div class="article-card" @click="goToDetail">

<!-- 文章封面图 -->

<div class="card-img" v-if="article.cover">

<img :src="article.cover" :alt="article.title" />

</div>

<!-- 文章信息 -->

<div class="card-content">

<!-- 分类标签 -->

<router-link

:to="{ path: '/category/' + article.categoryId }"

class="category-tag"

>

{{ article.categoryName }}

</router-link>

<!-- 文章标题 -->

<h3 class="card-title">{{ article.title }}</h3>

<!-- 文章摘要 -->

<p class="card-excerpt">{{ article.excerpt }}</p>

<!-- 底部信息(作者、时间、阅读量) -->

<div class="card-meta">

<span class="meta-item">

<i class="User"></i> {{ article.author }}

</span>

<span class="meta-item">

<i class="Calendar"></i> {{ formatTime(article.createTime) }}

</span>

<span class="meta-item">

<i class="Eye"></i> {{ article.viewCount }} 阅读

</span>

</div>

</div>

</div>

</template>

<script setup>

import { defineProps } from 'vue'

import { useRouter } from 'vue-router'

import { formatTime } from '@/utils/format' // 时间格式化工具

import { User, Calendar, Eye } from '@element-plus/icons-vue'

const router = useRouter()

// 接收父组件传递的文章数据

const props = defineProps({

article: {

type: Object,

required: true,

default: () => ({})

}

})

// 跳转到文章详情页

const goToDetail = () => {

router.push({

path: `/article/${props.article.id}`

})

}

</script>

<style scoped lang="scss">

.article-card {

background: #fff;

border-radius: 8px;

box-shadow: 0 2px 8px rgba(0,0,0,0.08);

transition: transform 0.3s, box-shadow 0.3s;

cursor: pointer;

&:hover {

transform: translateY(-5px);

box-shadow: 0 8px 24px rgba(0,0,0,0.12);

}

.card-img {

height: 180px;

overflow: hidden;

border-top-left-radius: 8px;

border-top-right-radius: 8px;

img {

width: 100%;

height: 100%;

object-fit: cover;

transition: transform 0.5s;

&:hover {

transform: scale(1.05);

}

}

}

.card-content {

padding: 20px;

.category-tag {

display: inline-block;

padding: 2px 8px;

background: #f5f5f5;

color: #42b983;

font-size: 12px;

border-radius: 4px;

margin-bottom: 10px;

}

.card-title {

font-size: 18px;

color: #2c3e50;

margin-bottom: 10px;

line-height: 1.5;

}

.card-excerpt {

font-size: 14px;

color: #666;

line-height: 1.6;

margin-bottom: 15px;

display: -webkit-box;

-webkit-line-clamp: 2;

-webkit-box-orient: vertical;

overflow: hidden;

}

.card-meta {

display: flex;

gap: 15px;

font-size: 12px;

color: #999;

.meta-item {

display: flex;

align-items: center;

gap: 4px;

}

}

}

}

</style>

3.3 核心页面开发:实现博客核心流程

3.3.1 首页(Home.vue)

首页展示最新文章、热门分类、推荐标签等核心内容:


<!-- src/pages/Home.vue -->

<template>

<div class="home-page">

<!-- 轮播图(可选) -->

<el-carousel height="300px" :autoplay="true" indicator-position="outside">

<el-carousel-item v-for="(item, index) in bannerList" :key="index">

<router-link :to="`/article/${item.id}`">

<img

:src="item.cover"

:alt="item.title"

class="banner-img"

/>

<div class="banner-mask">

<h3 class="banner-title">{{ item.title }}</h3>

</div>

</router-link>

</el-carousel-item>

</el-carousel>

<!-- 最新文章 -->

<section class="latest-articles">

<div class="section-header">

<h2>最新文章</h2>

<router-link to="/articles" class="more-link">查看全部 →</router-link>

</div>

<div class="articles-list">

<ArticleCard

v-for="article in articleList"

:key="article.id"

:article="article"

/>

</div>

</section>

<!-- 分类与标签 -->

<div class="category-tag-wrap">

<section class="category-section">

<div class="section-header">

<h2>热门分类</h2>

</div>

<div class="category-list">

<router-link

:to="{ path: '/category/' + item.id }"

class="category-item"

v-for="item in categoryList"

:key="item.id"

>

{{ item.name }} ({{ item.articleCount }})

</router-link>

</div>

</section>

<section class="tag-section">

<div class="section-header">

<h2>推荐标签</h2>

</div>

<div class="tag-list">

<router-link

:to="{ path: '/tag/' + item.id }"

class="tag-item"

v-for="item in tagList"

:key="item.id"

>

{{ item.name }} ({{ item.articleCount }})

</router-link>

</div>

</section>

</div>

</div>

</template>

<script setup>

import { onMounted } from 'vue'

import ArticleCard from '@/components/ArticleCard.vue'

import { useArticleStore } from '@/store/modules/article'

import { useCategoryStore } from '@/store/modules/category'

import { useTagStore } from '@/store/modules/tag'

const articleStore = useArticleStore()

const categoryStore = useCategoryStore()

const tagStore = useTagStore()

// 轮播图数据(可从接口获取)

const bannerList = [

{ id: 1, title: 'Vue 3 Composition API 完全指南', cover: '/assets/img/banner1.jpg' },

{ id: 2, title: 'Vite 性能优化实战技巧', cover: '/assets/img/banner2.jpg' }

]

// 页面加载时获取数据

onMounted(() => {

// 获取最新文章(前6篇)

articleStore.fetchArticleList({ page: 1, pageSize: 6 })

// 获取热门分类

categoryStore.fetchHotCategory()

// 获取推荐标签

tagStore.fetchRecommendTag()

})

</script>

<style scoped lang="scss">

.home-page {

.banner-img {

width: 100%;

height: 100%;

object-fit: cover;

}

.banner-mask {

position: absolute;

bottom: 0;

left: 0;

right: 0;

background: linear-gradient(transparent, rgba(0,0,0,0.7));

padding: 20px;

.banner-title {

color: #fff;

font-size: 20px;

font-weight: 600;

}

}

.latest-articles {

margin: 30px 0;

.section-header {

display: flex;

justify-content: space-between;

align-items: center;

margin-bottom: 20px;

h2 {

font-size: 20px;

color: #2c3e50;

font-weight: 600;

}

.more-link {

color: #42b983;

font-size: 14px;

}

}

.articles-list {

display: grid;

grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));

gap: 20px;

}

}

.category-tag-wrap {

display: flex;

gap: 30px;

margin-bottom: 30px;

.category-section, .tag-section {

flex: 1;

.section-header {

margin-bottom: 15px;

h2 {

font-size: 18px;

color: #2c3e50;

font-weight: 600;

}

}

.category-list {

display: flex;

flex-wrap: wrap;

gap: 10px;

.category-item {

padding: 8px 15px;

background: #f5f5f5;

border-radius: 20px;

font-size: 14px;

color: #333;

transition: background 0.3s;

&:hover {

background: #42b983;

color: #fff;

}

}

}

.tag-list {

display: flex;

flex-wrap: wrap;

gap: 10px;

.tag-item {

padding: 6px 12px;

background: #f8f9fa;

border-radius: 4px;

font-size: 13px;

color: #666;

transition: all 0.3s;

&:hover {

background: #42b983;

color: #fff;

}

}

}

}

}

}

</style>

3.3.2 文章详情页(ArticleDetail.vue)

核心功能:展示文章内容(Markdown 渲染)、目录导航、评论区、相关推荐:


<!-- src/pages/ArticleDetail.vue -->

<template>

<div class="article-detail">

<div class="article-header">

<!-- 分类标签 -->

<router-link

:to="{ path: '/category/' + currentArticle.categoryId }"

class="category-tag"

>

{{ currentArticle.categoryName }}

</router-link>

<!-- 文章标题 -->

<h1 class="article-title">{{ currentArticle.title }}</h1>

<!-- 文章元信息 -->

<div class="article-meta">

<span class="meta-item">

<i class="User"></i> {{ currentArticle.author }}

</span>

<span class="meta-item">

<i class="Calendar"></i> {{ formatTime(currentArticle.createTime) }}

</span>

<span class="meta-item">

<i class="Eye"></i> {{ currentArticle.viewCount }} 阅读

</span>

<span class="meta-item">

<i class="Tag"></i>

<router-link

:to="{ path: '/tag/' + tag.id }"

class="tag-link"

v-for="tag in currentArticle.tags"

:key="tag.id"

>

{{ tag.name }}

</router-link>

</span>

</div>

</div>

<!-- 文章封面图 -->

<div class="article-cover" v-if="currentArticle.cover">

<img :src="currentArticle.cover" :alt="currentArticle.title" />

</div>

<!-- 文章内容(Markdown 渲染) -->

<div class="article-content" v-html="renderedContent"></div>

<!-- 点赞 + 分享 -->

<div class="article-actions">

<button class="like-btn" @click="handleLike">

<i class="Thumb" :class="{ liked: isLiked }"></i>

<span>{{ currentArticle.likeCount }} 点赞</span>

</button>

<div class="share-btn">

<i class="Share"></i> 分享

</div>

</div>

<!-- 相关推荐 -->

<div class="related-articles">

<h3 class="related-title">相关推荐</h3>

<div class="related-list">

<ArticleCard

v-for="article in relatedArticles"

:key="article.id"

:article="article"

/>

</div>

</div>

<!-- 评论区 -->

<div class="comment-section">

<h3 class="comment-title">评论区 ({{ commentCount }})</h3>

<!-- 评论输入框 -->

<div class="comment-input">

<el-input

v-model="commentContent"

type="textarea"

:rows="4"

placeholder="写下你的看法..."

/>

<el-button type="primary" @click="submitComment">提交评论</el-button>

</div>

<!-- 评论列表 -->

<div class="comment-list">

<div class="comment-item" v-for="comment in commentList" :key="comment.id">

<div class="comment-avatar">

<img :src="comment.avatar" :alt="comment.nickname" />

</div>

<div class="comment-content">

<div class="comment-header">

<span class="comment-nickname">{{ comment.nickname }}</span>

<span class="comment-time">{{ formatTime(comment.createTime) }}</span>

</div>

<p class="comment-text">{{ comment.content }}</p>

<div class="comment-actions">

<button class="reply-btn">回复</button>

<button class="like-btn">

<i class="Thumb"></i> {{ comment.likeCount }}

</button>

</div>

</div>

</div>

</div>

</div>

</div>

</template>

<script setup>

import { ref, onMounted, computed } from 'vue'

import { useRoute } from 'vue-router'

import { useArticleStore } from '@/store/modules/article'

import ArticleCard from '@/components/ArticleCard.vue'

import { formatTime } from '@/utils/format'

import { User, Calendar, Eye, Tag, Thumb, Share } from '@element-plus/icons-vue'

import marked from 'marked' // Markdown 解析库

import hljs from 'highlight.js' // 代码高亮

import 'highlight.js/styles/atom-one-dark.css' // 代码高亮样式

// 初始化 Markdown 解析器

marked.setOptions({

renderer: new marked.Renderer(),

highlight: (code, lang) => {

if (lang && hljs.getLanguage(lang)) {

return hljs.highlight(code, { language: lang }).value

}

return hljs.highlight(code, { language: 'plaintext' }).value

},

breaks: true // 支持换行

})

const route = useRoute()

const articleStore = useArticleStore()

const articleId = route.params.id // 从路由获取文章 ID

// 响应式数据

const currentArticle = computed(() => articleStore.currentArticle || {})

const renderedContent = computed(() => {

// 将 Markdown 转为 HTML

return currentArticle.value.content

? marked.parse(currentArticle.value.content)

: ''

})

const isLiked = ref(false) // 是否点赞

const commentContent = ref('') // 评论内容

const commentList = ref([]) // 评论列表

const commentCount = ref(0) // 评论总数

const relatedArticles = ref([]) // 相关文章

// 页面加载时获取数据

onMounted(() => {

fetchArticleDetail()

fetchRelatedArticles()

fetchCommentList()

})

// 获取文章详情

const fetchArticleDetail = async () => {

await articleStore.fetchArticleDetail(articleId)

// 模拟增加阅读量(实际需调用接口)

currentArticle.value.viewCount += 1

}

// 获取相关文章(同分类下的其他文章)

const fetchRelatedArticles = () => {

// 实际项目中从接口获取,这里模拟数据

relatedArticles.value = [

{

id: 3,

title: 'Vue 3 生命周期钩子完全解析',

cover: '/assets/img/article3.jpg',

categoryId: currentArticle.value.categoryId,

categoryName: currentArticle.value.categoryName,

author: 'Vue 开发者',

createTime: '2024-05-10',

viewCount: 120

}

]

}

// 获取评论列表

const fetchCommentList = () => {

// 实际项目中从接口获取,这里模拟数据

commentList.value = [

{

id: 1,

nickname: '小明',

avatar: '/assets/img/avatar1.jpg',

content: '文章写得很详细,收获很大!',

createTime: '2024-05-15',

likeCount: 5

}

]

commentCount.value = commentList.value.length

}

// 点赞功能

const handleLike = () => {

isLiked.value = !isLiked.value

currentArticle.value.likeCount += isLiked.value ? 1 : -1

// 实际项目中需调用点赞接口

}

// 提交评论

const submitComment = () => {

if (!commentContent.value.trim()) return

// 模拟添加评论

const newComment = {

id: commentList.value.length + 1,

nickname: '我',

avatar: '/assets/img/avatar-default.jpg',

content: commentContent.value.trim(),

createTime: new Date().toISOString(),

likeCount: 0

}

commentList.value.unshift(newComment)

commentCount.value += 1

commentContent.value = ''

// 实际项目中需调用提交评论接口

}

</script>

<style scoped lang="scss">

.article-detail {

background: #fff;

border-radius: 8px;

padding: 30px;

box-shadow: 0 2px 8px rgba(0,0,0,0.08);

.article-header {

margin-bottom: 20px;

.category-tag {

display: inline-block;

padding: 4px 10px;

background: #f0f9f0;

color: #42b983;

font-size: 13px;

border-radius: 4px;

margin-bottom: 10px;

}

.article-title {

font-size: 24px;

color: #2c3e50;

font-weight: 600;

line-height: 1.5;

margin-bottom: 15px;

}

.article-meta {

display: flex;

flex-wrap: wrap;

gap: 15px;

font-size: 14px;

color: #999;

.meta-item {

display: flex;

align-items: center;

gap: 5px;

}

.tag-link {

color: #666;

margin-right: 8px;

&:hover {

color: #42b983;

}

}

}

}

.article-cover {

margin-bottom: 30px;

img {

width: 100%;

height: auto;

border-radius: 8px;

}

}

.article-content {

font-size: 16px;

color: #333;

line-height: 1.8;

margin-bottom: 30px;

// Markdown 样式适配

h2 {

font-size: 20px;

margin: 25px 0 15px;

color: #2c3e50;

}

h3 {

font-size: 18px;

margin: 20px 0 10px;

color: #2c3e50;

}

p {

margin-bottom: 15px;

}

img {

max-width: 100%;

height: auto;

margin: 15px 0;

border-radius: 4px;

}

pre {

background: #f8f9fa;

padding: 15px;

border-radius: 4px;

overflow-x: auto;

margin: 15px 0;

}

code {

background: #f5f5f5;

padding: 2px 4px;

border-radius: 4px;

font-size: 14px;

}

blockquote {

border-left: 4px solid #42b983;

padding: 10px 15px;

background: #f0f9f0;

margin: 15px 0;

}

ul, ol {

margin: 15px 0;

padding-left: 25px;

}

}

.article-actions {

display: flex;

justify-content: space-between;

align-items: center;

margin-bottom: 30px;

padding: 15px 0;

border-top: 1px solid #eee;

border-bottom: 1px solid #eee;

.like-btn {

display: flex;

align-items: center;

gap: 5px;

background: transparent;

border: none;

color: #666;

cursor: pointer;

transition: color 0.3s;

&.liked {

color: #e74c3c;

}

}

.share-btn {

display: flex;

align-items: center;

gap: 5px;

color: #666;

cursor: pointer;

transition: color 0.3s;

&:hover {

color: #42b983;

}

}

}

.related-articles {

margin-bottom: 30px;

.related-title {

font-size: 18px;

color: #2c3e50;

font-weight: 600;

margin-bottom: 15px;

}

.related-list {

display: grid;

grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));

gap: 15px;

}

}

.comment-section {

margin-top: 30px;

.comment-title {

font-size: 18px;

color: #2c3e50;

font-weight: 600;

margin-bottom: 20px;

}

.comment-input {

margin-bottom: 30px;

display: flex;

gap: 15px;

.el-input {

flex: 1;

}

.el-button {

align-self: flex-end;

}

}

.comment-list {

gap: 20px;

display: flex;

flex-direction: column;

}

.comment-item {

display: flex;

gap: 15px;

padding: 15px 0;

border-bottom: 1px solid #f5f5f5;

.comment-avatar {

width: 40px;

height: 40px;

border-radius: 50%;

overflow: hidden;

img {

width: 100%;

height: 100%;

object-fit: cover;

}

}

.comment-content {

flex: 1;

.comment-header {

display: flex;

justify-content: space-between;

margin-bottom: 5px;

.comment-nickname {

font-size: 14px;

font-weight: 500;

color: #2c3e50;

}

.comment-time {

font-size: 12px;

color: #999;

}

}

.comment-text {

font-size: 14px;

color: #333;

margin-bottom: 8px;

}

.comment-actions {

display: flex;

gap: 15px;

font-size: 12px;

color: #999;

.reply-btn, .like-btn {

background: transparent;

border: none;

color: #999;

cursor: pointer;

transition: color 0.3s;

&:hover {

color: #42b983;

}

}

}

}

}

}

}

</style>

3.4 接口封装与数据请求

3.4.1 Axios 封装(src/api/request.js)

统一处理请求拦截、响应拦截、错误处理:


import axios from 'axios'

import { ElMessage } from 'element-plus'

// 创建 Axios 实例

const service = axios.create({

baseURL: import.meta.env.VITE_API_BASE_URL, // 从环境变量获取基础地址

timeout: 5000 // 请求超时时间

})

// 请求拦截器:添加 Token、设置请求头

service.interceptors.request.use(

(config) => {

// 实际项目中可添加 Token 认证

// const token = localStorage.getItem('token')

// if (token) {

// config.headers['Authorization'] = `Bearer ${token}`

// }

return config

},

(error) => {

// 请求错误处理

ElMessage.error('请求发送失败,请稍后重试')

return Promise.reject(error)

}

)

// 响应拦截器:统一处理响应数据、错误

service.interceptors.response.use(

(response) => {

const res = response.data

// 假设后端返回格式为 { code: 200, data: {}, message: '' }

if (res.code !== 200) {

ElMessage.error(res.message || '请求失败')

return Promise.reject(res)

} else {

return res

}

},

(error) => {

// 响应错误处理

ElMessage.error('服务器错误,请稍后重试')

return Promise.reject(error)

}

)

export default service

3.4.2 接口请求函数(src/api/article.js)

按模块封装接口,便于维护:


import request from './request'

// 获取文章列表

export const getArticleList = (params) => {

return request({

url: '/article/list',

method: 'GET',

params

})

}

// 获取文章详情

export const getArticleDetail = (id) => {

return request({

url: `/article/${id}`,

method: 'GET'

})

}

// 点赞文章

export const likeArticle = (id) => {

return request({

url: `/article/${id}/like`,

method: 'POST'

})

}

// 提交评论

export const submitComment = (data) => {

return request({

url: '/comment/submit',

method: 'POST',

data

})

}

// 获取评论列表

export const getCommentList = (articleId) => {

return request({

url: `/comment/list/${articleId}`,

method: 'GET'

})

}

四、博客进阶功能:提升用户体验与实用性

4.1 Markdown 编辑器集成(后台管理)

如果需要自己发布文章,需集成 Markdown 编辑器,推荐使用 vue3-markdown-it:


npm install vue3-markdown-it markdown-it-prism --save


<!-- src/components/MarkdownEditor.vue -->

<template>

<div class="markdown-editor">

<el-row :gutter="20">

<!-- 编辑区 -->

<el-col :span="12">

<textarea

v-model="content"

class="editor-input"

placeholder="请输入 Markdown 内容..."

></textarea>

</el-col>

<!-- 预览区 -->

<el-col :span="12">

<div class="editor-preview">

<markdown-it

:content="content"

:options="editorOptions"

/>

</div>

</el-col>

</el-row>

</div>

</template>

<script setup>

import { ref } from 'vue'

import MarkdownIt from 'vue3-markdown-it'

import 'vue3-markdown-it/dist/style.css'

import prism from 'markdown-it-prism'

// 引入代码高亮样式

import 'prismjs/themes/prism-tomorrow.css'

const props = defineProps({

modelValue: {

type: String,

default: ''

}

})

const emit = defineEmits(['update:modelValue'])

const content = ref(props.modelValue)

// 监听内容变化,同步到父组件

watch(content, (val) => {

emit('update:modelValue', val)

})

// Markdown 编辑器配置

const editorOptions = ref({

plugins: [prism],

html: true, // 支持 HTML 标签

breaks: true, // 支持换行

linkify: true // 自动识别链接

})

</script>

<style scoped lang="scss">

.markdown-editor {

.editor-input {

width: 100%;

height: 600px;

padding: 15px;

border: 1px solid #eee;

border-radius: 4px;

font-size: 14px;

line-height: 1.8;

resize: none;

outline: none;

&:focus {

border-color: #42b983;

}

}

.editor-preview {

height: 600px;

padding: 15px;

border: 1px solid #eee;

border-radius: 4px;

overflow-y: auto;

// 预览区样式适配

h2 {

font-size: 20px;

margin: 20px 0 10px;

}

pre {

background: #f8f9fa;

padding: 10px;

border-radius: 4px;

overflow-x: auto;

}

code {

background: #f5f5f5;

padding: 2px 4px;

border-radius: 4px;

}

}

}

</style>

4.2 暗黑模式切换

提升用户体验,支持亮色 / 暗黑模式切换,基于 CSS 变量实现:


// src/utils/theme.js

// 切换暗黑模式

export const toggleDarkMode = () => {

const isDark = document.documentElement.classList.contains('dark-mode')

if (isDark) {

document.documentElement.classList.remove('dark-mode')

localStorage.setItem('theme', 'light')

} else {

document.documentElement.classList.add('dark-mode')

localStorage.setItem('theme', 'dark')

}

}

// 初始化主题(从本地存储读取)

export const initTheme = () => {

const theme = localStorage.getItem('theme') || 'light'

if (theme === 'dark') {

document.documentElement.classList.add('dark-mode')

}

}


// src/assets/css/global.scss

// 定义亮色模式变量

:root {

--bg-color: #fafafa;

--text-color: #333;

--card-bg: #fff;

--border-color: #eee;

--primary-color: #42b983;

}

// 暗黑模式变量

.dark-mode {

--bg-color: #1a1a1a;

--text-color: #e0e0e0;

--card-bg: #2d2d2d;

--border-color: #444;

--primary-color: #66cc99;

}

// 全局样式应用变量

body {

background-color: var(--bg-color);

color: var(--text-color);

font-family: 'Helvetica Neue', Arial, sans-serif;

}

// 其他组件样式中替换固定颜色为变量

在 Header 组件添加切换按钮:


<!-- Header.vue 中添加 -->

<el-button

icon="Moon"

size="small"

class="theme-toggle"

@click="toggleDarkMode"

>

{{ isDark ? '亮色模式' : '暗黑模式' }}

</el-button>

<script setup>

import { toggleDarkMode, initTheme } from '@/utils/theme'

import { ref, onMounted } from 'vue'

const isDark = ref(false)

onMounted(() => {

initTheme()

isDark.value = document.documentElement.classList.contains('dark-mode')

})

// 监听主题变化

const toggleDarkMode = () => {

window.toggleDarkMode()

isDark.value = !isDark.value

}

</script>

4.3 文章目录导航

基于文章标题自动生成目录,支持锚点跳转:


<!-- src/components/ArticleToc.vue -->

<template>

<div class="article-toc">

<h3 class="toc-title">文章目录</h3>

<ul class="toc-list">

<li

class="toc-item"

v-for="(item, index) in tocList"

:key="index"

:class="{ active: currentAnchor === item.id }"

>

<a

:href="`#${item.id}`"

@click.prevent="scrollToAnchor(item.id)"

:style="{ paddingLeft: `${(item.level - 2) * 15}px` }"

>

{{ item.text }}

</a>

</li>

</ul>

</div>

</template>

<script setup>

import { ref, watch, onMounted } from 'vue'

const props = defineProps({

// 文章内容 HTML(用于解析标题)

content: {

type: String,

required: true

}

})

const tocList = ref([]) // 目录列表

const currentAnchor = ref('') // 当前激活的锚点

// 解析文章标题生成目录

const parseToc = () => {

const tempDiv = document.createElement('div')

tempDiv.innerHTML = props.content

const headings = tempDiv.querySelectorAll('h2, h3') // 只解析 h2、h3 标题

const list = []

headings.forEach((heading, index) => {

const id = `heading-${index}`

heading.id = id // 给标题添加 id

list.push({

id,

text: heading.textContent,

level: parseInt(heading.tagName.replace('H', '')) // 标题级别(2、3)

})

})

tocList.value = list

// 更新文章内容中的标题 id

props.content = tempDiv.innerHTML

}

// 滚动到指定锚点

const scrollToAnchor = (id) => {

const element = document.getElementById(id)

if (element) {

element.scrollIntoView({ behavior: 'smooth' })

currentAnchor.value = id

}

}

// 监听滚动,更新当前激活的锚点

const watchScroll = () => {

const scrollTop = window.scrollY + 200

const headings = document.querySelectorAll('h2, h3')

headings.forEach((heading) => {

const offsetTop = heading.offsetTop

const nextHeading = heading.nextElementSibling?.tagName.match(/H[2-3]/)

? heading.nextElementSibling

: null

const offsetBottom = nextHeading ? nextHeading.offsetTop : Infinity

if (scrollTop >= offsetTop && scrollTop < offsetBottom) {

currentAnchor.value = heading.id

}

})

}

// 初始化与监听

onMounted(() => {

parseToc()

window.addEventListener('scroll', watchScroll)

})

watch(() => props.content, parseToc)

// 组件卸载时移除滚动监听

onUnmounted(() => {

window.removeEventListener('scroll', watchScroll)

})

</script>

<style scoped lang="scss">

.article-toc {

background: var(--card-bg);

border-radius: 8px;

padding: 15px;

box-shadow: 0 2px 8px rgba(0,0,0,0.08);

.toc-title {

font-size: 16px;

color: var(--text-color);

font-weight: 600;

margin-bottom: 10px;

padding-bottom: 10px;

border-bottom: 1px solid var(--border-color);

}

.toc-list {

list-style: none;

}

.toc-item {

margin: 8px 0;

a {

color: var(--text-color);

font-size: 14px;

text-decoration: none;

transition: color 0.3s;

&:hover, &.active {

color: var(--primary-color);

}

}

}

}

</style>

五、性能优化:让博客加载更快、运行更流畅

5.1 路由懒加载与组件懒加载

已在路由配置中使用懒加载(() => import('xxx.vue')),组件懒加载可进一步优化首屏加载:


<!-- 在需要懒加载的组件处使用 -->

<template>

<div>

<!-- 懒加载评论区(滚动到可视区域才加载) -->

<LazyComment

v-if="isCommentVisible"

:articleId="articleId"

/>

</div>

</template>

<script setup>

import { ref, onMounted } from 'vue'

// 懒加载组件

const LazyComment = defineAsyncComponent(() => import('@/components/LazyComment.vue'))

const articleId = ref(1)

const isCommentVisible = ref(false)

// 监听滚动,判断组件是否进入可视区域

onMounted(() => {

const observer = new IntersectionObserver((entries) => {

entries.forEach(entry => {

if (entry.isIntersecting) {

isCommentVisible.value = true

observer.unobserve(entry.target)

}

})

})

observer.observe(document.getElementById('comment-trigger'))

})

</script>

5.2 图片优化

  1. 懒加载:使用 Element Plus 的 ElImage 组件或原生 loading="lazy":

<el-image

:src="article.cover"

:alt="article.title"

lazy

placeholder="加载中..."

/>

<!-- 原生方式 -->

<img

:src="article.cover"

:alt="article.title"

loading="lazy"

width="100%"

height="auto"

/>

  1. 图片压缩与 CDN 加速
  • 本地图片使用 TinyPNG 压缩后再上传;
  • 线上图片使用云存储(如阿里云 OSS、七牛云)+ CDN 加速,配置图片格式转换(WebP)。

5.3 打包优化(vite.config.js)


import { defineConfig } from 'vite'

import vue from '@vitejs/plugin-vue'

import path from 'path'

import { visualizer } from 'rollup-plugin-visualizer' // 打包体积分析

import { viteBuildInfo } from 'vite-plugin-build-info' // 构建信息

export default defineConfig({

plugins: [

vue(),

// 打包体积分析(生成 stats.html)

visualizer({

open: false,

filename: 'stats.html'

}),

// 构建信息注入

viteBuildInfo()

],

build: {

// 代码分割

rollupOptions: {

output: {

manualChunks: {

// 拆分 Element Plus 为单独 chunk

'element-plus': ['element-plus'],

// 拆分 Pinia、Vue Router 等依赖

'vue-vendor': ['vue', 'vue-router', 'pinia']

}

}

},

// 压缩代码

minify: 'terser',

terserOptions: {

compress: {

drop_console: true, // 移除 console.log

drop_debugger: true // 移除 debugger

}

}

}

})

5.4 缓存策略

  1. 接口缓存:对不常变化的数据(如分类、标签)进行本地存储缓存:

// src/api/category.js

import request from './request'

export const getHotCategory = async () => {

// 先从本地存储读取

const cache = localStorage.getItem('hotCategory')

if (cache) {

return JSON.parse(cache)

}

// 本地无缓存,调用接口

const res = await request({

url: '/category/hot',

method: 'GET'

})

// 缓存 1 小时(3600 秒)

localStorage.setItem('hotCategory', JSON.stringify(res))

localStorage.setItem('hotCategoryExpire', Date.now() + 3600 * 1000)

return res

}

  1. 静态资源缓存:部署时配置 Nginx 或 Vercel 的缓存策略,对 JS、CSS、图片等文件设置长期缓存。

六、部署上线:从本地到公网访问

6.1 静态资源打包


npm run build

打包后生成 dist 目录,包含所有静态资源。

6.2 部署方案选择

方案 1:Vercel 部署(推荐)
  1. 把代码推送到 GitHub 仓库;
  1. 注册 Vercel 账号,关联 GitHub 仓库;
  1. Vercel 自动识别 Vue 项目,点击 “Deploy” 即可完成部署;
  1. 可自定义域名(在 Vercel 后台配置 DNS 解析)。
方案 2:云服务器 + Nginx 部署
  1. 购买云服务器(如阿里云、腾讯云),安装 Nginx;
  1. 将 dist 目录上传到服务器(使用 FTP 或 scp 命令);
  1. 配置 Nginx:

server {

listen 80;

server_name your-domain.com; # 你的域名

root /usr/share/nginx/html/blog; # dist 目录路径

index index.html;

# 解决 Vue Router 历史模式刷新 404 问题

location / {

try_files $uri $uri/ /index.html;

}

# 静态资源缓存

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp)$ {

expires 30d; # 缓存 30 天

add_header Cache-Control "public, max-age=2592000";

}

}

  1. 重启 Nginx:nginx -s reload。
方案 3:云开发静态网站托管(无服务器)
  1. 开通微信云开发或阿里云云开发;
  1. 使用云开发 CLI 工具上传 dist 目录:

# 安装云开发 CLI

npm install -g @cloudbase/cli

# 登录并部署

tcb login

tcb hosting deploy dist -e your-env-id

  1. 绑定自定义域名,开启 HTTPS。

七、总结与展望

7.1 开发总结

用 Vue 构建个人博客的核心是 “前后端分离 + 工程化开发”:

  • 前端通过 Vue 3 + Vite 实现高效开发与渲染;
  • 核心功能(文章展示、评论、分类标签)围绕用户需求设计;
  • 性能优化从路由、图片、打包、缓存多维度入手;
  • 部署灵活,可根据预算选择 Vercel、云服务器或云开发。

7.2 功能扩展方向

  1. 后端管理系统:基于 Vue + Element Plus 开发后台,实现文章发布、分类管理、评论审核;
  1. 全文搜索:集成 Elasticsearch 或 Algolia 实现高效全文搜索;
  1. 访问统计:接入百度统计或 Google Analytics,分析用户行为;
  1. 国际化:使用 Vue I18n 实现多语言切换;
  1. PWA 支持:添加 Service Worker,实现离线访问。

7.3 学习建议

  1. 先掌握 Vue 3 核心语法(Composition API、响应式原理);
  1. 熟悉 Vue 生态工具(Vite、Pinia、Vue Router)的使用场景;
  1. 实战中积累性能优化经验,理解 “用户体验优先” 的开发思路;
  1. 关注 Vue 官方动态(如 Vue 3.4+ 的新特性),持续迭代博客功能。

用 Vue 搭建博客不仅是 “造轮子”,更是对前端技术的综合实践。希望本文的 8000 字实战指南能帮助你打造属于自己的技术博客,在分享知识的同时,提升自身的开发能力!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐