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 | 用户名 |
| 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 prettier2. 配置文件
// 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 -p2. 配置文件
// 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代码优化建议
全栈项目优化策略
后端优化
性能优化
- 数据库索引:为常用查询字段添加索引
- 缓存:使用 Redis 缓存热点数据
- 分页:实现 API 分页,避免一次性返回大量数据
- 代码拆分:将业务逻辑拆分为服务层,提高代码可维护性
安全性优化
- 输入验证:使用 express-validator 验证所有输入
- CORS 配置:在生产环境中配置具体的域名,而不是使用通配符
- ** helmet**:使用 helmet 中间件增强安全性
- 速率限制:实现 API 速率限制,防止暴力攻击
可维护性优化
- 错误处理:实现统一的错误处理中间件
- 日志管理:使用 winston 记录详细的日志
- API 文档:使用 Swagger 生成 API 文档
- 测试:编写单元测试和集成测试
前端优化
性能优化
- 代码分割:使用 React.lazy 和 Suspense 实现代码分割
- 图片优化:使用适当的图片格式和尺寸,实现懒加载
- 状态管理:合理使用 Redux,避免过度使用
- 网络优化:实现请求缓存,减少重复请求
用户体验优化
- 加载状态:为所有异步操作添加加载状态
- 错误处理:优雅处理错误,提供友好的错误提示
- 响应式设计:确保在不同设备上的良好体验
- 动画效果:添加适当的动画,提升用户体验
代码质量优化
- ESLint:使用 ESLint 规范代码风格
- Prettier:使用 Prettier 格式化代码
- TypeScript:考虑使用 TypeScript 提高代码类型安全性
- 组件复用:提取可复用组件,减少代码重复
常见问题与解决方案
全栈开发常见问题
1. 跨域问题
问题:前端请求后端 API 时出现跨域错误
解决方案:
- 在后端使用 cors 中间件
- 配置正确的 CORS 选项
- 在生产环境中,使用 Nginx 反向代理
2. 认证问题
问题:用户登录后,刷新页面认证状态丢失
解决方案:
- 使用 localStorage 存储 token
- 实现请求拦截器,自动添加 token
- 实现响应拦截器,处理 token 过期
3. 部署问题
问题:部署到生产环境后,API 调用失败
解决方案:
- 检查环境变量配置
- 确保数据库连接正确
- 检查防火墙设置
- 查看服务器日志
4. 性能问题
问题:应用加载缓慢,响应时间长
解决方案:
- 前端:实现代码分割、图片优化、缓存策略
- 后端:优化数据库查询、实现缓存、使用集群模式
- 服务器:使用 CDN、负载均衡、适当的服务器配置
学习目标
通过本章节的学习,您应该能够:
- 掌握全栈项目架构设计:能够设计合理的项目架构和目录结构
- 实现完整的后端服务:包括 API 开发、数据库操作、认证授权、文件上传等
- 开发前端应用:使用 React 构建用户界面,实现路由、状态管理、表单处理等
- 实现前后端交互:通过 API 实现前后端数据通信
- 部署全栈应用:使用 Docker 容器化部署,实现快速上线
- 优化应用性能:掌握前端和后端的性能优化技巧
- 解决常见问题:能够排查和解决全栈开发中的常见问题
小结
本章节通过一个完整的全栈项目实战,综合运用了之前所学的所有 Node.js 知识,实现了一个功能完整的博客应用。项目涵盖了从项目架构设计、技术栈选择、目录结构规划,到后端 API 开发、数据库设计、认证授权实现,再到前端界面开发、前后端交互、Docker 部署等完整的全栈开发流程。
通过这个项目实战,您应该对 Node.js 全栈开发有了全面的了解,能够独立完成一个完整的全栈项目开发。在实际开发中,您可以根据具体需求调整技术栈和功能实现,但核心的开发流程和设计原则是相通的。
全栈开发不仅要求掌握多种技术,更要求具备系统思维和全局视野,能够从整体上把握项目的架构和发展方向。希望本章节的学习能够帮助您在全栈开发的道路上更进一步。