全栈项目实战:Vue3+Node.js开发博客系统

发布于:2025-05-14 ⋅ 阅读:(10) ⋅ 点赞:(0)

全栈项目实战: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应用开发的各项需求。建议在此基础上继续探索更高级的功能和优化方案,打造更加完善的博客平台。