MERN (MongoDB, Express, React, Node.js) 技术栈是一个非常棒的选择
Todo List 是学习这个技术栈的经典入门项目
分步骤完成这个项目
项目结构
创建一个总的项目文件夹,然后在里面分别创建 server(后端)和 client(前端)两个文件夹

数据库设置(MongoDB Atlas)
MongoDB Atlas提供了一个免费云数据库服务
具体步骤如下

具体界面大概长这样

后端(Express)
进入server目录 开始搭建后端
A.后端项目初始化
1.初始化Node.js项目
npm init -y //创建一个package.json文件
2.安装依赖
express:我们的 Node.js web 框架
mongoose:一个 ODM (Object Data Modeling) 库 它是我们与 MongoDB 数据库沟通的桥梁 它能让我们用 JavaScript 对象来定义数据结构(Schema)
cors:一个中间件,用于解决跨域资源共享 (CORS) 问题。因为我们的 React 前端 (运行在 localhost:3000) 需要调用后端 (运行在 localhost:5000),它们在不同的”域”(端口不同),浏览器默认会阻止这种请求
dotenv:用于管理环境变量 我们会把敏感的数据库连接字符串放在一个 .env 文件中,而不是硬编码在代码里
nodemon (开发依赖):一个工具,它会监视你的文件改动,并自动重启服务器,极大提升开发效率
# 安装生产依赖
npm install express mongoose cors dotenv
# 安装开发依赖
npm install -D nodemon
3.配置package.json
打开 package.json 文件,在 scripts 部分添加 “start” 和 “dev” 命令:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
npm start:在生产环境运行服务器
npm run dev:在开发环境运行服务器(使用 nodemon)
4.创建.env文件
在 server 根目录创建 .env 文件 这是存放秘密信息的地方
# .env 文件
# 把你在 MongoDB Atlas 复制的连接字符串粘贴到这里
# 把 <username> 替换为你的数据库用户名 (例如 todoUser)
# 把 <password> 替换为你的数据库密码 (例如 yourStrongPassword123)
# 你可能还需要在 .net/ 后面指定一个数据库名,例如 .net/todolist?
MONGO_URI=mongodb+srv://todoUser:yourStrongPassword123@cluster0.xxxxx.mongodb.net/todolist?retryWrites=true&w=majority
# 我们让服务器运行在 5000 端口
PORT=5000
5.创建.gitignore文件
在 server 根目录创建 .gitignore 文件,防止把 node_modules 和 .env 文件提交到 Git
# .gitignore 文件
node_modules
.env
B.后端文件结构
为了保持代码整洁,我们采用 MVC (Model-View-Controller) 的变体结构:
server/
├── config/
│ └── db.js # 数据库连接逻辑
├── models/
│ └── Todo.js # 数据库模型 (Schema)
├── controllers/
│ └── todoController.js # API 的业务逻辑
├── routes/
│ └── todos.js # API 的路由定义
├── node_modules/
├── .env # 环境变量
├── .gitignore
├── package.json
└── server.js # 服务器入口文件
C.详细的后端代码
1.数据库连接(config/db.js)
这是连接到你 MongoDB Atlas 数据库的核心文件
// config/db.js
const mongoose = require('mongoose');
const dotenv = require('dotenv');
// 加载 .env 文件中的环境变量
dotenv.config();
const connectDB = async () => {
try {
// mongoose.connect 返回一个 promise
await mongoose.connect(process.env.MONGO_URI, {
// 这些是 Mongoose 6.x 之后的推荐选项,可以不写,Mongoose 默认会处理
// useNewUrlParser: true,
// useUnifiedTopology: true,
});
console.log('MongoDB 连接成功...');
} catch (err) {
console.error('MongoDB 连接失败:', err.message);
// 如果连接失败,退出 Node.js 进程
process.exit(1);
}
};
module.exports = connectDB;
2.数据模型(models/Todo.js)
Mongoose Schema 用于定义 “Todo” 事项在数据库中长什么样
// models/Todo.js
const mongoose = require('mongoose');
// 定义 Schema
const TodoSchema = new mongoose.Schema({
task: {
type: String,
required: [true, '任务内容不能为空'], // 必填项
},
completed: {
type: Boolean,
default: false, // 默认值为 false (未完成)
},
createdAt: {
type: Date,
default: Date.now, // 默认值为当前时间
},
});
// 导出模型
// mongoose.model('模型名', Schema)
// 'Todo' 是模型的名字,MongoDB 会自动将其转为复数形式 'todos' 作为集合(collection)的名字
module.exports = mongoose.model('Todo', TodoSchema);
3.路由(routes/todos.js)
路由文件只负责定义 API 的 URL 路径 (endpoints) 和它们使用的 HTTP 方法 (GET, POST, PUT, DELETE),并指定由哪个控制器函数来处理
// routes/todos.js
const express = require('express');
const router = express.Router();
// 引入我们的控制器
const {
getTodos,
createTodo,
updateTodo,
deleteTodo,
} = require('../controllers/todoController');
// 定义路由
// GET /api/todos - 获取所有 todo
router.get('/', getTodos);
// POST /api/todos - 创建一个新的 todo
router.post('/', createTodo);
// PUT /api/todos/:id - 更新一个 todo (例如标记为完成)
// :id 是一个动态参数,表示 todo 的唯一 ID
router.put('/:id', updateTodo);
// DELETE /api/todos/:id - 删除一个 todo
router.delete('/:id', deleteTodo);
module.exports = router;
4.控制器(controllers/todoController.js)
这是后端的核心业务逻辑
控制器函数负责处理请求、与数据库交互(通过 Model)、并返回响应
我们使用 async/await 来处理 Mongoose 的异步操作
// controllers/todoController.js
const Todo = require('../models/Todo'); // 引入 Todo 模型
// @desc 获取所有 Todos
// @route GET /api/todos
exports.getTodos = async (req, res) => {
try {
// Todo.find() 会查找这个 collection 中的所有文档
// .sort({ createdAt: -1 }) 按创建时间倒序排列
const todos = await Todo.find().sort({ createdAt: -1 });
// res.json() 发送 JSON 响应
res.status(200).json(todos);
} catch (err) {
res.status(500).json({ message: '服务器错误', error: err.message });
}
};
// @desc 创建一个新的 Todo
// @route POST /api/todos
exports.createTodo = async (req, res) => {
try {
// req.body 包含了从前端发送过来的数据 (例如: { "task": "学习 Node.js" })
const { task } = req.body;
if (!task) {
return res.status(400).json({ message: '任务内容不能为空' });
}
// 使用 Todo 模型创建一个新的文档实例
const newTodo = new Todo({
task: task,
// 'completed' 和 'createdAt' 会使用 Schema 中的默认值
});
// .save() 将新文档保存到数据库
const savedTodo = await newTodo.save();
res.status(201).json(savedTodo); // 201 表示 "Created"
} catch (err) {
res.status(500).json({ message: '服务器错误', error: err.message });
}
};
// @desc 更新一个 Todo (切换 completed 状态)
// @route PUT /api/todos/:id
exports.updateTodo = async (req, res) => {
try {
// req.params.id 从 URL 中获取 ID
const todo = await Todo.findById(req.params.id);
if (!todo) {
return res.status(404).json({ message: '未找到该 Todo' });
}
// 切换 completed 状态
// 我们也可以通过 req.body 来获取更新的内容,但这里只做切换
todo.completed = !todo.completed;
const updatedTodo = await todo.save();
// 或者使用 findByIdAndUpdate
// const updatedTodo = await Todo.findByIdAndUpdate(
// req.params.id,
// { completed: !todo.completed },
// { new: true } // {new: true} 会返回更新后的文档
// );
res.status(200).json(updatedTodo);
} catch (err) {
res.status(500).json({ message: '服务器错误', error: err.message });
}
};
// @desc 删除一个 Todo
// @route DELETE /api/todos/:id
exports.deleteTodo = async (req, res) => {
try {
const todo = await Todo.findById(req.params.id);
if (!todo) {
return res.status(404).json({ message: '未找到该 Todo' });
}
// await todo.remove(); // Mongoose 6.x 之后不推荐
await Todo.findByIdAndDelete(req.params.id);
res.status(200).json({ message: 'Todo 删除成功' });
} catch (err) {
res.status(500).json({ message: '服务器错误', error: err.message });
}
};
5.服务器入口(server.js)
这是我们服务器的”启动文件” 负责:
- 加载环境变量
- 连接数据库
- 初始化 Express
- 加载中间件 (cors, express.json)
- 挂载路由
- 启动服务器监听
// server.js
const express = require('express');
const dotenv = require('dotenv');
const cors = require('cors');
const connectDB = require('./config/db'); // 引入数据库连接函数
// 加载环境变量 (必须在最前面)
dotenv.config();
// 引入路由文件
const todoRoutes = require('./routes/todos');
// 连接数据库
connectDB();
// 初始化 Express app
const app = express();
// 中间件
// 1. 启用 CORS
app.use(cors());
// 2. 启用 Express 内置的 JSON 解析器
// (这样 req.body 才能解析 application/json 格式的请求体)
app.use(express.json());
// 挂载路由
// 当访问 /api/todos 时,使用 todoRoutes 来处理
app.use('/api/todos', todoRoutes);
// 定义根路由 (可选, 用于测试服务器是否运行)
app.get('/', (req, res) => {
res.send('Todo List API 正在运行...');
});
// 从 .env 文件获取端口号,如果没有定义,则使用 5000
const PORT = process.env.PORT || 5000;
// 启动服务器
app.listen(PORT, () => {
console.log(`服务器正在 http://localhost:${PORT} 上运行`);
});
D.运行和测试后端
npm run dev (server目录下)
在终端显示:
MongoDB 连接成功…
服务器正在 http://localhost:5000 上运行

前端(React)
切换到client目录
A.React 项目初始化
1.使用 Create React App (CRA) 创建项目
npx create-react-app .
# 注意后面的 "." 表示在当前 (client) 目录创建
2.安装 axios
我们将使用 axios 来发起 HTTP 请求(调用后端 API),它比原生的 fetch 更好用
npm install axios
3.配置代理(Proxy)
为了解决开发环境下的 CORS 跨域问题(React:3000 -> Express:5000),我们可以在 client/package.json 中添加一个 proxy 字段
打开 client/package.json,在末尾(与 dependencies 同级)添加
"proxy": "http://localhost:5000"
这样设置后,React 在开发中发起 /api/todos 请求时,会自动将其代理到 http://localhost:5000/api/todos,避免了跨域
4.清理src目录
删除 src 目录下的 App.test.js, logo.svg, setupTests.js 等文件,保持 App.js, index.js, App.css, index.css 即可
B.React完整代码
src/App.js
// src/App.js
import React, { useState, useEffect }
from 'react';
import axios from 'axios';
import './App.css'; // 引入 CSS
function App() {
const [todos, setTodos] = useState([]);
const [newTask, setNewTask] = useState('');
// 1. 获取所有 Todos (GET)
const fetchTodos = async () => {
try {
// 注意:因为设置了 proxy, 我们只需要写 /api/todos
const res = await axios.get('/api/todos');
setTodos(res.data);
} catch (err) {
console.error('获取 Todos 失败:', err);
}
};
// 首次加载时获取数据
useEffect(() => {
fetchTodos();
}, []); // 空依赖数组表示只在组件挂载时运行一次
// 2. 添加新 Todo (POST)
const handleAddTodo = async (e) => {
e.preventDefault(); // 阻止表单默认提交行为
if (!newTask.trim()) return; // 忽略空任务
try {
const res = await axios.post('/api/todos', { task: newTask });
// 将新返回的 todo 添加到列表的最前面
setTodos([res.data, ...todos]);
setNewTask(''); // 清空输入框
} catch (err) {
console.error('添加 Todo 失败:', err);
}
};
// 3. 切换 Todo 完成状态 (PUT)
const handleToggleComplete = async (id, currentCompleted) => {
try {
const res = await axios.put(`/api/todos/${id}`, {
// 注意:我们的后端逻辑是自动切换状态,所以 body 不是必须的
// 但如果后端需要,你可以发送 { completed: !currentCompleted }
});
// 更新前端 state
setTodos(
todos.map((todo) =>
todo._id === id ? { ...todo, completed: res.data.completed } : todo
)
);
} catch (err) {
console.error('更新 Todo 失败:', err);
}
};
// 4. 删除 Todo (DELETE)
const handleDeleteTodo = async (id) => {
// *** 新增的删除确认逻辑 ***
if (!window.confirm('您确定要删除这个任务吗?')) {
return; // 如果用户点击“取消”,则停止执行后续操作
}
// *** 结束新增逻辑 ***
try {
await axios.delete(`/api/todos/${id}`);
// 从前端 state 中移除
setTodos(todos.filter((todo) => todo._id !== id));
} catch (err) {
console.error('删除 Todo 失败:', err);
}
};
return (
<div className="App">
<header>
<h1>Todo List</h1>
</header>
{/* 添加 Todo 的表单 */}
<form onSubmit={handleAddTodo} className="add-todo-form">
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="添加新任务..."
/>
<button type="submit">添加</button>
</form>
{/* Todo 列表 */}
<ul className="todo-list">
{todos.map((todo) => (
<li
key={todo._id}
className={`todo-item ${todo.completed ? 'completed' : ''}`}
>
<span
onClick={() => handleToggleComplete(todo._id, todo.completed)}
className="task-text"
>
{todo.task}
</span>
<button
onClick={() => handleDeleteTodo(todo._id)}
className="delete-btn"
>
删除
</button>
</li>
))}
</ul>
</div>
);
}
export default App;
src/App.css
/* src/App.css */
/* 通用样式和布局 */
body {
margin: 0;
padding: 0;
font-family: 'Arial', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 活泼的浅蓝色渐变背景 */
background: linear-gradient(135deg, #f0f8ff 0%, #e6e6fa 100%);
display: flex;
justify-content: center;
align-items: flex-start; /* 从顶部开始布局 */
padding: 50px 20px;
min-height: 100vh;
}
.App {
width: 100%;
max-width: 550px; /* 稍微放大一点 */
background-color: #ffffff;
border-radius: 20px; /* 更圆润的边角 */
/* 更明显的阴影,带一点色彩 */
box-shadow: 0 10px 30px rgba(108, 92, 231, 0.2);
padding: 30px;
min-height: 500px;
}
header {
text-align: center;
margin-bottom: 30px;
}
h1 {
/* 使用紫色作为标题强调色 */
color: #6c5ce7;
font-size: 2.5em;
font-weight: 700;
/* 标题下方增加一个圆润的强调 */
display: inline-block;
padding-bottom: 10px;
position: relative;
}
h1::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 4px;
background-color: #a29bfe;
border-radius: 2px;
}
/* 添加 Todo 表单样式 */
.add-todo-form {
display: flex;
margin-bottom: 35px;
gap: 12px;
}
.add-todo-form input {
flex-grow: 1;
padding: 14px 18px;
border: 2px solid #ddd;
border-radius: 10px; /* 保持圆润 */
font-size: 1.05em;
transition: all 0.3s ease;
}
.add-todo-form input:focus {
outline: none;
/* 聚焦时使用鲜明的青色 */
border-color: #00cec9;
box-shadow: 0 0 10px rgba(0, 206, 201, 0.3);
}
.add-todo-form button {
padding: 14px 25px;
/* 按钮使用紫色 */
background-color: #6c5ce7;
color: white;
border: none;
border-radius: 10px;
font-size: 1.05em;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s, transform 0.1s;
}
.add-todo-form button:hover {
background-color: #584bda;
}
/* Todo 列表样式 */
.todo-list {
list-style: none;
padding: 0;
}
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px;
margin-bottom: 15px;
background-color: #f8f9fa;
border-radius: 10px;
transition: all 0.3s ease;
/* 列表项的柔和阴影 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-left: 6px solid #a29bfe; /* 左侧紫色装饰条 */
}
.todo-item:hover {
background-color: #ffffff;
/* 鼠标悬停时边框颜色更深 */
border-left-color: #6c5ce7;
}
.task-text {
flex-grow: 1;
margin-right: 15px;
font-size: 1.1em;
cursor: pointer;
color: #2d3436; /* 较深的文字颜色 */
}
/* 完成状态 */
.todo-item.completed {
opacity: 0.9;
/* 完成项使用鲜明的青色装饰条 */
border-left-color: #00b894;
background-color: #e8f7f5; /* 略带青色的背景 */
}
.todo-item.completed .task-text {
text-decoration: line-through;
color: #95a5a6; /* 灰色文字 */
}
/* 删除按钮样式 */
.delete-btn {
/* 醒目的红色 */
background-color: #ff6b6b;
color: white;
border: none;
padding: 10px 18px;
border-radius: 8px;
cursor: pointer;
font-size: 0.9em;
font-weight: 600;
transition: background-color 0.3s, transform 0.1s;
}
.delete-btn:hover {
background-color: #ee5253;
}
C.运行项目
现在你可以同时运行后端和前端了
运行后端: 打开第一个终端窗口,进入 server 目录 npm run dev
运行前端: 打开第二个终端窗口,进入 client 目录 npm start
现在,就可以在浏览器中添加、查看、切换完成状态和删除 Todo 事项了!所有数据都会持久化存储在你的 MongoDB Atlas 云数据库中
总结
最后的文件结构like this

后端控制台

前端控制台

最终效果

Network
