Day-24-项目6-待办事项应用

Day 24: 项目6-待办事项应用

📅 日期: 2026年03月18日
🎯 学习目标: 创建一个完整的待办事项应用,实现 CRUD 操作
⏱️ 预计用时: 3-4 小时
📂 分类: 10-现代JavaScript


🎯 学习目标

通过今天的实战项目,你将:

  1. 掌握 CRUD 操作:创建、读取、更新、删除
  2. 深入理解 localStorage:浏览器数据持久化
  3. 学会状态管理:数据驱动视图更新
  4. 实现筛选排序:按状态、日期筛选
  5. 应用最佳实践:模块化代码、事件委托

💡 项目需求

我们要创建一个 待办事项应用,具有以下功能:

核心功能

  1. 添加任务:输入标题,选择优先级、截止日期
  2. 显示列表:展示所有任务,支持状态筛选
  3. 编辑任务:修改标题、优先级、状态
  4. 删除任务:单个删除或批量删除
  5. 数据持久化:刷新页面后数据不丢失

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: 项目管理

  • 里程碑:重要节点提醒
  • 截止日期:到期任务高亮
  • 数据分析:统计完成率

✍️ 练习任务

基础练习(必做)

  1. 代码实现

    • [ ] 创建 HTML、CSS、JS 文件
    • [ ] 实现添加任务功能
    • [ ] 实现删除任务功能
    • [ ] 实现完成状态切换
  2. 功能验证

    • [ ] 添加任务后立即显示
    • [ ] 刷新页面数据不丢失
    • [ ] 筛选器工作正常
    • [ ] 统计数据正确

进阶练习(推荐)

  1. 编辑功能

    • 双击任务标题进入编辑模式
    • 保存修改后的内容
  2. 批量操作

    • 全选/取消全选
    • 批量删除已完成任务
    • 批量修改优先级
  3. 高级筛选

    • 按优先级筛选
    • 按日期范围筛选
    • 多条件组合筛选
  4. 数据导出

    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();
    }
    

实战挑战(选做)

  1. 拖拽排序

    • 使用 HTML5 Drag & Drop API
    • 拖拽任务调整顺序
    • 保存新顺序到 localStorage
  2. 提醒功能

    • 设置任务提醒时间
    • 时间到后显示通知
    • 使用 Notification API
  3. 主题切换

    • 浅色/深色主题
    • 保存主题偏好
    • 平滑过渡动画
  4. 云同步

    • 集成后端 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 文档

推荐资源

  1. You Don’t Know JS
  2. JavaScript Design Patterns
  3. Clean Code JavaScript

相关框架


🎉 总结

今天我们创建了一个 完整的待办事项应用,涵盖了:

CRUD 操作:完整的增删改查
数据持久化:localStorage 存储
状态管理:数据驱动视图
筛选排序:灵活的数据过滤
用户体验:流畅的交互设计

这是一个 真实可用的应用,你可以继续扩展成更复杂的项目。记住:好的应用来自于对用户需求的深入理解


下一节课预告: Day 25 将学习 项目7 – 天气预报应用,使用真实 API 获取天气数据!

💪 加油!坚持就是胜利!