React与NodeJS全栈项目从零搭建实战指南与常见问题解析
引言:全栈开发的魅力与挑战
在现代Web开发中,React作为前端框架的霸主,NodeJS作为后端JavaScript运行时的领导者,它们的组合(通常称为MERN/MEAN栈)已经成为开发者的首选。这种组合的最大优势在于统一了技术栈——前后端都使用JavaScript,极大地提高了开发效率和团队协作的便利性。
然而,从零开始搭建一个完整的全栈项目并非易事。开发者需要面对依赖管理、环境配置、API设计、跨域问题、部署策略等一系列挑战。本文将通过详细的步骤指导和完整的代码示例,帮助你从零搭建一个生产级别的React+NodeJS全栈项目,并深入解析常见问题。
第一部分:项目初始化与架构设计
1.1 项目目录结构规划
良好的项目结构是成功的一半。我们采用前后端分离的架构,但将它们放在同一个仓库中以便管理。
my-fullstack-app/ ├── client/ # React前端项目 │ ├── public/ │ ├── src/ │ │ ├── components/ │ │ ├── pages/ │ │ ├── services/ # API调用封装 │ │ ├── utils/ # 工具函数 │ │ └── App.js │ └── package.json ├── server/ # NodeJS后端项目 │ ├── config/ # 配置文件 │ ├── controllers/ # 业务逻辑 │ ├── models/ # 数据模型 │ ├── routes/ # 路由定义 │ ├── middleware/ # 中间件 │ ├── utils/ # 工具函数 │ ├── app.js # Express应用入口 │ └── package.json ├── .gitignore ├── README.md └── package.json # 根目录的package.json(可选) 1.2 初始化前后端项目
步骤1:创建项目根目录并初始化
mkdir my-fullstack-app cd my-fullstack-app # 初始化根目录package.json(可选,用于管理共同脚本) npm init -y 步骤2:初始化React前端
npx create-react-app client # 或者使用Vite(更快) npm create vite@latest client -- --template react 步骤3:初始化NodeJS后端
mkdir server cd server npm init -y # 安装核心依赖 npm install express mongoose dotenv cors bcryptjs jsonwebtoken npm install --save-dev nodemon 1.3 配置并发启动工具
为了同时启动前后端,我们可以使用concurrently工具。在根目录安装:
npm install --save-dev concurrently 然后在根目录的package.json中添加脚本:
{ "name": "my-fullstack-app", "scripts": { "client": "cd client && npm start", "server": "cd server && npm run dev", "dev": "concurrently "npm run server" "npm run client"", "install-all": "npm install && cd client && npm install && cd ../server && npm install" } } 第二部分:NodeJS后端开发实战
2.1 Express应用基础配置
在server/app.js中创建Express应用:
const express = require('express'); const cors = require('cors'); const dotenv = require('dotenv'); const connectDB = require('./config/database'); // 加载环境变量 dotenv.config(); // 初始化Express应用 const app = express(); // 连接数据库 connectDB(); // 中间件配置 app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:3000', credentials: true })); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); // 基础路由 app.get('/api', (req, res) => { res.json({ message: 'API is running successfully' }); }); // 错误处理中间件 app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ success: false, message: 'Internal Server Error', error: process.env.NODE_ENV === 'production' ? {} : err }); }); const PORT = process.env.PORT || 5000; app.listen(PORT, () => { console.log(`Server running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`); }); 2.2 数据库连接与配置
创建server/config/database.js:
const mongoose = require('mongoose'); const connectDB = async () => { try { const conn = await mongoose.connect(process.env.MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true, }); console.log(`MongoDB Connected: ${conn.connection.host}`); } catch (error) { console.error('Database connection error:', error.message); process.exit(1); // 退出进程 } }; module.exports = connectDB; 创建.env文件在server目录下:
NODE_ENV=development PORT=5000 MONGO_URI=mongodb://localhost:27017/fullstack_app CLIENT_URL=http://localhost:3000 JWT_SECRET=your_super_secret_key_change_this_in_production 2.3 用户认证系统实战
数据模型(server/models/User.js):
const mongoose = require('mongoose'); const bcrypt = require('bcryptjs'); const userSchema = new mongoose.Schema({ name: { type: String, required: [true, 'Please add a name'], trim: true }, email: { type: String, required: [true, 'Please add an email'], unique: true, lowercase: true, match: [/^S+@S+.S+$/, 'Please add a valid email'] }, password: { type: String, required: [true, 'Please add a password'], minlength: 6, select: false }, role: { type: String, enum: ['user', 'admin'], default: 'user' }, createdAt: { type: Date, default: Date.now } }); // 密码加密中间件 userSchema.pre('save', async function(next) { if (!this.isModified('password')) return next(); const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); next(); }); // 验证密码方法 userSchema.methods.matchPassword = async function(enteredPassword) { return await bcrypt.compare(enteredPassword, this.password); }; module.exports = mongoose.model('User', userSchema); 认证控制器(server/controllers/authController.js):
const User = require('../models/User'); const jwt = require('jsonwebtoken'); const asyncHandler = require('express-async-handler'); // @desc 注册新用户 // @route POST /api/auth/register // @access Public exports.register = asyncHandler(async (req, res) => { const { name, email, password } = req.body; // 检查用户是否已存在 const userExists = await User.findOne({ email }); if (userExists) { return res.status(400).json({ success: false, message: 'User already exists' }); } // 创建用户 const user = await User.create({ name, email, password }); // 生成JWT const token = jwt.sign( { id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '30d' } ); res.status(201).json({ success: true, token, user: { id: user._id, name: user.name, email: user.email, role: user.role } }); }); // @desc 用户登录 // @route POST /api/auth/login // @access Public exports.login = asyncHandler(async (req, res) => { const { email, password } = req.body; // 检查用户是否存在 const user = await User.findOne({ email }).select('+password'); if (!user || !(await user.matchPassword(password))) { return res.status(401).json({ success: false, message: 'Invalid credentials' }); } // 生成JWT const token = jwt.sign( { id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '30d' } ); res.json({ success: true, token, user: { id: user._id, name: user.name, email: user.email, role: user.role } }); }); // @desc 获取当前用户信息 // @route GET /api/auth/me // @access Private exports.getMe = asyncHandler(async (req, res) => { const user = await User.findById(req.user.id); res.json({ success: true, user }); }); 认证中间件(server/middleware/auth.js):
const jwt = require('jsonwebtoken'); const asyncHandler = require('express-async-handler'); const User = require('../models/User'); // 保护路由中间件 exports.protect = asyncHandler(async (req, res, next) => { let token; if ( req.headers.authorization && req.headers.authorization.startsWith('Bearer') ) { try { // 获取token token = req.headers.authorization.split(' ')[1]; // 验证token const decoded = jwt.verify(token, process.env.JWT_SECRET); // 获取用户信息(不包含密码) req.user = await User.findById(decoded.id).select('-password'); if (!req.user) { return res.status(401).json({ success: false, message: 'Not authorized, user not found' }); } next(); } catch (error) { console.error(error); return res.status(401).json({ success: false, message: 'Not authorized, token failed' }); } } if (!token) { return res.status(401).json({ success: false, message: 'Not authorized, no token' }); } }); // 授权中间件(基于角色) exports.authorize = (...roles) => { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ success: false, message: `Not authorized, required roles: ${roles.join(', ')}` }); } next(); }; }; 2.4 路由系统设计
创建server/routes/auth.js:
const express = require('express'); const router = express.Router(); const { register, login, getMe } = require('../controllers/authController'); const { protect } = require('../middleware/auth'); router.post('/register', register); router.post('/login', login); router.get('/me', protect, getMe); module.exports = router; 在server/app.js中注册路由:
// ... 其他导入 const authRoutes = require('./routes/auth'); // ... 其他中间件 app.use('/api/auth', authRoutes); 第三部分:React前端开发实战
3.1 项目结构与状态管理
我们将使用Context API进行状态管理,避免引入Redux的复杂性。
API服务封装(client/src/services/api.js):
import axios from 'axios'; // 创建axios实例 const API = axios.create({ baseURL: process.env.REACT_APP_API_URL || 'http://localhost:5000/api', timeout: 10000, 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.data; }, (error) => { // 统一错误处理 if (error.response) { const { status, data } = error.response; if (status === 401) { // token过期或无效,清除token并跳转登录 localStorage.removeItem('token'); window.location.href = '/login'; } return Promise.reject(data?.message || '请求失败'); } return Promise.reject('网络错误,请检查连接'); } ); export default API; 认证服务(client/src/services/authService.js):
import API from './api'; export const register = async (userData) => { const response = await API.post('/auth/register', userData); if (response.success) { localStorage.setItem('token', response.token); } return response; }; export const login = async (credentials) => { const response = await API.post('/auth/login', credentials); if (response.success) { localStorage.setItem('token', response.token); } return response; }; export const getMe = async () => { return await API.get('/auth/me'); }; export const logout = () => { localStorage.removeItem('token'); }; Auth Context(client/src/contexts/AuthContext.js):
import React, { createContext, useState, useEffect, useContext } from 'react'; import { getMe, logout as logoutService } from '../services/authService'; const AuthContext = createContext(); export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [isAuthenticated, setIsAuthenticated] = useState(false); useEffect(() => { const token = localStorage.getItem('token'); if (token) { fetchUser(); } else { setLoading(false); } }, []); const fetchUser = async () => { try { const response = await getMe(); if (response.success) { setUser(response.user); setIsAuthenticated(true); } } catch (error) { console.error('Failed to fetch user:', error); localStorage.removeItem('token'); } finally { setLoading(false); } }; const login = async (credentials) => { const response = await loginService(credentials); if (response.success) { await fetchUser(); } return response; }; const register = async (userData) => { const response = await registerService(userData); if (response.success) { await fetchUser(); } return response; }; const logout = () => { logoutService(); setUser(null); setIsAuthenticated(false); }; const value = { user, isAuthenticated, loading, login, register, logout, fetchUser }; return ( <AuthContext.Provider value={value}> {children} </AuthContext.Provider> ); }; export const useAuth = () => { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }; 3.2 路由配置与受保护路由
路由配置(client/src/App.js):
import React from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import Login from './pages/Login'; import Register from './pages/Register'; import Dashboard from './pages/Dashboard'; import Profile from './pages/Profile'; import Navbar from './components/Navbar'; // 受保护的路由组件 const ProtectedRoute = ({ children }) => { const { isAuthenticated, loading } = useAuth(); if (loading) { return <div>Loading...</div>; } return isAuthenticated ? children : <Navigate to="/login" />; }; // 公共路由组件(已登录用户无法访问) const PublicRoute = ({ children }) => { const { isAuthenticated, loading } = useAuth(); if (loading) { return <div>Loading...</div>; } return !isAuthenticated ? children : <Navigate to="/dashboard" />; }; function App() { return ( <AuthProvider> <Router> <Navbar /> <div className="container"> <Routes> <Route path="/" element={<Navigate to="/dashboard" />} /> <Route path="/login" element={ <PublicRoute> <Login /> </PublicRoute> } /> <Route path="/register" element={ <PublicRoute> <Register /> </PublicRoute> } /> <Route path="/dashboard" element={ <ProtectedRoute> <Dashboard /> </ProtectedRoute> } /> <Route path="/profile" element={ <ProtectedRoute> <Profile /> </ProtectedRoute> } /> </Routes> </div> </Router> </AuthProvider> ); } export default App; 3.3 UI组件与页面实现
登录页面(client/src/pages/Login.js):
import React, { useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; import { useNavigate, Link } from 'react-router-dom'; const Login = () => { const [formData, setFormData] = useState({ email: '', password: '' }); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const { login } = useAuth(); const navigate = useNavigate(); const handleChange = (e) => { setFormData({ ...formData, [e.target.name]: e.target.value }); }; const handleSubmit = async (e) => { e.preventDefault(); setError(''); setLoading(true); try { const response = await login(formData); if (response.success) { navigate('/dashboard'); } else { setError(response.message); } } catch (err) { setError(err.message || '登录失败'); } finally { setLoading(false); } }; return ( <div className="auth-container"> <div className="auth-card"> <h2>登录</h2> {error && <div className="error-message">{error}</div>} <form onSubmit={handleSubmit}> <div className="form-group"> <label>邮箱</label> <input type="email" name="email" value={formData.email} onChange={handleChange} required /> </div> <div className="form-group"> <label>密码</label> <input type="password" name="password" value={formData.password} onChange={handleChange} required /> </div> <button type="submit" disabled={loading}> {loading ? '登录中...' : '登录'} </button> </form> <p className="auth-link"> 还没有账号?<Link to="/register">立即注册</Link> </p> </div> </div> ); }; export default Login; 仪表盘页面(client/src/pages/Dashboard.js):
import React, { useState, useEffect } from 'react'; import { useAuth } from '../contexts/AuthContext'; import API from '../services/api'; const Dashboard = () => { const { user } = useAuth(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetchDashboardData(); }, []); const fetchDashboardData = async () => { try { // 模拟获取仪表盘数据 const response = await API.get('/dashboard'); // 需要在后端创建此路由 if (response.success) { setData(response.data); } } catch (error) { console.error('Failed to fetch dashboard data:', error); } finally { setLoading(false); } }; if (loading) return <div>加载中...</div>; return ( <div className="dashboard"> <h1>欢迎, {user?.name}!</h1> <div className="dashboard-content"> <div className="stats-card"> <h3>用户信息</h3> <p>邮箱: {user?.email}</p> <p>角色: {user?.role}</p> </div> {data && ( <div className="data-card"> <h3>系统数据</h3> <pre>{JSON.stringify(data, null, 2)}</pre> </div> )} </div> </div> ); }; export default Dashboard; 第四部分:常见问题深度解析
4.1 跨域问题(CORS)详解
问题描述:浏览器安全策略阻止前端直接访问不同源的后端API。
解决方案:
- 开发环境配置(server/app.js):
// 允许的源列表 const allowedOrigins = [ 'http://localhost:3000', 'http://localhost:3001', process.env.CLIENT_URL ]; app.use(cors({ origin: function (origin, callback) { // 允许没有origin的请求(如postman) if (!origin) return callback(null, true); if (allowedOrigins.indexOf(origin) === -1) { const msg = 'The CORS policy does not allow access from the specified Origin.'; return callback(new Error(msg), false); } return callback(null, true); }, credentials: true, // 允许cookie methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] })); - 生产环境Nginx配置:
server { listen 80; server_name yourdomain.com; location /api/ { proxy_pass http://localhost:5000/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; # CORS headers add_header 'Access-Control-Allow-Origin' 'https://yourfrontend.com' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; if ($request_method = 'OPTIONS') { add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } } location / { proxy_pass http://localhost:3000; # ... 其他配置 } } 4.2 认证与授权问题
问题1:Token存储与安全
错误做法:
// ❌ 不安全:存储在localStorage,易受XSS攻击 localStorage.setItem('token', token); // ❌ 不安全:URL参数传递 window.location.href = `/reset-password?token=${token}`; 正确做法:
// ✅ 推荐:httpOnly cookie(后端设置) // 后端设置cookie res.cookie('token', token, { httpOnly: true, // 防止XSS攻击 secure: process.env.NODE_ENV === 'production', // HTTPS only sameSite: 'strict', // 防止CSRF maxAge: 30 * 24 * 60 * 60 * 1000 // 30天 }); // ✅ 前端:使用内存状态 + 短期token // 在AuthContext中,token不存储localStorage,而是存储在内存中 // 配合refresh token机制 问题2:Token刷新机制
实现Refresh Token:
// server/middleware/auth.js exports.refreshToken = asyncHandler(async (req, res) => { const { refreshToken } = req.body; if (!refreshToken) { return res.status(401).json({ message: 'No refresh token provided' }); } try { const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET); const user = await User.findById(decoded.id); if (!user) { return res.status(401).json({ message: 'Invalid refresh token' }); } // 生成新的access token const newAccessToken = jwt.sign( { id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '15m' } ); res.json({ success: true, token: newAccessToken }); } catch (error) { return res.status(401).json({ message: 'Invalid refresh token' }); } }); 4.3 环境变量管理
问题:不同环境(开发、测试、生产)需要不同的配置。
解决方案:
- 后端环境变量:
# .env NODE_ENV=development PORT=5000 MONGO_URI=mongodb://localhost:27017/fullstack_app JWT_SECRET=dev_secret # .env.production NODE_ENV=production PORT=5000 MONGO_URI=mongodb://prod-mongo:27017/fullstack_app JWT_SECRET=prod_super_secret_key - 前端环境变量(Create React App):
# .env REACT_APP_API_URL=http://localhost:5000/api REACT_APP_VERSION=$npm_package_version # .env.production REACT_APP_API_URL=https://api.yourdomain.com/api - 动态配置加载:
// server/config/index.js const config = { development: { database: process.env.MONGO_URI || 'mongodb://localhost:27017/dev', jwtSecret: process.env.JWT_SECRET || 'dev_secret', corsOrigin: ['http://localhost:3000'] }, production: { database: process.env.MONGO_URI, jwtSecret: process.env.JWT_SECRET, corsOrigin: ['https://yourdomain.com'] } }; module.exports = config[process.env.NODE_ENV || 'development']; 4.4 错误处理最佳实践
后端统一错误处理:
// server/utils/AppError.js class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; this.isOperational = true; Error.captureStackTrace(this, this.constructor); } } module.exports = AppError; // server/middleware/errorHandler.js const AppError = require('../utils/AppError'); const errorHandler = (err, req, res, next) => { // 默认错误 let error = { ...err }; error.message = err.message; // Mongoose bad ObjectId if (err.name === 'CastError') { const message = `Resource not found with id of ${err.value}`; error = new AppError(message, 404); } // Mongoose duplicate key if (err.code === 11000) { const field = Object.keys(err.keyValue)[0]; const message = `Duplicate field value: ${field}. Please use another value`; error = new AppError(message, 400); } // Mongoose validation error if (err.name === 'ValidationError') { const message = Object.values(err.errors).map(val => val.message).join(', '); error = new AppError(message, 400); } // JWT errors if (err.name === 'JsonWebTokenError') { const message = 'Invalid token. Please log in again'; error = new AppError(message, 401); } if (err.name === 'TokenExpiredError') { const message = 'Your token has expired. Please log in again'; error = new AppError(message, 401); } // 开发环境:返回详细错误 if (process.env.NODE_ENV === 'development') { res.status(error.statusCode || 500).json({ success: false, error: error, message: error.message, stack: error.stack }); } // 生产环境:返回简洁错误 else { // 已知错误:返回具体信息 if (error.isOperational) { res.status(error.statusCode).json({ success: false, message: error.message }); } // 未知错误:不泄露细节 else { console.error('ERROR 💥', err); res.status(500).json({ success: false, message: 'Something went very wrong!' }); } } }; module.exports = errorHandler; 前端错误处理:
// client/src/utils/errorHandler.js export const handleAPIError = (error) => { if (error.response) { // 服务器返回了错误状态码 const { status, data } = error.response; switch (status) { case 400: return data.message || '请求参数错误'; case 401: // 清除token并跳转登录 localStorage.removeItem('token'); window.location.href = '/login'; return '请重新登录'; case 403: return '您没有权限执行此操作'; case 404: return '请求的资源不存在'; case 500: return '服务器内部错误,请稍后重试'; default: return '请求失败'; } } else if (error.request) { // 请求已发出但没有收到响应 return '网络连接失败,请检查您的网络'; } else { // 发送请求时出错 return '请求配置错误'; } }; // 使用示例 try { const data = await API.get('/some-endpoint'); } catch (error) { const message = handleAPIError(error); toast.error(message); // 使用toast显示错误 } 4.5 性能优化策略
后端优化:
- 数据库查询优化:
// ❌ 不好的做法:N+1查询问题 const users = await User.find(); for (let user of users) { user.posts = await Post.find({ author: user._id }); // 每次循环都查询 } // ✅ 好的做法:使用populate const users = await User.find().populate({ path: 'posts', select: 'title createdAt' }); // ✅ 或者使用聚合管道 const users = await User.aggregate([ { $lookup: { from: 'posts', localField: '_id', foreignField: 'author', as: 'posts' } } ]); - 缓存策略:
const redis = require('redis'); const client = redis.createClient(); // 缓存中间件 const cache = (req, res, next) => { const key = req.originalUrl; client.get(key, (err, data) => { if (err) throw err; if (data !== null) { return res.json(JSON.parse(data)); } // 重写res.json来缓存结果 const originalJson = res.json; res.json = (body) => { client.setex(key, 3600, JSON.stringify(body)); // 缓存1小时 originalJson.call(res, body); }; next(); }); }; app.get('/api/posts', cache, getPosts); 前端优化:
- 代码分割与懒加载:
import React, { Suspense, lazy } from 'react'; // 懒加载组件 const Dashboard = lazy(() => import('./pages/Dashboard')); const Profile = lazy(() => import('./pages/Profile')); function App() { return ( <Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/profile" element={<Profile />} /> </Routes> </Suspense> ); } - React.memo 和 useCallback:
import React, { memo, useCallback, useState } from 'react'; // 使用memo避免不必要的重渲染 const UserCard = memo(({ user, onSelect }) => { console.log('Rendering UserCard'); return ( <div onClick={() => onSelect(user.id)}> {user.name} </div> ); }); function UserList() { const [users, setUsers] = useState([]); // 使用useCallback缓存函数 const handleSelectUser = useCallback((userId) => { console.log('Selected user:', userId); }, []); return ( <div> {users.map(user => ( <UserCard key={user.id} user={user} onSelect={handleSelectUser} /> ))} </div> ); } 第五部分:部署与生产环境配置
5.1 后端部署(Docker化)
Dockerfile(server/Dockerfile):
FROM node:18-alpine WORKDIR /app # 复制package.json和package-lock.json COPY package*.json ./ # 安装依赖(不包含devDependencies) RUN npm ci --only=production # 复制源代码 COPY . . # 创建非root用户 RUN addgroup -g 1001 -S nodejs RUN adduser -S nodeuser -u 1001 # 更改文件所有权 RUN chown -R nodeuser:nodejs /app USER nodeuser # 暴露端口 EXPOSE 5000 # 健康检查 HEALTHCHECK --interval=30s --timeout=3s CMD node healthcheck.js || exit 1 # 启动命令 CMD ["node", "app.js"] docker-compose.yml(根目录):
version: '3.8' services: backend: build: ./server ports: - "5000:5000" environment: - NODE_ENV=production - MONGO_URI=mongodb://mongo:27017/fullstack_app - JWT_SECRET=${JWT_SECRET} depends_on: - mongo restart: unless-stopped mongo: image: mongo:6 volumes: - mongo-data:/data/db environment: - MONGO_INITDB_ROOT_USERNAME=admin - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD} ports: - "27017:27017" restart: unless-stopped frontend: build: context: ./client dockerfile: Dockerfile.production ports: - "80:80" depends_on: - backend restart: unless-stopped volumes: mongo-data: 前端生产Dockerfile(client/Dockerfile.production):
# 构建阶段 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/build /usr/share/nginx/html # 复制nginx配置 COPY nginx.conf /etc/nginx/conf.d/default.conf # 暴露80端口 EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] Nginx配置(client/nginx.conf):
server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; # Gzip压缩 gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; # 前端路由(支持React Router) location / { try_files $uri $uri/ /index.html; } # API代理 location /api/ { proxy_pass http://backend:5000/api/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } # 静态资源缓存 location ~* .(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control "public, immutable"; } # 安全头 add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; } 5.2 CI/CD配置(GitHub Actions)
.github/workflows/deploy.yml:
name: Deploy to Production on: push: branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' cache: 'npm' - name: Install dependencies run: | npm ci cd client && npm ci cd ../server && npm ci - name: Run tests run: | npm test cd client && npm test cd ../server && npm test deploy: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Build and push Docker images run: | docker build -t yourusername/fullstack-backend:latest ./server docker build -t yourusername/fullstack-frontend:latest ./client docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} docker push yourusername/fullstack-backend:latest docker push yourusername/fullstack-frontend:latest - name: Deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USERNAME }} key: ${{ secrets.SSH_KEY }} script: | cd /opt/fullstack-app docker-compose pull docker-compose up -d docker system prune -f 5.3 生产环境安全加固
1. 安全中间件(server/middleware/security.js):
const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const mongoSanitize = require('express-mongo-sanitize'); const xss = require('xss-clean'); const hpp = require('hpp'); // 安全HTTP头 exports.secureHeaders = helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'"], imgSrc: ["'self'", "data:", "https:"], }, }, }); // 速率限制 exports.rateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15分钟 max: 100, // 每个IP最多100个请求 message: 'Too many requests from this IP, please try again later', standardHeaders: true, legacyHeaders: false, }); // 数据消毒 exports.sanitizeData = [ mongoSanitize(), // 防止MongoDB注入 xss(), // 防止XSS攻击 hpp() // 防止HTTP参数污染 ]; 2. 在app.js中使用:
const { secureHeaders, rateLimiter, sanitizeData } = require('./middleware/security'); // 安全中间件 app.use(secureHeaders); app.use('/api', rateLimiter); app.use(sanitizeData); 第六部分:监控与日志
6.1 日志系统
Winston日志配置(server/config/logger.js):
const winston = require('winston'); const path = require('path'); const logger = winston.createLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), transports: [ // 错误日志 new winston.transports.File({ filename: path.join(__dirname, '../logs/error.log'), level: 'error' }), // 所有日志 new winston.transports.File({ filename: path.join(__dirname, '../logs/combined.log') }) ] }); // 开发环境同时输出到控制台 if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ) })); } module.exports = logger; 使用日志:
const logger = require('./config/logger'); // 记录不同级别的日志 logger.error('Database connection failed'); logger.warn('API response time is high'); logger.info('User logged in', { userId: user.id }); logger.debug('Request payload:', req.body); 6.2 性能监控
使用Prometheus + Grafana:
// server/middleware/metrics.js const client = require('prom-client'); // 创建Registry const register = new client.Registry(); // 添加默认指标 client.collectDefaultMetrics({ register }); // 自定义指标 const httpRequestDuration = new client.Histogram({ name: 'http_request_duration_seconds', help: 'Duration of HTTP requests in seconds', labelNames: ['method', 'route', 'status_code'], buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10] }); register.registerMetric(httpRequestDuration); // 中间件 exports.metricsMiddleware = (req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = (Date.now() - start) / 1000; httpRequestDuration .labels(req.method, req.route?.path || req.path, res.statusCode) .observe(duration); }); next(); }; // 暴露metrics端点 app.get('/metrics', async (req, res) => { res.set('Content-Type', register.contentType); res.end(await register.metrics()); }); 第七部分:测试策略
7.1 后端测试(Jest + Supertest)
测试配置(server/package.json):
{ "scripts": { "test": "NODE_ENV=test jest --watchAll", "test:ci": "NODE_ENV=test jest --coverage" }, "jest": { "testEnvironment": "node", "coveragePathIgnorePatterns": ["/node_modules/"], "setupFilesAfterEnv": ["<rootDir>/tests/setup.js"] } } 测试示例(server/tests/auth.test.js):
const request = require('supertest'); const app = require('../app'); const User = require('../models/User'); const mongoose = require('mongoose'); describe('Auth Routes', () => { beforeAll(async () => { await mongoose.connect(process.env.MONGO_TEST_URI); }); afterAll(async () => { await mongoose.connection.close(); }); beforeEach(async () => { await User.deleteMany({}); }); describe('POST /api/auth/register', () => { it('should register a new user', async () => { const res = await request(app) .post('/api/auth/register') .send({ name: 'Test User', email: 'test@example.com', password: 'password123' }); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('token'); expect(res.body.user.email).toBe('test@example.com'); }); it('should not register duplicate email', async () => { await User.create({ name: 'Test User', email: 'test@example.com', password: 'password123' }); const res = await request(app) .post('/api/auth/register') .send({ name: 'Test User', email: 'test@example.com', password: 'password123' }); expect(res.statusCode).toEqual(400); }); }); }); 7.2 前端测试(React Testing Library)
测试示例(client/src/tests/Login.test.js):
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import { AuthProvider } from '../contexts/AuthContext'; import Login from '../pages/Login'; import { login as loginService } from '../services/authService'; jest.mock('../services/authService'); describe('Login Component', () => { const mockLogin = loginService; beforeEach(() => { mockLogin.mockClear(); }); it('renders login form', () => { render( <BrowserRouter> <AuthProvider> <Login /> </AuthProvider> </BrowserRouter> ); expect(screen.getByLabelText(/邮箱/i)).toBeInTheDocument(); expect(screen.getByLabelText(/密码/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /登录/i })).toBeInTheDocument(); }); it('submits form with correct data', async () => { mockLogin.mockResolvedValue({ success: true, token: 'fake-token', user: { id: '1', name: 'Test User' } }); render( <BrowserRouter> <AuthProvider> <Login /> </AuthProvider> </BrowserRouter> ); fireEvent.change(screen.getByLabelText(/邮箱/i), { target: { value: 'test@example.com' } }); fireEvent.change(screen.getByLabelText(/密码/i), { target: { value: 'password123' } }); fireEvent.click(screen.getByRole('button', { name: /登录/i })); await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123' }); }); }); }); 第八部分:总结与最佳实践清单
8.1 项目检查清单
部署前检查:
- [ ] 所有环境变量已正确配置
- [ ] 数据库已备份并测试恢复流程
- [ ] 所有API端点都有错误处理
- [ ] 前端构建无警告和错误
- [ ] 已配置HTTPS和安全头
- [ ] 日志系统已配置
- [ ] 监控指标已设置
- [ ] 已测试速率限制
- [ ] 已配置自动备份
- [ ] 已编写API文档
8.2 性能优化清单
后端:
- [ ] 数据库索引已优化
- [ ] 使用Redis缓存热点数据
- [ ] 启用Gzip压缩
- [ ] 使用PM2集群模式
- [ ] 数据库连接池配置
前端:
- [ ] 代码分割和懒加载
- [ ] 图片优化(WebP格式)
- [ ] 使用CDN分发静态资源
- [ ] 启用浏览器缓存
- [ ] 减少第三方依赖
8.3 安全最佳实践
- [ ] 使用HTTPS everywhere
- [ ] 实施速率限制
- [ ] 定期更新依赖
- [ ] 使用安全的密码策略
- [ ] 实施CSP策略
- [ ] 定期安全审计
- [ ] 使用环境变量管理密钥
- [ ] 实施访问控制列表
结语
React + NodeJS全栈开发是一个强大且灵活的组合。通过本文的详细指南,你应该能够从零开始搭建一个生产级别的全栈项目,并处理常见的开发问题。记住,优秀的全栈开发不仅仅是编写代码,更重要的是考虑安全性、性能、可维护性和用户体验。
持续学习和实践是提升技能的关键。建议定期回顾代码,关注新的最佳实践,并不断优化你的项目。祝你在全栈开发的道路上取得成功!
支付宝扫一扫
微信扫一扫