全栈项目实战:Vue3+Node.js开发博客系统
一、项目架构设计
1. 技术栈选型
前端技术栈:
- Vue 3 + Composition API
- TypeScript
- Pinia状态管理
- Vue Router 4
- Element Plus UI组件库
- Vite构建工具
后端技术栈:
- Node.js (Express/Koa)
- MongoDB (Mongoose)
- JWT认证
- RESTful API设计
- Swagger文档
2. 目录结构规划
blog-system/
├── client/ # 前端项目
│ ├── public/ # 静态资源
│ ├── src/
│ │ ├── api/ # API请求封装
│ │ ├── assets/ # 静态资源
│ │ ├── components/ # 公共组件
│ │ ├── composables/ # 自定义Hook
│ │ ├── router/ # 路由配置
│ │ ├── stores/ # Pinia状态
│ │ ├── styles/ # 全局样式
│ │ ├── utils/ # 工具函数
│ │ ├── views/ # 页面组件
│ │ ├── App.vue # 根组件
│ │ └── main.ts # 入口文件
│ ├── tsconfig.json # TypeScript配置
│ └── vite.config.ts # Vite配置
│
├── server/ # 后端项目
│ ├── config/ # 配置文件
│ ├── controllers/ # 控制器
│ ├── models/ # 数据模型
│ ├── middleware/ # 中间件
│ ├── routes/ # 路由定义
│ ├── utils/ # 工具函数
│ ├── app.js # 应用入口
│ └── package.json
│
├── docs/ # 项目文档
└── package.json # 全局脚本
二、后端API开发
1. Express应用初始化
// server/app.js
const express = require('express')
const mongoose = require('mongoose')
const cors = require('cors')
const helmet = require('helmet')
const morgan = require('morgan')
const { errorHandler } = require('./middleware/error')
const app = express()
// 中间件
app.use(cors())
app.use(helmet())
app.use(morgan('dev'))
app.use(express.json())
// 数据库连接
mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => console.log('MongoDB connected'))
.catch(err => console.error(err))
// 路由
app.use('/api/auth', require('./routes/auth'))
app.use('/api/users', require('./routes/users'))
app.use('/api/posts', require('./routes/posts'))
app.use('/api/comments', require('./routes/comments'))
// 错误处理
app.use(errorHandler)
const PORT = process.env.PORT || 5000
app.listen(PORT, () => console.log(`Server running on port ${PORT}`))
2. 数据模型设计
// server/models/Post.js
const mongoose = require('mongoose')
const slugify = require('slugify')
const PostSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Please add a title'],
trim: true,
maxlength: [100, 'Title cannot be more than 100 characters']
},
slug: String,
content: {
type: String,
required: [true, 'Please add content'],
maxlength: [5000, 'Content cannot be more than 5000 characters']
},
excerpt: {
type: String,
maxlength: [300, 'Excerpt cannot be more than 300 characters']
},
coverImage: {
type: String,
default: 'no-photo.jpg'
},
tags: {
type: [String],
required: true,
enum: [
'technology',
'programming',
'design',
'business',
'lifestyle'
]
},
author: {
type: mongoose.Schema.ObjectId,
ref: 'User',
required: true
},
status: {
type: String,
enum: ['draft', 'published'],
default: 'draft'
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: Date
}, {
toJSON: { virtuals: true },
toObject: { virtuals: true }
})
// 创建文章slug
PostSchema.pre('save', function(next) {
this.slug = slugify(this.title, { lower: true })
next()
})
// 反向填充评论
PostSchema.virtual('comments', {
ref: 'Comment',
localField: '_id',
foreignField: 'post',
justOne: false
})
module.exports = mongoose.model('Post', PostSchema)
3. RESTful API实现
// server/controllers/posts.js
const Post = require('../models/Post')
const ErrorResponse = require('../utils/errorResponse')
const asyncHandler = require('../middleware/async')
// @desc 获取所有文章
// @route GET /api/posts
// @access Public
exports.getPosts = asyncHandler(async (req, res, next) => {
res.status(200).json(res.advancedResults)
})
// @desc 获取单篇文章
// @route GET /api/posts/:id
// @access Public
exports.getPost = asyncHandler(async (req, res, next) => {
const post = await Post.findById(req.params.id)
.populate({
path: 'author',
select: 'name avatar'
})
.populate('comments')
if (!post) {
return next(
new ErrorResponse(`Resource not found with id of ${req.params.id}`, 404)
)
}
res.status(200).json({ success: true, data: post })
})
// @desc 创建文章
// @route POST /api/posts
// @access Private
exports.createPost = asyncHandler(async (req, res, next) => {
// 添加作者
req.body.author = req.user.id
const post = await Post.create(req.body)
res.status(201).json({ success: true, data: post })
})
// @desc 更新文章
// @route PUT /api/posts/:id
// @access Private
exports.updatePost = asyncHandler(async (req, res, next) => {
let post = await Post.findById(req.params.id)
if (!post) {
return next(
new ErrorResponse(`Resource not found with id of ${req.params.id}`, 404)
)
}
// 验证文章所有者或管理员
if (post.author.toString() !== req.user.id && req.user.role !== 'admin') {
return next(
new ErrorResponse(`User ${req.user.id} is not authorized to update this post`, 401)
)
}
post = await Post.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true
})
res.status(200).json({ success: true, data: post })
})
// @desc 删除文章
// @route DELETE /api/posts/:id
// @access Private
exports.deletePost = asyncHandler(async (req, res, next) => {
const post = await Post.findById(req.params.id)
if (!post) {
return next(
new ErrorResponse(`Resource not found with id of ${req.params.id}`, 404)
)
}
// 验证文章所有者或管理员
if (post.author.toString() !== req.user.id && req.user.role !== 'admin') {
return next(
new ErrorResponse(`User ${req.user.id} is not authorized to delete this post`, 401)
)
}
await post.remove()
res.status(200).json({ success: true, data: {} })
})
三、前端Vue3实现
1. 前端工程初始化
npm init vite@latest client --template vue-ts
cd client
npm install pinia vue-router axios element-plus @element-plus/icons-vue
2. 状态管理设计
// client/src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login, logout, getMe } from '@/api/auth'
import type { User } from '@/types'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref(localStorage.getItem('token') || '')
const isAuthenticated = ref(false)
async function loginUser(credentials: { email: string; password: string }) {
const response = await login(credentials)
token.value = response.token
localStorage.setItem('token', token.value)
await fetchUser()
}
async function fetchUser() {
try {
user.value = await getMe()
isAuthenticated.value = true
} catch (error) {
logoutUser()
}
}
function logoutUser() {
logout()
user.value = null
token.value = ''
isAuthenticated.value = false
localStorage.removeItem('token')
}
return { user, token, isAuthenticated, loginUser, logoutUser, fetchUser }
})
3. 博客首页实现
<!-- client/src/views/HomeView.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usePostStore } from '@/stores/post'
import PostList from '@/components/PostList.vue'
import PostFilter from '@/components/PostFilter.vue'
const postStore = usePostStore()
const isLoading = ref(false)
const error = ref<string | null>(null)
const fetchPosts = async () => {
try {
isLoading.value = true
error.value = null
await postStore.fetchPosts()
} catch (err) {
error.value = err.message || 'Failed to fetch posts'
} finally {
isLoading.value = false
}
}
onMounted(() => {
if (postStore.posts.length === 0) {
fetchPosts()
}
})
</script>
<template>
<div class="home">
<el-container>
<el-main>
<el-row :gutter="20">
<el-col :md="16" :sm="24">
<post-filter @filter-change="fetchPosts" />
<div v-if="isLoading" class="loading-spinner">
<el-skeleton :rows="5" animated />
</div>
<template v-else>
<post-list
v-if="postStore.posts.length > 0"
:posts="postStore.posts"
/>
<el-empty v-else description="No posts found" />
</template>
</el-col>
<el-col :md="8" :sm="24">
<div class="sidebar">
<el-card>
<template #header>
<h3>Popular Tags</h3>
</template>
<el-tag
v-for="tag in postStore.tags"
:key="tag"
size="large"
@click="postStore.setCurrentTag(tag)"
>
{{ tag }}
</el-tag>
</el-card>
</div>
</el-col>
</el-row>
</el-main>
</el-container>
</div>
</template>
<style scoped>
.home {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.sidebar {
position: sticky;
top: 20px;
}
.el-tag {
margin: 5px;
cursor: pointer;
}
.loading-spinner {
padding: 20px;
}
</style>
4. Markdown编辑器集成
<!-- client/src/components/Editor/MarkdownEditor.vue -->
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import VMdEditor from '@kangc/v-md-editor'
import '@kangc/v-md-editor/lib/style/base-editor.css'
import githubTheme from '@kangc/v-md-editor/lib/theme/github.js'
import '@kangc/v-md-editor/lib/theme/style/github.css'
// 引入所有你需要的插件
import hljs from 'highlight.js'
import createEmojiPlugin from '@kangc/v-md-editor/lib/plugins/emoji/index'
import '@kangc/v-md-editor/lib/plugins/emoji/emoji.css'
VMdEditor.use(githubTheme, {
Hljs: hljs,
})
VMdEditor.use(createEmojiPlugin())
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const content = ref(props.modelValue)
watch(() => props.modelValue, (newVal) => {
if (newVal !== content.value) {
content.value = newVal
}
})
watch(content, (newVal) => {
emit('update:modelValue', newVal)
})
</script>
<template>
<v-md-editor
v-model="content"
:mode="'edit'"
height="500px"
left-toolbar="undo redo clear | h bold italic strikethrough quote | ul ol table hr | link image code emoji"
/>
</template>
四、前后端交互
1. API请求封装
// client/src/api/http.ts
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/auth'
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
apiClient.interceptors.request.use((config) => {
const authStore = useAuthStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
})
// 响应拦截器
apiClient.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
const authStore = useAuthStore()
authStore.logoutUser()
window.location.href = '/login'
}
return Promise.reject(
error.response?.data?.message || error.message || 'Unknown error'
)
}
)
export default apiClient
2. 文章API模块
// client/src/api/post.ts
import apiClient from './http'
import type { Post, PostListParams } from '@/types'
export const fetchPosts = (params?: PostListParams) => {
return apiClient.get<Post[]>('/api/posts', { params })
}
export const getPost = (id: string) => {
return apiClient.get<Post>(`/api/posts/${id}`)
}
export const createPost = (data: FormData) => {
return apiClient.post<Post>('/api/posts', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
export const updatePost = (id: string, data: FormData) => {
return apiClient.put<Post>(`/api/posts/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
export const deletePost = (id: string) => {
return apiClient.delete(`/api/posts/${id}`)
}
五、项目部署方案
1. Docker容器化部署
前端Dockerfile:
# client/Dockerfile
FROM node:18-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
后端Dockerfile:
# server/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 5000
CMD ["node", "app.js"]
docker-compose.yml:
version: '3.8'
services:
client:
build: ./client
ports:
- "80:80"
depends_on:
- server
restart: unless-stopped
server:
build: ./server
ports:
- "5000:5000"
environment:
- MONGODB_URI=mongodb://mongo:27017/blog
depends_on:
- mongo
restart: unless-stopped
mongo:
image: mongo:5.0
volumes:
- mongo-data:/data/db
ports:
- "27017:27017"
restart: unless-stopped
volumes:
mongo-data:
2. Nginx配置
# client/nginx.conf
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://server:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
六、项目扩展方向
1. 性能优化建议
- 实现前端缓存策略
- 添加服务端渲染(SSR)支持
- 使用CDN加速静态资源
- 优化数据库查询索引
2. 功能扩展建议
- 实现文章草稿自动保存
- 添加文章系列功能
- 集成第三方登录(OAuth)
- 开发移动端应用
- 实现全文搜索功能
3. 安全增强建议
- 实现CSRF防护
- 添加速率限制
- 增强输入验证
- 定期安全审计
通过本实战教程,您已经掌握了使用Vue3和Node.js开发全栈博客系统的完整流程。从项目架构设计到具体功能实现,再到最终部署上线,这套技术栈能够满足现代Web应用开发的各项需求。建议在此基础上继续探索更高级的功能和优化方案,打造更加完善的博客平台。