Vue 构建个人博客:从入门到进阶的实战全指南
一、引言:为什么选择 Vue 开发博客?
在静态博客生成器(如 Hexo、Jekyll)和动态博客框架(如 WordPress)占据主流的当下,选择 Vue 手动搭建博客似乎是 “自讨苦吃”。但作为前端开发者,用 Vue 构建博客有三个不可替代的优势:
- 完全自定义:摆脱模板限制,从 UI 到功能全由自己掌控,打造独一无二的博客风格;
- 技术练兵场:将 Vue 生态(Vue Router、Pinia、Vite 等)融会贯通,实战中提升工程化能力;
- 前后端分离优势:前端负责渲染,后端仅提供接口,部署灵活(可静态托管 + 云函数),访问速度更快。
本文将以 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 图片优化
- 懒加载:使用 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"
/>
- 图片压缩与 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 缓存策略
- 接口缓存:对不常变化的数据(如分类、标签)进行本地存储缓存:
// 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
}
- 静态资源缓存:部署时配置 Nginx 或 Vercel 的缓存策略,对 JS、CSS、图片等文件设置长期缓存。
六、部署上线:从本地到公网访问
6.1 静态资源打包
npm run build
打包后生成 dist 目录,包含所有静态资源。
6.2 部署方案选择
方案 1:Vercel 部署(推荐)
- 把代码推送到 GitHub 仓库;
- 注册 Vercel 账号,关联 GitHub 仓库;
- Vercel 自动识别 Vue 项目,点击 “Deploy” 即可完成部署;
- 可自定义域名(在 Vercel 后台配置 DNS 解析)。
方案 2:云服务器 + Nginx 部署
- 购买云服务器(如阿里云、腾讯云),安装 Nginx;
- 将 dist 目录上传到服务器(使用 FTP 或 scp 命令);
- 配置 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";
}
}
- 重启 Nginx:nginx -s reload。
方案 3:云开发静态网站托管(无服务器)
- 开通微信云开发或阿里云云开发;
- 使用云开发 CLI 工具上传 dist 目录:
# 安装云开发 CLI
npm install -g @cloudbase/cli
# 登录并部署
tcb login
tcb hosting deploy dist -e your-env-id
- 绑定自定义域名,开启 HTTPS。
七、总结与展望
7.1 开发总结
用 Vue 构建个人博客的核心是 “前后端分离 + 工程化开发”:
- 前端通过 Vue 3 + Vite 实现高效开发与渲染;
- 核心功能(文章展示、评论、分类标签)围绕用户需求设计;
- 性能优化从路由、图片、打包、缓存多维度入手;
- 部署灵活,可根据预算选择 Vercel、云服务器或云开发。
7.2 功能扩展方向
- 后端管理系统:基于 Vue + Element Plus 开发后台,实现文章发布、分类管理、评论审核;
- 全文搜索:集成 Elasticsearch 或 Algolia 实现高效全文搜索;
- 访问统计:接入百度统计或 Google Analytics,分析用户行为;
- 国际化:使用 Vue I18n 实现多语言切换;
- PWA 支持:添加 Service Worker,实现离线访问。
7.3 学习建议
- 先掌握 Vue 3 核心语法(Composition API、响应式原理);
- 熟悉 Vue 生态工具(Vite、Pinia、Vue Router)的使用场景;
- 实战中积累性能优化经验,理解 “用户体验优先” 的开发思路;
- 关注 Vue 官方动态(如 Vue 3.4+ 的新特性),持续迭代博客功能。
用 Vue 搭建博客不仅是 “造轮子”,更是对前端技术的综合实践。希望本文的 8000 字实战指南能帮助你打造属于自己的技术博客,在分享知识的同时,提升自身的开发能力!
更多推荐


所有评论(0)