Node.js 全栈项目实战

章节标题

48. Node.js 全栈项目实战

核心知识点讲解

全栈项目架构设计

项目技术栈选择

后端技术栈

  • Node.js:运行环境
  • Express:Web 框架
  • MongoDB:数据库
  • Mongoose:ODM 库
  • JWT:认证机制
  • bcrypt:密码加密
  • multer:文件上传
  • winston:日志管理

前端技术栈

  • React:前端框架
  • React Router:路由管理
  • Axios:HTTP 客户端
  • Redux Toolkit:状态管理
  • Tailwind CSS:样式框架
  • Formik:表单处理
  • Yup:表单验证

项目架构设计

目录结构

full-stack-project/
├── backend/           # 后端代码
│   ├── src/
│   │   ├── config/      # 配置文件
│   │   ├── controllers/  # 控制器
│   │   ├── middleware/   # 中间件
│   │   ├── models/       # 数据模型
│   │   ├── routes/       # 路由
│   │   ├── services/     # 业务逻辑
│   │   ├── utils/        # 工具函数
│   │   └── app.js        # 应用入口
│   ├── package.json      # 后端依赖
│   └── .env.example      # 环境变量示例
├── frontend/          # 前端代码
│   ├── src/
│   │   ├── components/   # 组件
│   │   ├── pages/        # 页面
│   │   ├── services/     # API 服务
│   │   ├── store/        # Redux 存储
│   │   ├── utils/        # 工具函数
│   │   ├── App.js        # 应用组件
│   │   ├── index.js      # 入口文件
│   │   └── index.css     # 全局样式
│   ├── package.json      # 前端依赖
│   └── .env              # 环境变量
├── docker-compose.yml   # Docker 配置
└── README.md            # 项目文档

数据库设计

**用户表 (users)**:

字段名 类型 描述
_id ObjectId 用户ID
username String 用户名
email String 邮箱
password String 密码哈希
avatar String 头像URL
role String 角色 (user/admin)
createdAt Date 创建时间
updatedAt Date 更新时间

**文章表 (articles)**:

字段名 类型 描述
_id ObjectId 文章ID
title String 标题
content String 内容
author ObjectId 作者ID
category String 分类
tags [String] 标签
coverImage String 封面图片
status String 状态 (draft/published)
viewCount Number 浏览量
createdAt Date 创建时间
updatedAt Date 更新时间

**评论表 (comments)**:

字段名 类型 描述
_id ObjectId 评论ID
articleId ObjectId 文章ID
author ObjectId 作者ID
content String 内容
parentId ObjectId 父评论ID
createdAt Date 创建时间
updatedAt Date 更新时间

API 设计

RESTful API 设计

认证相关 API

  • POST /api/auth/register - 用户注册
  • POST /api/auth/login - 用户登录
  • GET /api/auth/me - 获取当前用户信息
  • PUT /api/auth/profile - 更新用户信息
  • PUT /api/auth/password - 修改密码

文章相关 API

  • GET /api/articles - 获取文章列表
  • GET /api/articles/:id - 获取文章详情
  • POST /api/articles - 创建文章
  • PUT /api/articles/:id - 更新文章
  • DELETE /api/articles/:id - 删除文章
  • GET /api/articles/category/:category - 按分类获取文章
  • GET /api/articles/tag/:tag - 按标签获取文章

评论相关 API

  • GET /api/comments/article/:articleId - 获取文章评论
  • POST /api/comments - 创建评论
  • PUT /api/comments/:id - 更新评论
  • DELETE /api/comments/:id - 删除评论

实用案例分析

完整全栈项目实现

后端实现

1. 项目初始化

# 创建项目目录
mkdir full-stack-project
cd full-stack-project

# 初始化后端
mkdir backend
cd backend
npm init -y

# 安装依赖
npm install express mongoose jsonwebtoken bcrypt multer winston dotenv cors express-validator

# 安装开发依赖
npm install --save-dev nodemon eslint prettier

2. 配置文件

// backend/src/config/config.js
require('dotenv').config();

module.exports = {
  port: process.env.PORT || 5000,
  mongoose: {
    url: process.env.MONGO_URL || 'mongodb://localhost:27017/fullstack',
    options: {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    },
  },
  jwt: {
    secret: process.env.JWT_SECRET || 'your-secret-key',
    expiresIn: '7d',
  },
  upload: {
    dest: 'uploads/',
  },
};

3. 数据模型

// backend/src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    required: true,
    unique: true,
    trim: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true,
  },
  password: {
    type: String,
    required: true,
    minlength: 6,
  },
  avatar: {
    type: String,
    default: 'default-avatar.png',
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user',
  },
}, {
  timestamps: true,
});

// 密码加密
userSchema.pre('save', async function(next) {
  if (this.isModified('password')) {
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
  }
  next();
});

// 密码验证
userSchema.methods.comparePassword = async function(password) {
  return await bcrypt.compare(password, this.password);
};

const User = mongoose.model('User', userSchema);
module.exports = User;
// backend/src/models/Article.js
const mongoose = require('mongoose');

const articleSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
    trim: true,
  },
  content: {
    type: String,
    required: true,
  },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true,
  },
  category: {
    type: String,
    required: true,
    trim: true,
  },
  tags: {
    type: [String],
    trim: true,
  },
  coverImage: {
    type: String,
    default: 'default-cover.png',
  },
  status: {
    type: String,
    enum: ['draft', 'published'],
    default: 'draft',
  },
  viewCount: {
    type: Number,
    default: 0,
  },
}, {
  timestamps: true,
});

const Article = mongoose.model('Article', articleSchema);
module.exports = Article;
// backend/src/models/Comment.js
const mongoose = require('mongoose');

const commentSchema = new mongoose.Schema({
  articleId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Article',
    required: true,
  },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true,
  },
  content: {
    type: String,
    required: true,
    trim: true,
  },
  parentId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Comment',
    default: null,
  },
}, {
  timestamps: true,
});

const Comment = mongoose.model('Comment', commentSchema);
module.exports = Comment;

4. 中间件

// backend/src/middleware/auth.js
const jwt = require('jsonwebtoken');
const config = require('../config/config');

const auth = (req, res, next) => {
  try {
    const token = req.header('Authorization').replace('Bearer ', '');
    const decoded = jwt.verify(token, config.jwt.secret);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ message: 'Unauthorized' });
  }
};

const admin = (req, res, next) => {
  if (req.user && req.user.role === 'admin') {
    next();
  } else {
    res.status(403).json({ message: 'Forbidden' });
  }
};

module.exports = { auth, admin };

5. 控制器

// backend/src/controllers/authController.js
const User = require('../models/User');
const jwt = require('jsonwebtoken');
const config = require('../config/config');

const authController = {
  // 用户注册
  register: async (req, res) => {
    try {
      const { username, email, password } = req.body;
      
      // 检查用户是否已存在
      const existingUser = await User.findOne({ $or: [{ username }, { email }] });
      if (existingUser) {
        return res.status(400).json({ message: 'User already exists' });
      }
      
      // 创建新用户
      const user = new User({ username, email, password });
      await user.save();
      
      // 生成 token
      const token = jwt.sign(
        { id: user._id, username: user.username, role: user.role },
        config.jwt.secret,
        { expiresIn: config.jwt.expiresIn }
      );
      
      res.status(201).json({ token, user: { id: user._id, username: user.username, email: user.email, role: user.role } });
    } catch (error) {
      res.status(500).json({ message: 'Server error' });
    }
  },
  
  // 用户登录
  login: async (req, res) => {
    try {
      const { email, password } = req.body;
      
      // 查找用户
      const user = await User.findOne({ email });
      if (!user) {
        return res.status(400).json({ message: 'Invalid credentials' });
      }
      
      // 验证密码
      const isMatch = await user.comparePassword(password);
      if (!isMatch) {
        return res.status(400).json({ message: 'Invalid credentials' });
      }
      
      // 生成 token
      const token = jwt.sign(
        { id: user._id, username: user.username, role: user.role },
        config.jwt.secret,
        { expiresIn: config.jwt.expiresIn }
      );
      
      res.json({ token, user: { id: user._id, username: user.username, email: user.email, role: user.role } });
    } catch (error) {
      res.status(500).json({ message: 'Server error' });
    }
  },
  
  // 获取当前用户信息
  getMe: async (req, res) => {
    try {
      const user = await User.findById(req.user.id).select('-password');
      res.json(user);
    } catch (error) {
      res.status(500).json({ message: 'Server error' });
    }
  },
};

module.exports = authController;

6. 路由

// backend/src/routes/authRoutes.js
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const { auth } = require('../middleware/auth');

router.post('/register', authController.register);
router.post('/login', authController.login);
router.get('/me', auth, authController.getMe);

module.exports = router;

7. 应用入口

// backend/src/app.js
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const config = require('./config/config');
const authRoutes = require('./routes/authRoutes');
const articleRoutes = require('./routes/articleRoutes');
const commentRoutes = require('./routes/commentRoutes');

const app = express();

// 中间件
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/uploads', express.static('uploads'));

// 路由
app.use('/api/auth', authRoutes);
app.use('/api/articles', articleRoutes);
app.use('/api/comments', commentRoutes);

// 数据库连接
mongoose.connect(config.mongoose.url, config.mongoose.options)
  .then(() => {
    console.log('Connected to MongoDB');
  })
  .catch((error) => {
    console.error('MongoDB connection error:', error);
  });

// 启动服务器
app.listen(config.port, () => {
  console.log(`Server running on port ${config.port}`);
});

前端实现

1. 项目初始化

# 在 full-stack-project 目录下
cd ..

# 初始化前端
npx create-vite frontend --template react
cd frontend

# 安装依赖
npm install react-router-dom axios @reduxjs/toolkit react-redux formik yup tailwindcss

# 配置 Tailwind CSS
npx tailwindcss init -p

2. 配置文件

// frontend/src/services/api.js
import axios from 'axios';

const api = axios.create({
  baseURL: 'http://localhost:5000/api',
  headers: {
    'Content-Type': 'application/json',
  },
});

// 请求拦截器
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器
api.interceptors.response.use(
  (response) => {
    return response;
  },
  (error) => {
    if (error.response && error.response.status === 401) {
      localStorage.removeItem('token');
      localStorage.removeItem('user');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default api;

3. 组件实现

// frontend/src/components/Navbar.jsx
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';

const Navbar = () => {
  const navigate = useNavigate();
  const user = JSON.parse(localStorage.getItem('user'));

  const handleLogout = () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    navigate('/login');
  };

  return (
    <nav className="bg-gray-800 text-white">
      <div className="container mx-auto px-4 py-3 flex justify-between items-center">
        <Link to="/" className="text-xl font-bold">BlogApp</Link>
        
        <div className="flex items-center space-x-4">
          <Link to="/" className="hover:text-gray-300">Home</Link>
          <Link to="/articles" className="hover:text-gray-300">Articles</Link>
          
          {user ? (
            <>
              <Link to="/dashboard" className="hover:text-gray-300">Dashboard</Link>
              <button onClick={handleLogout} className="hover:text-gray-300">Logout</button>
            </>
          ) : (
            <>
              <Link to="/login" className="hover:text-gray-300">Login</Link>
              <Link to="/register" className="hover:text-gray-300">Register</Link>
            </>
          )}
        </div>
      </div>
    </nav>
  );
};

export default Navbar;

4. 页面实现

// frontend/src/pages/LoginPage.jsx
import React from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import api from '../services/api';
import { useNavigate } from 'react-router-dom';

const LoginPage = () => {
  const navigate = useNavigate();

  const validationSchema = Yup.object({
    email: Yup.string().email('Invalid email').required('Required'),
    password: Yup.string().required('Required'),
  });

  const formik = useFormik({
    initialValues: {
      email: '',
      password: '',
    },
    validationSchema,
    onSubmit: async (values) => {
      try {
        const response = await api.post('/auth/login', values);
        localStorage.setItem('token', response.data.token);
        localStorage.setItem('user', JSON.stringify(response.data.user));
        navigate('/dashboard');
      } catch (error) {
        alert('Login failed');
      }
    },
  });

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md">
        <h2 className="text-2xl font-bold mb-6">Login</h2>
        <form onSubmit={formik.handleSubmit}>
          <div className="mb-4">
            <label className="block text-gray-700 mb-2">Email</label>
            <input
              type="email"
              className="w-full px-3 py-2 border border-gray-300 rounded-md"
              {...formik.getFieldProps('email')}
            />
            {formik.errors.email && (
              <div className="text-red-500 text-sm">{formik.errors.email}</div>
            )}
          </div>
          <div className="mb-6">
            <label className="block text-gray-700 mb-2">Password</label>
            <input
              type="password"
              className="w-full px-3 py-2 border border-gray-300 rounded-md"
              {...formik.getFieldProps('password')}
            />
            {formik.errors.password && (
              <div className="text-red-500 text-sm">{formik.errors.password}</div>
            )}
          </div>
          <button type="submit" className="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600">
            Login
          </button>
        </form>
      </div>
    </div>
  );
};

export default LoginPage;

5. 路由配置

// frontend/src/App.jsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Navbar from './components/Navbar';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/DashboardPage';
import ArticleListPage from './pages/ArticleListPage';
import ArticleDetailPage from './pages/ArticleDetailPage';
import CreateArticlePage from './pages/CreateArticlePage';
import EditArticlePage from './pages/EditArticlePage';

function App() {
  return (
    <Router>
      <Navbar />
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/login" element={<LoginPage />} />
        <Route path="/register" element={<RegisterPage />} />
        <Route path="/dashboard" element={<DashboardPage />} />
        <Route path="/articles" element={<ArticleListPage />} />
        <Route path="/articles/:id" element={<ArticleDetailPage />} />
        <Route path="/create" element={<CreateArticlePage />} />
        <Route path="/edit/:id" element={<EditArticlePage />} />
      </Routes>
    </Router>
  );
}

export default App;

Docker 部署

1. Dockerfile

# backend/Dockerfile
FROM node:16-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

RUN mkdir -p uploads

EXPOSE 5000

CMD ["npm", "start"]
# frontend/Dockerfile
FROM node:16-alpine as build

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

FROM nginx:alpine

COPY --from=build /app/build /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

2. Docker Compose

# docker-compose.yml
version: '3.8'

services:
  backend:
    build: ./backend
    ports:
      - "5000:5000"
    environment:
      - MONGO_URL=mongodb://mongo:27017/fullstack
      - JWT_SECRET=your-secret-key
    depends_on:
      - mongo
    volumes:
      - ./backend/uploads:/app/uploads

  frontend:
    build: ./frontend
    ports:
      - "3000:80"
    depends_on:
      - backend

  mongo:
    image: mongo:4.4
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:

3. 启动服务

# 在 full-stack-project 目录下
docker-compose up -d

代码优化建议

全栈项目优化策略

后端优化

  1. 性能优化

    • 数据库索引:为常用查询字段添加索引
    • 缓存:使用 Redis 缓存热点数据
    • 分页:实现 API 分页,避免一次性返回大量数据
    • 代码拆分:将业务逻辑拆分为服务层,提高代码可维护性
  2. 安全性优化

    • 输入验证:使用 express-validator 验证所有输入
    • CORS 配置:在生产环境中配置具体的域名,而不是使用通配符
    • ** helmet**:使用 helmet 中间件增强安全性
    • 速率限制:实现 API 速率限制,防止暴力攻击
  3. 可维护性优化

    • 错误处理:实现统一的错误处理中间件
    • 日志管理:使用 winston 记录详细的日志
    • API 文档:使用 Swagger 生成 API 文档
    • 测试:编写单元测试和集成测试

前端优化

  1. 性能优化

    • 代码分割:使用 React.lazy 和 Suspense 实现代码分割
    • 图片优化:使用适当的图片格式和尺寸,实现懒加载
    • 状态管理:合理使用 Redux,避免过度使用
    • 网络优化:实现请求缓存,减少重复请求
  2. 用户体验优化

    • 加载状态:为所有异步操作添加加载状态
    • 错误处理:优雅处理错误,提供友好的错误提示
    • 响应式设计:确保在不同设备上的良好体验
    • 动画效果:添加适当的动画,提升用户体验
  3. 代码质量优化

    • ESLint:使用 ESLint 规范代码风格
    • Prettier:使用 Prettier 格式化代码
    • TypeScript:考虑使用 TypeScript 提高代码类型安全性
    • 组件复用:提取可复用组件,减少代码重复

常见问题与解决方案

全栈开发常见问题

1. 跨域问题

问题:前端请求后端 API 时出现跨域错误

解决方案

  • 在后端使用 cors 中间件
  • 配置正确的 CORS 选项
  • 在生产环境中,使用 Nginx 反向代理

2. 认证问题

问题:用户登录后,刷新页面认证状态丢失

解决方案

  • 使用 localStorage 存储 token
  • 实现请求拦截器,自动添加 token
  • 实现响应拦截器,处理 token 过期

3. 部署问题

问题:部署到生产环境后,API 调用失败

解决方案

  • 检查环境变量配置
  • 确保数据库连接正确
  • 检查防火墙设置
  • 查看服务器日志

4. 性能问题

问题:应用加载缓慢,响应时间长

解决方案

  • 前端:实现代码分割、图片优化、缓存策略
  • 后端:优化数据库查询、实现缓存、使用集群模式
  • 服务器:使用 CDN、负载均衡、适当的服务器配置

学习目标

通过本章节的学习,您应该能够:

  1. 掌握全栈项目架构设计:能够设计合理的项目架构和目录结构
  2. 实现完整的后端服务:包括 API 开发、数据库操作、认证授权、文件上传等
  3. 开发前端应用:使用 React 构建用户界面,实现路由、状态管理、表单处理等
  4. 实现前后端交互:通过 API 实现前后端数据通信
  5. 部署全栈应用:使用 Docker 容器化部署,实现快速上线
  6. 优化应用性能:掌握前端和后端的性能优化技巧
  7. 解决常见问题:能够排查和解决全栈开发中的常见问题

小结

本章节通过一个完整的全栈项目实战,综合运用了之前所学的所有 Node.js 知识,实现了一个功能完整的博客应用。项目涵盖了从项目架构设计、技术栈选择、目录结构规划,到后端 API 开发、数据库设计、认证授权实现,再到前端界面开发、前后端交互、Docker 部署等完整的全栈开发流程。

通过这个项目实战,您应该对 Node.js 全栈开发有了全面的了解,能够独立完成一个完整的全栈项目开发。在实际开发中,您可以根据具体需求调整技术栈和功能实现,但核心的开发流程和设计原则是相通的。

全栈开发不仅要求掌握多种技术,更要求具备系统思维和全局视野,能够从整体上把握项目的架构和发展方向。希望本章节的学习能够帮助您在全栈开发的道路上更进一步。

« 上一篇 Node.js 插件开发 下一篇 » Node.js 最佳实践