Day 24: 项目6-待办事项应用
📅 日期: 2026年03月18日
🎯 学习目标: 创建一个完整的待办事项应用,实现 CRUD 操作
⏱️ 预计用时: 3-4 小时
📂 分类: 10-现代JavaScript
🎯 学习目标
通过今天的实战项目,你将:
- 掌握 CRUD 操作:创建、读取、更新、删除
- 深入理解 localStorage:浏览器数据持久化
- 学会状态管理:数据驱动视图更新
- 实现筛选排序:按状态、日期筛选
- 应用最佳实践:模块化代码、事件委托
💡 项目需求
我们要创建一个 待办事项应用,具有以下功能:
核心功能
- 添加任务:输入标题,选择优先级、截止日期
- 显示列表:展示所有任务,支持状态筛选
- 编辑任务:修改标题、优先级、状态
- 删除任务:单个删除或批量删除
- 数据持久化:刷新页面后数据不丢失
UI 设计
- 输入区:标题输入框、优先级选择、日期选择、添加按钮
- 筛选区:全部/未完成/已完成切换
- 任务列表:每个任务显示标题、优先级、日期、操作按钮
- 统计栏:总任务数、已完成数、未完成数
📝 核心概念
1. CRUD 操作
// Create - 创建
function addTodo(todo) {
const newTodo = {
id: Date.now(),
title: todo.title,
priority: todo.priority,
dueDate: todo.dueDate,
completed: false,
createdAt: new Date().toISOString()
};
todos.push(newTodo);
saveTodos();
renderTodos();
}
// Read - 读取
function getTodos(filter = 'all') {
if (filter === 'active') {
return todos.filter(t => !t.completed);
} else if (filter === 'completed') {
return todos.filter(t => t.completed);
}
return todos;
}
// Update - 更新
function updateTodo(id, updates) {
const index = todos.findIndex(t => t.id === id);
if (index !== -1) {
todos[index] = { ...todos[index], ...updates };
saveTodos();
renderTodos();
}
}
// Delete - 删除
function deleteTodo(id) {
todos = todos.filter(t => t.id !== id);
saveTodos();
renderTodos();
}
2. localStorage API
// 保存数据
function saveTodos() {
localStorage.setItem('todos', JSON.stringify(todos));
}
// 读取数据
function loadTodos() {
const data = localStorage.getItem('todos');
if (data) {
todos = JSON.parse(data);
} else {
todos = [];
}
}
// 清除数据
function clearTodos() {
localStorage.removeItem('todos');
todos = [];
}
3. 数据驱动视图
// 状态变化 → 更新数据 → 重新渲染
let currentFilter = 'all';
function setFilter(filter) {
currentFilter = filter;
renderTodos(); // 数据驱动视图
}
function renderTodos() {
const filteredTodos = getTodos(currentFilter);
const html = filteredTodos.map(todo => createTodoHTML(todo)).join('');
todoList.innerHTML = html;
updateStats();
}
🛠️ DOM 操作方法
1. 动态创建任务
function createTodoElement(todo) {
const li = document.createElement('li');
li.className = 'todo-item';
li.dataset.id = todo.id;
// 复选框
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'todo-checkbox';
checkbox.checked = todo.completed;
// 标题
const span = document.createElement('span');
span.className = 'todo-title';
span.textContent = todo.title;
// 优先级标签
const priority = document.createElement('span');
priority.className = `priority priority-${todo.priority}`;
priority.textContent = todo.priority.toUpperCase();
// 删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.className = 'todo-delete';
deleteBtn.textContent = '×';
li.append(checkbox, span, priority, deleteBtn);
return li;
}
2. 事件委托
// 使用事件委托处理所有任务点击
todoList.addEventListener('click', (event) => {
const todoItem = event.target.closest('.todo-item');
if (!todoItem) return;
const todoId = parseInt(todoItem.dataset.id);
// 复选框点击
if (event.target.matches('.todo-checkbox')) {
toggleTodo(todoId);
}
// 删除按钮点击
if (event.target.matches('.todo-delete')) {
deleteTodo(todoId);
}
});
3. 表单处理
function handleAddTodo(event) {
event.preventDefault(); // 阻止表单提交
const titleInput = document.getElementById('todo-title');
const prioritySelect = document.getElementById('todo-priority');
const dateInput = document.getElementById('todo-date');
const title = titleInput.value.trim();
if (!title) {
showError('请输入任务标题');
return;
}
const newTodo = {
title: title,
priority: prioritySelect.value,
dueDate: dateInput.value,
completed: false
};
addTodo(newTodo);
// 清空表单
titleInput.value = '';
dateInput.value = '';
titleInput.focus();
}
🎮 完整项目代码
HTML 结构
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>待办事项应用</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>📝 待办事项</h1>
<div class="stats">
<span class="stat">总任务: <strong id="total-count">0</strong></span>
<span class="stat">已完成: <strong id="completed-count">0</strong></span>
<span class="stat">未完成: <strong id="active-count">0</strong></span>
</div>
</header>
<!-- 添加任务表单 -->
<form id="todo-form" class="todo-form">
<input
type="text"
id="todo-title"
class="todo-input"
placeholder="输入任务标题..."
autocomplete="off"
>
<select id="todo-priority" class="todo-priority">
<option value="low">低优先级</option>
<option value="medium" selected>中优先级</option>
<option value="high">高优先级</option>
</select>
<input
type="date"
id="todo-date"
class="todo-date"
>
<button type="submit" class="btn-add">添加</button>
</form>
<!-- 筛选器 -->
<div class="filters">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="active">未完成</button>
<button class="filter-btn" data-filter="completed">已完成</button>
</div>
<!-- 任务列表 -->
<ul id="todo-list" class="todo-list"></ul>
<!-- 空状态 -->
<div id="empty-state" class="empty-state hidden">
<p>🎉 暂无任务,开始添加吧!</p>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
CSS 样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
/* 头部 */
header {
text-align: center;
margin-bottom: 30px;
}
h1 {
color: #667eea;
margin-bottom: 20px;
font-size: 2em;
}
.stats {
display: flex;
justify-content: center;
gap: 30px;
color: #666;
}
.stat {
font-size: 0.9em;
}
.stat strong {
color: #667eea;
font-size: 1.2em;
}
/* 表单 */
.todo-form {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.todo-input {
flex: 1;
min-width: 200px;
padding: 12px 20px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 1em;
transition: border-color 0.3s;
}
.todo-input:focus {
outline: none;
border-color: #667eea;
}
.todo-priority {
padding: 12px 20px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 1em;
cursor: pointer;
}
.todo-date {
padding: 12px 20px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 1em;
}
.btn-add {
padding: 12px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1em;
cursor: pointer;
transition: transform 0.2s;
}
.btn-add:hover {
transform: translateY(-2px);
}
.btn-add:active {
transform: translateY(0);
}
/* 筛选器 */
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: center;
}
.filter-btn {
padding: 8px 20px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
}
.filter-btn:hover {
border-color: #667eea;
}
.filter-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
/* 任务列表 */
.todo-list {
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
margin-bottom: 10px;
transition: all 0.3s;
}
.todo-item:hover {
background: #e9ecef;
transform: translateX(5px);
}
.todo-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
.todo-title {
flex: 1;
font-size: 1.1em;
color: #333;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: #999;
}
.priority {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8em;
font-weight: bold;
}
.priority-low {
background: #d4edda;
color: #155724;
}
.priority-medium {
background: #fff3cd;
color: #856404;
}
.priority-high {
background: #f8d7da;
color: #721c24;
}
.todo-delete {
width: 30px;
height: 30px;
border: none;
background: #dc3545;
color: white;
border-radius: 50%;
font-size: 1.5em;
cursor: pointer;
transition: transform 0.2s;
}
.todo-delete:hover {
transform: scale(1.1);
}
.todo-date {
font-size: 0.9em;
color: #666;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state.hidden {
display: none;
}
.empty-state p {
font-size: 1.2em;
}
/* 动画 */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.todo-item {
animation: slideIn 0.3s ease-out;
}
/* 响应式设计 */
@media (max-width: 600px) {
.todo-form {
flex-direction: column;
}
.todo-input,
.todo-priority,
.todo-date,
.btn-add {
width: 100%;
}
.stats {
flex-direction: column;
gap: 10px;
}
}
JavaScript 逻辑
// ==================== 状态管理 ====================
let todos = [];
let currentFilter = 'all';
// ==================== DOM 元素 ====================
const todoForm = document.getElementById('todo-form');
const todoInput = document.getElementById('todo-title');
const todoPriority = document.getElementById('todo-priority');
const todoDate = document.getElementById('todo-date');
const todoList = document.getElementById('todo-list');
const emptyState = document.getElementById('empty-state');
const filterButtons = document.querySelectorAll('.filter-btn');
const totalCount = document.getElementById('total-count');
const completedCount = document.getElementById('completed-count');
const activeCount = document.getElementById('active-count');
// ==================== 初始化 ====================
function init() {
loadTodos();
renderTodos();
setupEventListeners();
}
// ==================== CRUD 操作 ====================
// 创建任务
function addTodo(todo) {
const newTodo = {
id: Date.now(),
title: todo.title,
priority: todo.priority || 'medium',
dueDate: todo.dueDate || null,
completed: false,
createdAt: new Date().toISOString()
};
todos.push(newTodo);
saveTodos();
renderTodos();
}
// 读取任务
function getTodos(filter = 'all') {
let filtered = todos;
if (filter === 'active') {
filtered = todos.filter(t => !t.completed);
} else if (filter === 'completed') {
filtered = todos.filter(t => t.completed);
}
// 按优先级排序
return filtered.sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
};
}
// 更新任务
function updateTodo(id, updates) {
const index = todos.findIndex(t => t.id === id);
if (index !== -1) {
todos[index] = { ...todos[index], ...updates };
saveTodos();
renderTodos();
}
}
// 删除任务
function deleteTodo(id) {
todos = todos.filter(t => t.id !== id);
saveTodos();
renderTodos();
}
// 切换完成状态
function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
if (todo) {
updateTodo(id, { completed: !todo.completed });
}
}
// ==================== localStorage ====================
function saveTodos() {
localStorage.setItem('todos', JSON.stringify(todos));
}
function loadTodos() {
const data = localStorage.getItem('todos');
if (data) {
todos = JSON.parse(data);
} else {
todos = [];
}
}
// ==================== 渲染 ====================
function renderTodos() {
const filteredTodos = getTodos(currentFilter);
// 清空列表
todoList.innerHTML = '';
// 显示/隐藏空状态
if (filteredTodos.length === 0) {
emptyState.classList.remove('hidden');
} else {
emptyState.classList.add('hidden');
// 渲染每个任务
filteredTodos.forEach(todo => {
const todoElement = createTodoElement(todo);
todoList.appendChild(todoElement);
});
}
// 更新统计
updateStats();
}
function createTodoElement(todo) {
const li = document.createElement('li');
li.className = `todo-item ${todo.completed ? 'completed' : ''}`;
li.dataset.id = todo.id;
// 复选框
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'todo-checkbox';
checkbox.checked = todo.completed;
// 标题
const span = document.createElement('span');
span.className = 'todo-title';
span.textContent = todo.title;
// 优先级标签
const priority = document.createElement('span');
priority.className = `priority priority-${todo.priority}`;
priority.textContent = todo.priority.toUpperCase();
// 日期(如果有)
if (todo.dueDate) {
const dateSpan = document.createElement('span');
dateSpan.className = 'todo-date';
dateSpan.textContent = formatDate(todo.dueDate);
li.appendChild(dateSpan);
}
// 删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.className = 'todo-delete';
deleteBtn.textContent = '×';
deleteBtn.title = '删除任务';
li.append(checkbox, span, priority, deleteBtn);
return li;
}
function updateStats() {
const total = todos.length;
const completed = todos.filter(t => t.completed).length;
const active = total - completed;
totalCount.textContent = total;
completedCount.textContent = completed;
activeCount.textContent = active;
}
function formatDate(dateString) {
const date = new Date(dateString);
const options = { month: 'short', day: 'numeric' };
return date.toLocaleDateString('zh-CN', options);
}
// ==================== 事件处理 ====================
function setupEventListeners() {
// 表单提交
todoForm.addEventListener('submit', handleAddTodo);
// 任务列表点击(事件委托)
todoList.addEventListener('click', handleTodoClick);
// 筛选按钮
filterButtons.forEach(btn => {
btn.addEventListener('click', handleFilterClick);
});
}
function handleAddTodo(event) {
event.preventDefault();
const title = todoInput.value.trim();
if (!title) {
alert('请输入任务标题');
return;
}
const newTodo = {
title: title,
priority: todoPriority.value,
dueDate: todoDate.value || null
};
addTodo(newTodo);
// 清空输入
todoInput.value = '';
todoDate.value = '';
todoInput.focus();
}
function handleTodoClick(event) {
const todoItem = event.target.closest('.todo-item');
if (!todoItem) return;
const todoId = parseInt(todoItem.dataset.id);
// 复选框点击
if (event.target.matches('.todo-checkbox')) {
toggleTodo(todoId);
}
// 删除按钮点击
if (event.target.matches('.todo-delete')) {
if (confirm('确定要删除这个任务吗?')) {
deleteTodo(todoId);
}
}
}
function handleFilterClick(event) {
const filter = event.target.dataset.filter;
// 更新当前筛选
currentFilter = filter;
// 更新按钮状态
filterButtons.forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
// 重新渲染
renderTodos();
}
// ==================== 启动应用 ====================
init();
⚠️ 重要注意事项
1. localStorage 限制
// ✅ 正确:检查可用性
function isLocalStorageAvailable() {
try {
const test = '__storage_test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
}
// ❌ 错误:不检查直接使用
localStorage.setItem('key', 'value'); // 可能抛出异常
2. JSON 序列化
// localStorage 只能存储字符串
// ✅ 正确:序列化对象
localStorage.setItem('todos', JSON.stringify(todos));
// ✅ 正确:解析 JSON
todos = JSON.parse(localStorage.getItem('todos') || '[]');
// ❌ 错误:直接存储对象
localStorage.setItem('todos', todos); // 存储为 "[object Object]"
3. XSS 防护
// ✅ 使用 textContent 而不是 innerHTML
span.textContent = todo.title; // 安全
// ❌ 危险:如果 title 包含 HTML
span.innerHTML = todo.title; // 可能导致 XSS 攻击
4. 数据验证
function addTodo(todo) {
// 验证必填字段
if (!todo.title || todo.title.trim() === '') {
throw new Error('标题不能为空');
}
// 验证优先级
const validPriorities = ['low', 'medium', 'high'];
if (!validPriorities.includes(todo.priority)) {
todo.priority = 'medium'; // 默认值
}
// ... 继续处理
}
🎓 实战应用场景
场景 1: 个人任务管理
- 每日计划:添加当天任务
- 优先级排序:重要任务优先
- 完成跟踪:查看进度
场景 2: 团队协作
- 任务分配:团队成员任务列表
- 进度同步:实时更新状态
- 数据导出:生成报告
场景 3: 项目管理
- 里程碑:重要节点提醒
- 截止日期:到期任务高亮
- 数据分析:统计完成率
✍️ 练习任务
基础练习(必做)
-
代码实现:
- [ ] 创建 HTML、CSS、JS 文件
- [ ] 实现添加任务功能
- [ ] 实现删除任务功能
- [ ] 实现完成状态切换
-
功能验证:
- [ ] 添加任务后立即显示
- [ ] 刷新页面数据不丢失
- [ ] 筛选器工作正常
- [ ] 统计数据正确
进阶练习(推荐)
-
编辑功能:
- 双击任务标题进入编辑模式
- 保存修改后的内容
-
批量操作:
- 全选/取消全选
- 批量删除已完成任务
- 批量修改优先级
-
高级筛选:
- 按优先级筛选
- 按日期范围筛选
- 多条件组合筛选
-
数据导出:
function exportTodos() { const dataStr = JSON.stringify(todos, null, 2); const dataBlob = new Blob([dataStr], {type: 'application/json'}); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = 'todos.json'; link.click(); }
实战挑战(选做)
-
拖拽排序:
- 使用 HTML5 Drag & Drop API
- 拖拽任务调整顺序
- 保存新顺序到 localStorage
-
提醒功能:
- 设置任务提醒时间
- 时间到后显示通知
- 使用 Notification API
-
主题切换:
- 浅色/深色主题
- 保存主题偏好
- 平滑过渡动画
-
云同步:
- 集成后端 API
- 多设备数据同步
- 冲突解决策略
💡 常见问题 FAQ
Q1: localStorage 容量有限制吗?
A: 有。通常限制为 5-10MB。如果需要存储更多数据,考虑使用 IndexedDB。
Q2: 如何处理日期的时区问题?
A:
// 保存时使用 ISO 格式
todo.dueDate = new Date().toISOString();
// 显示时转换为本地时间
const localDate = new Date(todo.dueDate).toLocaleDateString('zh-CN');
Q3: 如何实现数据备份?
A:
function exportTodos() {
const dataStr = JSON.stringify(todos);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(dataBlob);
// 创建下载链接...
}
function importTodos(file) {
const reader = new FileReader();
reader.onload = (e) => {
todos = JSON.parse(e.target.result);
saveTodos();
renderTodos();
};
reader.readAsText(file);
}
Q4: 如何优化性能?
A:
- 使用虚拟列表处理大量数据
- 防抖输入(避免频繁保存)
- 使用 Web Workers 处理复杂计算
Q5: 如何实现撤销功能?
A:
const history = [];
function saveState() {
history.push(JSON.stringify(todos));
if (history.length > 50) history.shift(); // 限制历史记录
}
function undo() {
if (history.length > 0) {
todos = JSON.parse(history.pop());
saveTodos();
renderTodos();
}
}
📚 拓展阅读
MDN 文档
推荐资源
相关框架
🎉 总结
今天我们创建了一个 完整的待办事项应用,涵盖了:
✅ CRUD 操作:完整的增删改查
✅ 数据持久化:localStorage 存储
✅ 状态管理:数据驱动视图
✅ 筛选排序:灵活的数据过滤
✅ 用户体验:流畅的交互设计
这是一个 真实可用的应用,你可以继续扩展成更复杂的项目。记住:好的应用来自于对用户需求的深入理解。
下一节课预告: Day 25 将学习 项目7 – 天气预报应用,使用真实 API 获取天气数据!
💪 加油!坚持就是胜利!