React实战项目:待办清单

通过这个实战项目,你将构建一个功能完整的待办清单应用,涵盖React的核心概念和最佳实践。

项目概述

我们将创建一个具有以下功能的待办清单应用:

  • 添加新的待办事项
  • 标记事项为完成/未完成
  • 删除待办事项
  • 筛选事项(全部/进行中/已完成)
  • 数据持久化(本地存储)
  • 统计信息显示
  • 响应式设计
项目功能
  • 增删改查
  • 状态管理
  • 数据持久化
  • 组件设计
  • 响应式UI

项目演示

项目结构

todo-app/
├── src/
│   ├── components/
│   │   ├── TodoApp.js        # 主组件
│   │   ├── TodoForm.js       # 输入表单组件
│   │   ├── TodoList.js       # 列表组件
│   │   ├── TodoItem.js       # 单个待办事项组件
│   │   ├── TodoFilter.js     # 筛选组件
│   │   └── TodoStats.js      # 统计组件
│   ├── hooks/
│   │   └── useLocalStorage.js # 自定义Hook
│   ├── utils/
│   │   └── constants.js      # 常量定义
│   ├── App.js               # 根组件
│   └── index.js             # 入口文件
└── package.json

逐步实现

1 项目设置与基础结构

// App.js - 根组件
import React from 'react';
import TodoApp from './components/TodoApp';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <h1><i className="fas fa-list-check"></i> React待办清单</h1>
        <p>一个功能完整的React待办事项应用</p>
      </header>
      <main>
        <TodoApp />
      </main>
      <footer>
        <p>使用React Hooks构建 © 2023</p>
      </footer>
    </div>
  );
}

export default App;
// index.js - 入口文件
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

2 数据模型与常量定义

// utils/constants.js
export const FILTERS = {
  ALL: 'all',
  ACTIVE: 'active',
  COMPLETED: 'completed'
};

export const LOCAL_STORAGE_KEY = 'react-todos';

3 自定义Hook:本地存储

// hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // 从localStorage读取初始值
  const readValue = () => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`读取localStorage键"${key}"时出错:`, error);
      return initialValue;
    }
  };

  const [storedValue, setStoredValue] = useState(readValue);

  // 当storedValue变化时,保存到localStorage
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.warn(`保存到localStorage键"${key}"时出错:`, error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

export default useLocalStorage;

4 主组件:TodoApp

// components/TodoApp.js
import React, { useState } from 'react';
import useLocalStorage from '../hooks/useLocalStorage';
import TodoForm from './TodoForm';
import TodoList from './TodoList';
import TodoFilter from './TodoFilter';
import TodoStats from './TodoStats';
import { FILTERS, LOCAL_STORAGE_KEY } from '../utils/constants';

function TodoApp() {
  // 使用自定义Hook管理待办事项
  const [todos, setTodos] = useLocalStorage(LOCAL_STORAGE_KEY, []);
  const [filter, setFilter] = useState(FILTERS.ALL);

  // 添加待办事项
  const addTodo = (text) => {
    if (!text.trim()) return;

    const newTodo = {
      id: Date.now(),
      text: text.trim(),
      completed: false,
      createdAt: new Date().toISOString()
    };

    setTodos([newTodo, ...todos]);
  };

  // 切换待办事项状态
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  // 删除待办事项
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 更新待办事项文本
  const updateTodo = (id, newText) => {
    if (!newText.trim()) {
      deleteTodo(id);
      return;
    }

    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, text: newText.trim() } : todo
    ));
  };

  // 清除所有已完成事项
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed));
  };

  // 根据筛选条件过滤待办事项
  const filteredTodos = todos.filter(todo => {
    switch (filter) {
      case FILTERS.ACTIVE:
        return !todo.completed;
      case FILTERS.COMPLETED:
        return todo.completed;
      default:
        return true;
    }
  });

  return (
    <div className="todo-app">
      <TodoForm onAdd={addTodo} />

      <TodoFilter
        currentFilter={filter}
        onFilterChange={setFilter}
        onClearCompleted={clearCompleted}
        hasCompleted={todos.some(todo => todo.completed)}
      />

      <TodoList
        todos={filteredTodos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
        onUpdate={updateTodo}
      />

      <TodoStats todos={todos} />
    </div>
  );
}

export default TodoApp;

5 表单组件:TodoForm

// components/TodoForm.js
import React, { useState } from 'react';

function TodoForm({ onAdd }) {
  const [input, setInput] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();

    if (!input.trim()) {
      setError('请输入待办事项内容');
      return;
    }

    onAdd(input);
    setInput('');
    setError('');
  };

  const handleKeyDown = (e) => {
    if (e.key === 'Enter') {
      handleSubmit(e);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="todo-form">
      <div className="input-group">
        <input
          type="text"
          value={input}
          onChange={(e) => {
            setInput(e.target.value);
            setError('');
          }}
          onKeyDown={handleKeyDown}
          placeholder="请输入待办事项..."
          className={`form-control ${error ? 'is-invalid' : ''}`}
          autoFocus
        />
        <button
          type="submit"
          className="btn btn-primary"
          disabled={!input.trim()}
        >
          <i className="fas fa-plus"></i> 添加
        </button>
      </div>
      {error && <div className="invalid-feedback d-block">{error}</div>}
      <small className="form-text text-muted">
        按Enter键快速添加
      </small>
    </form>
  );
}

export default TodoForm;

6 列表组件:TodoList

// components/TodoList.js
import React from 'react';
import TodoItem from './TodoItem';

function TodoList({ todos, onToggle, onDelete, onUpdate }) {
  if (todos.length === 0) {
    return (
      <div className="todo-empty">
        <i className="fas fa-clipboard-list fa-3x"></i>
        <h4>暂无待办事项</h4>
        <p>添加你的第一个待办事项吧!</p>
      </div>
    );
  }

  return (
    <div className="todo-list">
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onDelete={onDelete}
          onUpdate={onUpdate}
        />
      ))}
    </div>
  );
}

export default TodoList;

7 单个事项组件:TodoItem

// components/TodoItem.js
import React, { useState } from 'react';

function TodoItem({ todo, onToggle, onDelete, onUpdate }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  const handleEdit = () => {
    setIsEditing(true);
  };

  const handleSave = () => {
    onUpdate(todo.id, editText);
    setIsEditing(false);
  };

  const handleCancel = () => {
    setEditText(todo.text);
    setIsEditing(false);
  };

  const handleKeyDown = (e) => {
    if (e.key === 'Enter') {
      handleSave();
    } else if (e.key === 'Escape') {
      handleCancel();
    }
  };

  return (
    <div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      {isEditing ? (
        <div className="todo-edit">
          <input
            type="text"
            value={editText}
            onChange={(e) => setEditText(e.target.value)}
            onKeyDown={handleKeyDown}
            className="form-control"
            autoFocus
          />
          <div className="todo-edit-actions">
            <button
              onClick={handleSave}
              className="btn btn-sm btn-success"
              disabled={!editText.trim()}
            >
              <i className="fas fa-check"></i>
            </button>
            <button
              onClick={handleCancel}
              className="btn btn-sm btn-secondary"
            >
              <i className="fas fa-times"></i>
            </button>
          </div>
        </div>
      ) : (
        <>
          <div className="todo-content">
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => onToggle(todo.id)}
              className="todo-checkbox"
            />
            <span className="todo-text" onClick={() => onToggle(todo.id)}>
              {todo.text}
            </span>
            <small className="todo-date">
              {new Date(todo.createdAt).toLocaleDateString()}
            </small>
          </div>
          <div className="todo-actions">
            <button
              onClick={handleEdit}
              className="btn btn-sm btn-outline-primary"
              title="编辑"
            >
              <i className="fas fa-edit"></i>
            </button>
            <button
              onClick={() => onDelete(todo.id)}
              className="btn btn-sm btn-outline-danger"
              title="删除"
            >
              <i className="fas fa-trash"></i>
            </button>
          </div>
        </>
      )}
    </div>
  );
}

export default TodoItem;

8 筛选组件:TodoFilter

// components/TodoFilter.js
import React from 'react';
import { FILTERS } from '../utils/constants';

function TodoFilter({ currentFilter, onFilterChange, onClearCompleted, hasCompleted }) {
  const filters = [
    { key: FILTERS.ALL, label: '全部' },
    { key: FILTERS.ACTIVE, label: '进行中' },
    { key: FILTERS.COMPLETED, label: '已完成' }
  ];

  return (
    <div className="todo-filter">
      <div className="filter-buttons">
        {filters.map(({ key, label }) => (
          <button
            key={key}
            onClick={() => onFilterChange(key)}
            className={`btn btn-sm ${
              currentFilter === key ? 'btn-primary' : 'btn-outline-primary'
            }`}
          >
            {label}
          </button>
        ))}
      </div>
      {hasCompleted && (
        <button
          onClick={onClearCompleted}
          className="btn btn-sm btn-outline-danger"
        >
          <i className="fas fa-trash"></i> 清除已完成
        </button>
      )}
    </div>
  );
}

export default TodoFilter;

9 统计组件:TodoStats

// components/TodoStats.js
import React from 'react';

function TodoStats({ todos }) {
  const total = todos.length;
  const completed = todos.filter(todo => todo.completed).length;
  const pending = total - completed;
  const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;

  return (
    <div className="todo-stats">
      <div className="stats-grid">
        <div className="stat-item">
          <div className="stat-label">总计</div>
          <div className="stat-value">{total}</div>
        </div>
        <div className="stat-item">
          <div className="stat-label">进行中</div>
          <div className="stat-value text-warning">{pending}</div>
        </div>
        <div className="stat-item">
          <div className="stat-label">已完成</div>
          <div className="stat-value text-success">{completed}</div>
        </div>
        <div className="stat-item">
          <div className="stat-label">完成率</div>
          <div className="stat-value">
            {completionRate}%
            <div className="progress" style={{ height: '5px', marginTop: '5px' }}>
              <div
                className="progress-bar bg-success"
                style={{ width: `${completionRate}%` }}
              ></div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

export default TodoStats;

CSS样式

/* App.css */
.App {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}

.App-header {
  text-align: center;
  margin-bottom: 30px;
  padding: 20px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border-radius: 10px;
}

.App-header h1 {
  margin: 0;
  font-size: 2.5rem;
}

.todo-app {
  background: white;
  border-radius: 10px;
  box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
  padding: 30px;
  margin-bottom: 30px;
}

.todo-form {
  margin-bottom: 30px;
}

.todo-form .input-group {
  margin-bottom: 10px;
}

.todo-list {
  margin: 20px 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px;
  margin-bottom: 10px;
  background: #f8f9fa;
  border-radius: 8px;
  border-left: 4px solid #007bff;
  transition: all 0.3s ease;
}

.todo-item.completed {
  border-left-color: #28a745;
  background: #f0fff4;
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #6c757d;
}

.todo-content {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 15px;
}

.todo-checkbox {
  width: 20px;
  height: 20px;
  cursor: pointer;
}

.todo-text {
  flex: 1;
  cursor: pointer;
  font-size: 1.1rem;
}

.todo-date {
  color: #6c757d;
  font-size: 0.85rem;
}

.todo-actions {
  display: flex;
  gap: 5px;
}

.todo-edit {
  display: flex;
  gap: 10px;
  flex: 1;
}

.todo-edit input {
  flex: 1;
}

.todo-edit-actions {
  display: flex;
  gap: 5px;
}

.todo-empty {
  text-align: center;
  padding: 40px 20px;
  color: #6c757d;
}

.todo-empty i {
  color: #dee2e6;
  margin-bottom: 15px;
}

.todo-filter {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 8px;
}

.filter-buttons {
  display: flex;
  gap: 10px;
}

.todo-stats {
  margin-top: 30px;
  padding: 20px;
  background: #f8f9fa;
  border-radius: 8px;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 20px;
}

.stat-item {
  text-align: center;
  padding: 15px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}

.stat-label {
  font-size: 0.9rem;
  color: #6c757d;
  margin-bottom: 5px;
}

.stat-value {
  font-size: 2rem;
  font-weight: bold;
}

footer {
  text-align: center;
  margin-top: 30px;
  padding-top: 20px;
  border-top: 1px solid #dee2e6;
  color: #6c757d;
}

/* 响应式设计 */
@media (max-width: 768px) {
  .App {
    padding: 10px;
  }

  .todo-app {
    padding: 20px;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }

  .todo-actions {
    justify-content: flex-end;
  }

  .todo-filter {
    flex-direction: column;
    gap: 15px;
  }

  .stats-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

功能扩展与优化

1. 添加动画效果

// components/AnimatedTodoItem.js
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';

function AnimatedTodoItem({ todo, onToggle, onDelete, onUpdate }) {
  const [isEditing, setIsEditing] = useState(false);

  return (
    <motion.div
      className={`todo-item ${todo.completed ? 'completed' : ''}`}
      initial={{ opacity: 0, y: -20 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, x: -100 }}
      transition={{ duration: 0.3 }}
      layout
    >
      <AnimatePresence>
        {isEditing ? (
          <motion.div
            className="todo-edit"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          >
            {/* 编辑模式内容 */}
          </motion.div>
        ) : (
          <motion.div
            className="todo-content"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          >
            {/* 展示模式内容 */}
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

2. 添加键盘快捷键

// hooks/useKeyboardShortcuts.js
import { useEffect } from 'react';

function useKeyboardShortcuts(onAdd, onClearCompleted) {
  useEffect(() => {
    const handleKeyDown = (e) => {
      // Ctrl + N: 聚焦到输入框
      if (e.ctrlKey && e.key === 'n') {
        e.preventDefault();
        document.querySelector('.todo-form input')?.focus();
      }

      // Ctrl + D: 清除已完成
      if (e.ctrlKey && e.key === 'd') {
        e.preventDefault();
        onClearCompleted();
      }

      // Ctrl + A: 全选/取消全选
      if (e.ctrlKey && e.key === 'a') {
        e.preventDefault();
        // 实现全选逻辑
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [onAdd, onClearCompleted]);
}

3. 添加拖拽排序

// 使用react-beautiful-dnd
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';

function DraggableTodoList({ todos, onReorder }) {
  const handleDragEnd = (result) => {
    if (!result.destination) return;

    const reorderedTodos = Array.from(todos);
    const [removed] = reorderedTodos.splice(result.source.index, 1);
    reorderedTodos.splice(result.destination.index, 0, removed);

    onReorder(reorderedTodos);
  };

  return (
    <DragDropContext onDragEnd={handleDragEnd}>
      <Droppable droppableId="todos">
        {(provided) => (
          <div
            {...provided.droppableProps}
            ref={provided.innerRef}
            className="todo-list"
          >
            {todos.map((todo, index) => (
              <Draggable key={todo.id} draggableId={todo.id.toString()} index={index}>
                {(provided) => (
                  <div
                    ref={provided.innerRef}
                    {...provided.draggableProps}
                    {...provided.dragHandleProps}
                  >
                    <TodoItem todo={todo} />
                  </div>
                )}
              </Draggable>
            ))}
            {provided.placeholder}
          </div>
        )}
      </Droppable>
    </DragDropContext>
  );
}
项目总结:
  1. 组件设计:合理的组件拆分和职责分离
  2. 状态管理:使用自定义Hook管理本地存储
  3. 用户体验:完善的增删改查功能
  4. 数据持久化:自动保存到localStorage
  5. 响应式设计:适配不同屏幕尺寸
  6. 代码质量:清晰的代码结构和注释

部署与发布

# 1. 安装依赖
npm install

# 2. 开发模式运行
npm start

# 3. 构建生产版本
npm run build

# 4. 部署到GitHub Pages
npm run deploy

# 5. 部署到Vercel
vercel

# 6. 部署到Netlify
netlify deploy --prod
常见问题:
  • localStorage限制:每个域名最多5MB存储空间
  • 性能优化:大量待办事项时考虑虚拟滚动
  • 数据安全:敏感数据不要存储在localStorage
  • 浏览器兼容:注意API的浏览器兼容性

进一步学习建议

功能升级
  • 添加分类和标签功能
  • 实现优先级设置
  • 添加截止日期提醒
  • 实现数据同步到云端
  • 添加搜索功能
技术升级
  • 集成TypeScript
  • 使用Redux或Context API
  • 添加单元测试
  • 实现PWA功能
  • 集成后端API